Compare commits

...

65 Commits

Author SHA1 Message Date
f38fba6506 fix: DisplayPath 所有层级加#N去重 + 移除旧格式兼容
- BuildNode 不再剥离父路径#N后缀
- FromModelItem 对每层祖先计算 siblingIndex 追加#N,与树加载一致
- 解决复制子树中节点别名泄露给其他子树的问题
- FromKey 移除纯 DisplayPath 旧格式兼容
2026-06-06 10:05:35 +08:00
7dec26b1ac fix: replace RowStyle IsSelected binding with SelectionChanged event
- RowStyle TwoWay binding on IsSelected is unreliable with virtualizing DataGrid
- Switched to DataGrid.SelectionChanged event handler in code-behind
- Syncs RemovedItems/AddedItems to ViewModel.IsSelected reliably
2026-06-04 21:42:07 +08:00
6b8bae2afc fix: clear all IsSelected flags after export to prevent stale selections
- Added finally block to reset IsSelected on all PathRoutes
- Prevents multi-select state from leaking into subsequent single exports
2026-06-04 21:36:52 +08:00
897280bb3f fix: show concatenated path names as dialog filename for multi-select export
- Single: path name as default filename (unchanged)
- Multi: semicolon-separated names as hint, each file still saved individually
2026-06-03 21:16:52 +08:00
1beaa7b24d Revert "fix: use folder dialog for multi-select export, file dialog for single"
This reverts commit 6d14d1d26f.
2026-06-03 21:16:28 +08:00
6d14d1d26f fix: use folder dialog for multi-select export, file dialog for single
- Single selection: SaveFileDialog with path name as default filename
- Multi selection: FolderBrowserDialog to pick output directory
- Export format: XML for multi (simpler UX), selectable for single
2026-06-03 21:11:41 +08:00
ac2de23e32 feat: support multi-select and batch export in path list
- DataGrid: SelectionMode Extended with RowStyle IsSelected binding
- PathRouteViewModel: add IsSelected property
- Export: iterates selected paths, saves each to separate file
- Ctrl/Shift+click to multi-select, then Export button batches them
2026-06-03 21:06:32 +08:00
c0714a5180 fix: regenerate path point IDs and rebuild edges on import rename
- Also regenerate all PathPoint.Id to avoid UNIQUE constraint on PathPoints.Id
- Call RecalculateRoute() to rebuild edges with new point references
- Edge constructor auto-generates new Id, so edges are fresh too
2026-06-03 20:58:49 +08:00
c6afef13de fix: generate new GUID when renaming imported path to avoid DB conflict
- Imported routes with same Id would overwrite each other in DB
- Now assign route.Id = Guid.NewGuid() on rename, ensuring unique DB entry
2026-06-03 20:54:08 +08:00
0cff495331 fix: prevent duplicate database save during XML import deserialization
- ParseRouteElement used route.AddPoint() which called RecalculateAndSaveRoute
- This saved the path to DB with original name before ImportPathCommand could rename it
- Changed to route.Points.Add() + route.RecalculateRoute() to skip DB save
- Only ImportPathCommand → AddRoute → SavePathToDatabase now persists renamed path
2026-06-03 20:44:52 +08:00
c2a37fe007 fix: only count rename when name actually changed during import
- Capture original name before GenerateUniqueName
- Skip RenamedCount increment if names are identical
- Fix misleading log showing same name twice
2026-06-03 20:34:49 +08:00
0967f7a9bc feat: show renamed count in import result dialog
- Add RenamedCount to ImportPathResult
- Track per-file and aggregate across multi-file imports
- Dialog now shows '重名自动重命名: N 个路径' when applicable
2026-06-03 20:29:28 +08:00
27ab485558 fix: use DialogHelper.ShowAutoClosingMessageBox for import result dialog
- 5s auto-close, test automation mode skips dialog
- Consistent with existing project patterns
2026-06-03 20:25:15 +08:00
ab0b5760d8 feat: show import result dialog after multi-file import 2026-06-03 20:22:58 +08:00
5692fde956 feat: rename export button, support multi-file import
- Rename '导出选中路径' to '导出', move before '导出全部'
- Import dialog: enable MultiSelect, loop through all selected files
- Aggregate import results across files
2026-06-03 20:17:19 +08:00
b3a8364e7e tweak: remove redundant '路径' from path creation button labels 2026-06-03 20:11:09 +08:00
a8a786d807 refactor: rename and reorder path creation buttons
- Rename '手动创建' to '地面路径'
- Rename '传统空轨路径' to '空轨路径'
- Move 空轨路径 after 吊装路径 (Ground → Hoisting → Rail order)
2026-06-03 20:07:33 +08:00
469f529fa0 tweak: increase path list DataGrid height from 200 to 340 (7→12 rows) 2026-06-03 20:02:51 +08:00
750559a560 refactor: collapsible test section with Grid layout in SystemManagement
- Wrap test buttons in Expander (default collapsed)
- Replace horizontal StackPanel with 3-column Grid layout
- Simplify button labels by removing redundant 'test' wording
2026-06-03 20:00:00 +08:00
6bc77672e9 fix: prevent crash when invalid characters typed in coordinate editor
EditableNumberConverter.ConvertBack was throwing FormatException
when users typed '=' or other non-numeric characters, causing
WPF binding validation to crash the application on focus change.
Changed to return Binding.DoNothing to silently reject invalid input.
2026-06-03 19:49:44 +08:00
0ed24b21a2 refactor: change virtual object RadioButton to CheckBox in AnimationControlView 2026-06-03 19:45:07 +08:00
c7a1eeebb8 feat: add FaceInferToolPlugin - single-click face normal inference tool
- New FaceInferToolPlugin: pixel-neighborhood method with {2,5,10}px offsets
- New FaceInferResult data class
- Test button in SystemManagement page
- Cursor: MarkupAutoPoly (arrow + dashed box)
- Uses project-standard tools: FindNamedParentContainer, viewpoint.Position
- Design doc at doc/design/2026/face-infer-tool-design.md
2026-06-02 23:11:11 +08:00
922864ce4f style: 别名树字体 SemiBold → Bold 2026-06-02 13:02:16 +08:00
337ccbb43b fix: OnTreeViewSelected NRE + 日志清理
- _isInternalSelection 为 true 时 OnTreeViewSelected 直接 return,阻断 NW→树→NW 循环
- 调试日志降为 Debug 级别
- doc.CurrentSelection 加空检查
2026-06-02 12:58:38 +08:00
8a8153dba0 fix: use ShouldUseReferenceBasedRealObjectPlanarPose to guard reference rotation capture
Ground and Hoisting paths don't consume fragment reference rotation, only Rail does.
Replace manual PathType checks with the existing semantic utility method.

Also fix GetPathRouteSync SELECT missing IsTwoPointVertical column.
2026-06-02 12:41:31 +08:00
93c143f75b docs: CHANGELOG 添加别名树功能 2026-06-02 00:13:58 +08:00
2ff4cb1678 fix: 2-point vertical path point modification no longer constrained by hoisting height limits
- Skip height constraints, orthogonalization, and intermediate point insertion
- Auto-recalculate LiftHeight from elevation difference between the two points
2026-06-01 23:55:04 +08:00
be5867f2a3 chore: bump version to 0.15.4 2026-06-01 23:32:05 +08:00
a8c5681dfb feat: separate 2-point vertical hoisting path from regular hoisting
- Add IsTwoPointVertical flag on PathRoute to distinguish pure vertical paths
- 2-point paths skip hoisting-specific logic: ResolveHoistingTrackedCenter,
  TryCreatePlanarPathRotationForFrame, ground-contact segment classification
- Endpoint semantics: center reaches endpoint (not top-face constrained)
- Frame interpolation applies same ResolveGroundTrackedCenter as move-to-start
- Add database migration for IsTwoPointVertical column
- Update CHANGELOG for v0.15.4
2026-06-01 23:31:00 +08:00
64a27f4c29 feat: add 2-point vertical hoisting path support
- Add '2点路径' button in AerialHeightDialog for pure vertical lift
- Handle IsTwoPointMode on first click to create 2-point path directly
- Fix PathTargetFrameResolver to accept 2-point Hoisting paths
- Fix animation yaw for 2-point vertical paths (use _currentYaw)
- Fix TryApplyPlanarRealObjectStartIncrementalTransform to translate even
  when no rotation change (was skipping translation for pure vertical)
- Guard _pathPoints[2] access in animation code for <3 points
2026-06-01 22:56:50 +08:00
a375e977d4 style: 别名树面板统一白色背景 + TreeView MinWidth=300 2026-06-01 11:04:18 +08:00
f8319438e9 fix: 无 DisplayName 的几何体节点显示为「几何对象」而非空字符 2026-06-01 10:39:57 +08:00
a5f9e4da24 feat: MaxFullDisplayDepth 改为从配置文件读取,支持实时生效
- SystemConfig 新增 AliasTreeConfig,含 MaxFullDisplayDepth 属性(默认 2)
- ConfigManager 添加 [alias_tree] 段的 TOML 解析和写入
- default_config.toml 添加 [alias_tree] 配置项
- AliasTreeControl 构造时读取配置 + 订阅 CategoryConfigurationChanged 实时重建
2026-06-01 10:30:21 +08:00
d670ae5c3c perf: BuildNode 延迟 CreatePathId + DisplayPath 同名去重修复
- BuildNode 不再调 FromModelItem/CreatePathId,改用父节点 DisplayPath 拼接
- CreatePathId 只在保存别名时通过 EnsureFullNodeKey 触发
- LazyLoadChildren 用 nameCount 字典计算 siblingIndex,BuildNode 追加 #N 去重
- 子节点 DisplayPath 拼接时剥离父路径中的 ||前缀和#N后缀,与 FromModelItem 一致
- LazyLoadChildren 直接用 parentNode.ModelItem,不再调 FindModelItemByNodeKey
- 别名匹配去掉 fallback,精确到#N
- _dataStore.LoadAll() 提升到循环外
2026-06-01 10:21:02 +08:00
61318b450d chore: DockPane 标题「别名导航树」→「别名树」 2026-06-01 09:12:28 +08:00
fddefde1ed docs: 更新别名树实施方案 — 新增显示原则 + 修正标识/定位/联动描述
- 新增第7节「别名树显示原则」:二条件OR逻辑、深度限制、别名特殊规则、按需加载生命周期
- 第4节:标识改为 CreatePathId格式,定位改为 Models.ResolvePathId
- 第10节:JSON导出格式改为 key 字段
- 第11节:双向联动改为 ModelItem.Parent链 + ExpandAndSelect
- 清理 CleanupOnDemandNodes 逻辑文档化
2026-06-01 09:10:47 +08:00
6260c175d4 fix: 别名树 IndexPath 改用 Navisworks 官方 API CreatePathId/ResolvePathId
- AliasNodeIdentity.FromModelItem: 用 doc.Models.CreatePathId 替代 GetModelItemIndexPaths
- 存储格式统一为 'modelIndex:PathId'(冒号分隔,PathId 为斜杠分隔)
- FindModelItemByNodeKey: 用 Models.ResolvePathId 替代手写 LocateByIndexPath
- 删除 LocateByIndexPath、LocateByDisplayPath、LoadPathChild、EnsureChildLoaded
- 与 PathAnimationManager/BatchQueueManager 使用完全一致的 PathId 格式
2026-05-31 23:26:54 +08:00
f7544a2095 fix: 别名树深度超限节点导航 — 改用 ModelItem.Parent 链替代 IndexPath
- 移除 IndexPath 解析/偏移量等易错逻辑
- ExpandAndSelect 改为沿 ModelItem.Parent 链回溯,ModelItem.Equals 匹配
- 深层节点通过 BuildNode 直接创建,不依赖占位/懒加载通道
- 删除 LoadPathChild、EnsureChildLoaded 等废弃方法
- 清理约 10 行净代码
2026-05-31 22:30:54 +08:00
ea49774f39 fix: no silent catch - log all exceptions 2026-05-31 18:14:09 +08:00
6a3be184a6 fix: crash from VisualTreeHelper.GetParent during rapid clicks
- OnTreeViewItemLeftClick: use e.OriginalSource.DataContext directly,
  not visual tree traversal (FindAncestor)
- _isProgrammaticExpand prevents Expanded event from triggering bulk
  LazyLoadChildren during ExpandAndSelect
- OnNavisSelectionChanged: synchronous processing with _isInternalSelection
  guard prevents feedback loop
- Try-catch wrappers on all COM API call sites (FromModelItem, BuildNode)
2026-05-31 18:12:34 +08:00
cba8e3f3b7 feat: depth-limited tree display for performance
- MaxFullDisplayDepth=2: only show full tree up to level 2
- Beyond max depth, nodes only expand if they have an alias
- AliasNodeViewModel.Depth tracks tree depth
- Reduces loaded nodes for large models with deep unaliased branches
2026-05-31 17:28:05 +08:00
7418fe28aa feat: JSON import/export for aliases + crash protection
- Export: JavaScriptSerializer, .alias.json format matching path data pattern
- Import: merge mode (overwrites existing keys, adds new)
- Try-catch wraps on SetAlias, RefreshAliases, NW selection operations
- Prevents crashes from database inconsistency or API exceptions
2026-05-31 17:10:32 +08:00
4da6669707 refactor: lazy database creation - match path planning pattern
- TryConnectToDatabase: createIfMissing=false (only connect, don't create)
- New BeforeSaveAlias event + EnsureDatabaseCreated: creates DB on first save
- Matches MainPlugin.TryConnectPathDatabase pattern
2026-05-31 16:56:57 +08:00
af7099d682 fix: alias persistence - trigger DatabaseInitialize on connect
- TryConnectToDatabase calls DatabaseInitialize(createIfMissing:true)
- Alias saving now creates .db file if it doesn't exist
- Removed delayed retry code (no longer needed)
- Added diagnostic logging for SetDataStore/RefreshAliases
2026-05-31 16:40:54 +08:00
903ed856e3 feat: display alias with [original name] suffix, fix false alias detection
- Two stacked TextBlocks: alias (red, bold) + [原文] (black, bold)
- [原文] suffix only visible when HasAlias=true
- CommitEdit/toolbar: treat unchanged text as no alias (set to null)
- Fixes: nodes edited without actual changes no longer show false [原文] suffix
2026-05-31 16:29:17 +08:00
d358c31a7d feat: inline editing with click-to-edit, TextBox overlay, Focus+SelectAll
- Grid overlay: TextBox covers TextBlock when editing (no more dual display)
- EditValue property: auto-initialized to DisplayName on edit start
- Click on already-selected node enters inline edit (PreviewMouseLeftButtonDown)
- Right-click auto-selects node before context menu
- TextBox IsVisibleChanged: Focus + SelectAll on Loaded priority
- XAML Visibility moved to Style setter (fix DataTrigger precedence)
- Light yellow background (#FFFFFDE6)
2026-05-31 15:52:03 +08:00
ad398652a7 feat: alias persistence via PathDatabase SQLite
- AliasTreePlugin 监听文档生命周期,连接 PathDatabase
- AliasDataStore 写入 PathDatabase.SQLiteConnection
- 编辑别名实时持久化,重启后自动恢复
- 修复 _isInternalSelection 阻塞 _currentSelectionItem 更新导致编辑写错节点
2026-05-31 14:59:54 +08:00
9a6859cdb5 style: match Navisworks Selection Tree font/color
- 别名用暗红色字体 (#C62828),原名用黑色
- 选中态:浅灰蓝底色 (#3A6EA5) + 白色字体,无焦点时也保持
- 字体加粗 SemiBold,字号 12px
- 移除斜体灰色样式和方块标记
2026-05-31 14:47:01 +08:00
b42cf02710 fix: bidirectional sync working for deep nodes + scroll-into-view
- 关掉 VirtualizingPanel 虚拟化(树是懒加载,虚拟化收益小但导致 ContainerFromItem 失败)
- ExpandAndSelect 重写为 IndexPath 纯 ViewModel 导航,不依赖 TreeViewItem
- 新增 GetTreeViewItemForNode:从 TreeView 根沿 parent 链找 Tvi
- IndexPath -1 修正(COM 路径从 1 开始计数,.NET Children 从 0)
- BringIntoView 延迟到 Dispatcher.Background
- 清理无用代码(ExpandAndSelectRecursive)
2026-05-31 14:21:50 +08:00
c002feee51 fix: ExpandAndSelect uses ModelItem.Equals for precise sibling matching
- 新增 AliasNodeViewModel.ModelItem 属性(持有 Navisworks 原生引用)
- BuildNode 中存储 ModelItem
- ExpandAndSelect 重写为 DFS 递归,用 ModelItem.Equals 精确匹配
  (同 ModelItemAnalysisHelper.ModelItemEquals 逻辑,底层原生对象比较)
- 不再依赖 IndexPath 或 _nodeMap 做选中同步
2026-05-31 13:17:52 +08:00
4feb12f6b9 refactor: AliasNodeIdentity dual-field + reuse ModelItemAnalysisHelper
- 新增 IndexPath(COM 树索引路径),复用 ModelItemAnalysisHelper.GetModelItemIndexPaths
- 保留 DisplayPath(父链 DisplayName 拼接)作为降级匹配
- 序列化格式:"IndexPath||DisplayPath",向后兼容纯 DisplayPath
- FindModelItemByNodeKey: IndexPath 优先定位,DisplayPath 降级
- BuildNode: 新增旧格式兼容查找
- 单元测试全面更新覆盖新/旧格式、双字段解析、前缀匹配
2026-05-31 12:52:26 +08:00
56d18c696c phase 2b: add AliasTreePlugin DockPanePlugin entry + wiring
- AliasTreePlugin.cs: DockPanePlugin with ElementHost wrapper
- XAML: wire Expanded/Selected event handlers via TreeViewItem
- Both projects compile, plugin deployed to Navisworks
2026-05-31 12:49:50 +08:00
f3392ddbf6 phase 2: VM + TreeView skeleton for alias tree
- AliasNodeViewModel: INotifyPropertyChanged, alias/display/editing state
- AliasTreeIcons.xaml: vector icon geometry + toolbar/button styles
- AliasTreeControl.xaml: TreeView with icon toolbar, status bar, context menu
- AliasTreeControl.xaml.cs: tree building, lazy loading, bidir selection sync,
  inline editing, alias highlight (via ModelHighlightHelper)
- Both projects compile successfully
2026-05-31 12:49:14 +08:00
3f985aa235 phase 1: data layer for alias tree
- AliasNodeIdentity: stable composite node identifier (path + sibling index)
- AliasDataStore: CRUD operations on SQLite AliasMap table
- PathDatabase: add AliasMap table creation + index
- Unit tests: 30 tests for identity parsing/equality, store CRUD/batch/import
- Both projects compile successfully
2026-05-31 12:46:33 +08:00
378bb6264c add edit tool usage notes to AGENTS.md 2026-05-30 19:17:30 +08:00
56a1ef55f4 0.15.3: misc fixes - rail helper cleanup, screenshot + button, database version 2026-05-30 19:15:34 +08:00
7189e17e3d fix indentation for AabbSx/Sy/Sz properties 2026-05-30 02:54:42 +08:00
1743704d78 update CHANGELOG for 0.15.3 fixes 2026-05-30 02:52:16 +08:00
1ca2a8e1bc 0.15.3: auto-adjust uses optimizer AABB directly for passage space visualization
- Optimizer now returns AABB extents (sx, sy, sz) from measured object
- UpdatePassageSpaceVisualization uses optimizer AABB directly:
  Forward=sx, Width=sz, Height=sy - no cuboid projection needed
- Fixed negative passageNormalToPath when lift offset is negative
- Removed host-axis cuboid projection from TryCalculateCurrentRealObjectPlanarProjectedExtents
2026-05-30 02:48:39 +08:00
b25a3a04a1 0.15.3: fix auto-adjust rotation order mismatch in CreateCanonicalRotationCorrection / CreateHostRotationCorrection
- Rotation multiplication order was qz*qy*qx (Z*Y*X) which didn't match
  MoveItemCombinedAxisRotationAndTranslation's up*nonUp*X order for YUp hosts.
  Switched to qup * qnonUp * qx using canonical space axes, works for both YUp and ZUp without HostType branching.
- ComputedLiftOffsetMeters -> ComputedLiftOffsetModelUnits, removed intermediate meter conversions.
- EditRotationWindow auto-adjust button now shows wait cursor.
2026-05-30 00:22:42 +08:00
af141aa37c 自动调整:去掉米单位混用,内部统一模型单位;ComputedLiftOffsetMeters -> ComputedLiftOffsetModelUnits;EditRotationWindow 添加等待光标 2026-05-30 00:01:20 +08:00
90367db214 自动调整Info日志改为Debug 2026-05-29 01:49:39 +08:00
c67dee4f11 约束保留门型偏好,评估改用固定宿主X轴前向,结果加回路径yaw 2026-05-29 01:44:23 +08:00
649c7b50f1 0.15.2 WIP: 自动调整 — 门型偏好、物流车高、上下偏移自动计算、性能优化、日志清理 2026-05-28 23:58:27 +08:00
e224e332a3 更新版本号至 0.15.2,更新 CHANGELOG 和 todo_features
- VERSION.md: 0.15.0 → 0.15.2
- CHANGELOG.md: 新增 [0.15.2] 条目(自动最优朝向、Y 轴旋转按钮、上下偏移、
  性能优化、布局优化)
- todo_features.md: 新增 [2026/5/27] 条目,4 项已完成
2026-05-27 15:42:28 +08:00
46 changed files with 6045 additions and 773 deletions

View File

@ -146,3 +146,22 @@ var rotation = new Rotation3D(qx, qy, qz, qw);
4. `doc/design/2026/NavisworksAPI使用方法.md`
5. `.agents/skills/geometry-transform/SKILL.md`(几何/变换/姿态/坐标)
6. `.agents/skills/nw-api/SKILL.md`
---
## 9. 工具使用提示
### edit 工具的正确格式
```json
{
"path": "src/.../File.cs",
"edits": [
{ "oldText": "原文本(必须完全匹配)", "newText": "新文本" }
]
}
```
- `path` 是顶层字段,不在 `edits` 数组内
- `oldText` 不能有歧义(匹配多个时提供更多上下文)
- 多个不重叠的编辑可以放在同一个 `edits` 数组中

View File

@ -1,5 +1,97 @@
# NavisworksTransport 变更日志
## [0.15.5] - 2026-06-02
### ✨ 新功能
- **快速取面工具 `FaceInferToolPlugin`**:新增独立 ToolPlugin单点+像素邻域法快速推断点击面法向量。
- 算法:一次点击内做三次 `PickItemFromPoint`(中心、右侧 δ px、下侧 δ px叉积求法向量δ 递进 {2, 5, 10} px 规避共线
- 朝向统一:法向量自动翻转到指向相机一侧(`viewpoint.Position` 与命中点对比)
- 构件识别:沿 `ModelItemAnalysisHelper.FindNamedParentContainer` 向上查找命名容器
- 光标:`Cursor.MarkupAutoPoly`(箭头+虚线小方块),与取点工具的 `Measure` 十字光标区分
- 与 `PathClickToolPlugin` 并列,互斥激活,结构完全平行
- **`FaceInferResult`**结果数据类包含命中点宿主坐标、归一化法向量、ModelItem
- **系统管理功能测试按钮**:页签底部新增「🧭 快速取面测试」,点击后激活工具,用户点击模型面 → 弹出结果对话框
### 📝 文档
- 新增 `doc/design/2026/face-infer-tool-design.md` 设计文档
## [0.15.4] - 2026-06-01
### ✨ 新功能
- **别名树面板**:新增独立 DockPane 面板,提供自定义节点别名管理功能。
- 别名导航树:按模型层级展示节点,支持深度限制配置(`max_full_display_depth`),超限节点不显示但可通过选中联动展开
- 内联编辑:双击/F2/点击已选中节点进入编辑,保存后红色高亮显示
- 别名持久化:使用 `CreatePathId` / `ResolvePathId` 官方 API存储到 `AliasMap` 表,重启后自动恢复
- 双向联动:选择树选中 → 别名树展开并定位;别名树选中 → 3D 视图聚焦(沿 `ModelItem.Parent` 链导航)
- JSON 导入导出:支持 `.alias.json` 格式的别名数据交换
- 配置实时生效:`max_full_display_depth` 从 `config.toml` 读取,修改后无需重启
- 性能优化:`BuildNode` 热路径不走 COM 调用,`CreatePathId` 仅在保存别名时触发;同名节点通过 `#N` 后缀去重
- **2点纯垂直吊装路径**:吊装高度对话框新增"2点路径"按钮,生成仅含起吊点和终点的纯垂直路径(无水平段/下降段),用于简单垂直吊运场景。
### 🐛 Bug修复
- **修复 `TryApplyPlanarRealObjectStartIncrementalTransform` 纯旋转变化时跳过平移**:纯垂直路径 deltaYaw=0 且无角度修正时,`if` 条件不成立导致物体未移动到起点。改为合并判断 `hasRotation || hasTranslation`
- **修复 2 点吊装路径动画 yaw 计算错误**`Atan2(dy, 0)` 对纯垂直段算出 90°物体被强制旋转。2 点路径改用 `_currentYaw` 保持初始朝向。
- **修复 `PathTargetFrameResolver` 拒绝 2 点 Hoisting 路径**`Count >= 3` → `Count >= 2`2 点路径用点1-点0做方向。
- **修复动画帧循环 `_pathPoints[2]` 越界**2 点路径加 `Count >= 3` 保护。
- **修复 2 点路径帧循环与起点落位补偿不一致**:起点用 `ResolveGroundTrackedCenter` 加半高补偿,帧循环也用同样的 `startCenter → endCenter` 插值,消除动画开始时的下坠。
### 🔧 改进
- **`PathRoute` 新增 `IsTwoPointVertical` 标记**2 点路径在创建时置 `true`,动画代码检查此标记走简化链路(跳过 `ResolveHoistingTrackedCenter`、`TryCreatePlanarPathRotationForFrame` 等吊装特定逻辑)。
- **`PathDatabase` 自动迁移**:新增 `IsTwoPointVertical` 列,旧数据库自动 `ALTER TABLE ADD COLUMN`,旧路径默认为 `false`
### 🐛 Bug修复
- **修复自动调整高度计算偏移错误**`CreateCanonicalRotationCorrection` 和 `CreateHostRotationCorrection` 的旋转乘法顺序 `qz * qy * qx`Z*Y*X`MoveItemCombinedAxisRotationAndTranslation``q3(up) * q2(nonUp) * q1(X)` 不一致。YUp 下顺序应为 Y*Z*X 而非 Z*Y*X导致投影计算使用的四元数与 Navisworks 实际应用的旋转不匹配,`GetAnimatedObjectGroundContactHeight` 返回的投影尺寸偏差 60%+,最终物体高度定位偏移 0.083m。
改用 Canonical Space 的标准轴(`forward=canonicalHostX`, `up=CanonicalUpVector3`, `nonUp=cross(forward, up)`)推导 `qup * qnonUp * qx`,自动兼容 YUp 和 ZUp不引入 `HostType` 分支。
- **修复自动调整后路径可视化宽度错误**:优化器通过实际测量 AABB 得到正确截面尺寸,但 `TryCalculateCurrentRealObjectPlanarProjectedExtents` 使用 cuboid 投影(固定的 host 轴)重算尺寸,路径斜时方向错、非立方体时尺寸虚大。
改为优化器绕过 cuboid 投影,直接将实测 AABB 尺寸(`sx/sy/sz`)返回给 `UpdatePassageSpaceVisualization`,直接用作 passage space 的 forward/side/up 尺寸。
- **修复负 lift 导致通行空间消失**`passageNormalToPath` 公式加入了负 lift物体压低使高度计算为负通行空间线框被渲染插件拒绝。改为 `Math.Max(0, lift)`,负 lift 不压缩通行空间高度。
### 🔧 改进
- **自动调整内部统一使用模型单位**`ObjectPassageProjectionOptimizationResult.ComputedLiftOffsetMeters` 改为 `ComputedLiftOffsetModelUnits`,移除中间 `ConvertToMeters`/`ConvertFromMeters` 的反复转换,只在 UI 边界(`EditRotationWindow.OnAutoAdjustClick`)做一次转换。
- **删除动画控制视图无用控件**:移除 `AnimationControlView.xaml` 中无绑定的"选择模型物体" RadioButton。
- **碰撞检测报告截图 + 按钮始终可见**`CollisionReportDialog.xaml` 缩略图区域的 `Visibility` 绑定从外层 `Border` 移到内层 `ItemsControl`(缩略图),`+` 按钮始终可见。
- **Rail 路径重选起点时清除安装辅助线**`ExecuteSelectAssemblyStartPointAsync` 和 `ExecuteRepositionRailStartPointAsync` 进入起点选择前调用 `ClearAssemblyInstallationReferenceVisuals()` 隐藏安装点辅助线。
- **数据库初始化设置版本号**`PathDatabase.InitializeDatabase()` 创建表后执行 `PRAGMA user_version = 10000`,使"修复数据库"正确显示版本信息。
---
## [0.15.2] - 2026-05-27
### 🚀 新功能
- **自动最优朝向(自动调整)**:新增 `ObjectPassageProjectionOptimizer`,通过测量物体在候选角度下的实际 AABB 在路径横截面上的投影面积,自动搜索使通行空间最小的 X/Y/Z 旋转角度。支持粗网格搜索8³=512 次评估)+ 模式搜索细化,最终结果写入调整物体窗口的三个角度输入框。
- **Y 轴增加旋转 90° 按钮**:在调整物体窗口的 Y 轴输入框后增加 ↻ 图标按钮,点击使当前 Y 角度值增加 90°并自动归一化到 [0, 360) 范围。使用与编辑坐标窗口一致的 `IconButtonStyle` + `RotateIconGeometry`Material Refresh 图标)。
### 🔧 改进
- **提升高度→上下偏移**:调整物体窗口的"提升高度"字段改为"上下偏移",允许输入负值(向下偏移),同步更新日志、状态栏、注释中的术语。修复 `ResolveGroundPathObjectLiftOffsetVector``ApplyGroundPathObjectLiftOffset` 中两处 `<= 0.0` 拦截负值的问题。
- **自动调整性能优化**:在 `TryApplyPlanarRealObjectStartIncrementalTransform` 中将三次独立旋转-平移序列X、非-up、up 轴)合并为单次 `MoveItemCombinedAxisRotationAndTranslation` 调用使用四元数合成组合旋转。NW API 调用从 ~10 次/轮降至 ~3 次/轮,自动调整耗时从 5s+ 降至 <2s
- **窗口布局优化**:调整物体窗口宽度 440→4703 列统一布局,移除冗余副标题和提示文字,快捷角度和底部按钮居中分布。
---
## [0.15.1] - 2026-05-26
### 🐛 Bug修复

View File

@ -52,10 +52,15 @@
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Data.SQLite">
<HintPath>..\TransportPlugin\packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.118.0\lib\net46\System.Data.SQLite.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="UnitTests\Core\AliasTreeTests.cs" />
<Compile Include="UnitTests\Core\PathHelperTests.cs" />
<Compile Include="UnitTests\Core\SelectionClipBoxLockStateTests.cs" />
<Compile Include="UnitTests\Core\PathPlanningManagerHoistingCompletionTests.cs" />

View File

@ -106,6 +106,8 @@
<!-- Core - Main Plugin Files -->
<Compile Include="src\Core\MainPlugin.cs" />
<Compile Include="src\Core\PathClickToolPlugin.cs" />
<Compile Include="src\Core\FaceInferToolPlugin.cs" />
<Compile Include="src\Core\FaceInferResult.cs" />
<Compile Include="src\Core\PathInputMonitor.cs" />
<Compile Include="src\Core\PathPointRenderPlugin.cs" />
<!-- Core - Business Logic -->
@ -146,6 +148,9 @@
<Compile Include="src\Core\Config\ConfigManager.cs" />
<Compile Include="src\Core\Config\CustomCategoryConfig.cs" />
<Compile Include="src\Core\Database\BackupManager.cs" />
<Compile Include="src\Core\AliasTree\AliasNodeIdentity.cs" />
<Compile Include="src\Core\AliasTree\AliasDataStore.cs" />
<Compile Include="src\Core\AliasTree\AliasTreePlugin.cs" />
<Compile Include="src\Core\Services\TimeTagCalculator.cs" />
<Compile Include="src\Core\Services\TimeTagService.cs" />
<Compile Include="src\Core\Services\TimeTagExporter.cs" />
@ -216,6 +221,9 @@
<Compile Include="src\UI\WPF\Views\LogisticsControlPanel.xaml.cs">
<DependentUpon>LogisticsControlPanel.xaml</DependentUpon>
</Compile>
<Compile Include="src\UI\WPF\Views\AliasTreeControl.xaml.cs">
<DependentUpon>AliasTreeControl.xaml</DependentUpon>
</Compile>
<Compile Include="src\UI\WPF\Views\ModelSettingsView.xaml.cs">
<DependentUpon>ModelSettingsView.xaml</DependentUpon>
</Compile>
@ -286,6 +294,7 @@
<Compile Include="src\UI\WPF\ViewModels\ModelSettingsViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\AnimationControlViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\PathEditingViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\AliasNodeViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\RailAssemblyWorkflowContext.cs" />
<Compile Include="src\UI\WPF\ViewModels\SystemManagementViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\CollisionReportViewModel.cs" />
@ -389,6 +398,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="src\UI\WPF\Views\AliasTreeControl.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="src\UI\WPF\Views\EditCoordinatesWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
@ -506,6 +519,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="src\UI\WPF\Resources\AliasTreeIcons.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Microsoft\WinFX\3.0\Microsoft.WinFX.targets" Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\WinFX\3.0\Microsoft.WinFX.targets')" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

View File

@ -0,0 +1,361 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NavisworksTransport.Core.AliasTree;
using System;
using System.Data.SQLite;
using System.IO;
namespace NavisworksTransport.UnitTests.Core
{
[TestClass]
public class AliasNodeIdentityTests
{
// ── 构造 ──
[TestMethod]
public void Constructor_ShouldSetBothFields()
{
var id = new AliasNodeIdentity("0,2,5", "building/floor/wall#1");
Assert.AreEqual("0,2,5", id.IndexPath);
Assert.AreEqual("building/floor/wall#1", id.DisplayPath);
}
[TestMethod]
public void Constructor_WithEmptyIndexPath_IsValid()
{
var id = new AliasNodeIdentity("", "building/floor");
Assert.AreEqual("", id.IndexPath);
Assert.AreEqual("building/floor", id.DisplayPath);
}
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Constructor_NullDisplayPath_ShouldThrow()
{
new AliasNodeIdentity("0,0", null);
}
// ── 序列化 ──
[TestMethod]
public void ToKey_WithBothFields_ShouldFormatCorrectly()
{
var id = new AliasNodeIdentity("0,2,5", "模型/楼层/墙#0");
Assert.AreEqual("0,2,5||模型/楼层/墙#0", id.ToKey());
}
[TestMethod]
public void ToKey_WithEmptyIndexPath_ShouldReturnDisplayPathOnly()
{
var id = new AliasNodeIdentity("", "模型/楼层/墙#0");
Assert.AreEqual("模型/楼层/墙#0", id.ToKey());
}
[TestMethod]
public void FromKey_NewFormat_ShouldParseCorrectly()
{
var id = AliasNodeIdentity.FromKey("0,2,5||模型/楼层/墙#0");
Assert.AreEqual("0,2,5", id.IndexPath);
Assert.AreEqual("模型/楼层/墙#0", id.DisplayPath);
}
[TestMethod]
public void FromKey_OldFormat_ShouldRetroCompatible()
{
var id = AliasNodeIdentity.FromKey("building/floor/wall#2");
Assert.AreEqual("", id.IndexPath);
Assert.AreEqual("building/floor/wall#2", id.DisplayPath);
}
[TestMethod]
public void FromKey_OldFormatNoIndex_ShouldParse()
{
var id = AliasNodeIdentity.FromKey("building/floor");
Assert.AreEqual("", id.IndexPath);
Assert.AreEqual("building/floor", id.DisplayPath);
}
[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public void FromKey_EmptyString_ShouldThrow()
{
AliasNodeIdentity.FromKey("");
}
[TestMethod]
[ExpectedException(typeof(FormatException))]
public void FromKey_EmptyDisplayAfterSeparator_ShouldThrow()
{
AliasNodeIdentity.FromKey("0,0||");
}
// ── TryParse ──
[TestMethod]
public void TryParseKey_NewFormat_ShouldSucceed()
{
Assert.IsTrue(AliasNodeIdentity.TryParseKey("0,0||path#0", out var id));
Assert.AreEqual("0,0", id.IndexPath);
Assert.AreEqual("path#0", id.DisplayPath);
}
[TestMethod]
public void TryParseKey_OldFormat_ShouldSucceed()
{
Assert.IsTrue(AliasNodeIdentity.TryParseKey("path#0", out var id));
Assert.AreEqual("", id.IndexPath);
}
[TestMethod]
public void TryParseKey_Empty_ShouldReturnFalse()
{
Assert.IsFalse(AliasNodeIdentity.TryParseKey("", out _));
}
// ── 查询辅助 ──
[TestMethod]
public void GetHierarchicalPath_WithIndex_ShouldStripSuffix()
{
var id = new AliasNodeIdentity("0,0", "a/b/c#2");
Assert.AreEqual("a/b/c", id.GetHierarchicalPath());
}
[TestMethod]
public void GetHierarchicalPath_WithoutIndex_ShouldReturnFull()
{
var id = new AliasNodeIdentity("0,0", "a/b/c");
Assert.AreEqual("a/b/c", id.GetHierarchicalPath());
}
[TestMethod]
public void GetSiblingIndex_WithIndex_ShouldReturn()
{
var id = new AliasNodeIdentity("0,0", "a/b#3");
Assert.AreEqual(3, id.GetSiblingIndex());
}
[TestMethod]
public void GetSiblingIndex_WithoutIndex_ShouldReturnZero()
{
var id = new AliasNodeIdentity("0,0", "a/b");
Assert.AreEqual(0, id.GetSiblingIndex());
}
[TestMethod]
public void HasPathPrefix_Matching_ShouldReturnTrue()
{
var id = new AliasNodeIdentity("0,0", "building/floor/wall#0");
Assert.IsTrue(id.HasPathPrefix("building/floor"));
Assert.IsTrue(id.HasPathPrefix("building"));
Assert.IsTrue(id.HasPathPrefix("building/floor/wall"));
}
[TestMethod]
public void HasPathPrefix_NonMatching_ShouldReturnFalse()
{
var id = new AliasNodeIdentity("0,0", "building/floor/wall#0");
Assert.IsFalse(id.HasPathPrefix("building/roof"));
Assert.IsFalse(id.HasPathPrefix("Build"));
}
// ── 值语义 ──
[TestMethod]
public void Equals_Identical_ShouldMatch()
{
var a = new AliasNodeIdentity("0,1", "path#0");
var b = new AliasNodeIdentity("0,1", "path#0");
Assert.AreEqual(a, b);
Assert.IsTrue(a == b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void Equals_DifferentIndexPath_ShouldNotMatch()
{
var a = new AliasNodeIdentity("0,1", "path");
var b = new AliasNodeIdentity("0,2", "path");
Assert.AreNotEqual(a, b);
}
[TestMethod]
public void Equals_DifferentDisplayPath_ShouldNotMatch()
{
var a = new AliasNodeIdentity("0,1", "path/a");
var b = new AliasNodeIdentity("0,1", "path/b");
Assert.AreNotEqual(a, b);
}
[TestMethod]
public void ToString_ShouldMatchToKey()
{
var id = new AliasNodeIdentity("0,1", "path#0");
Assert.AreEqual(id.ToKey(), id.ToString());
}
}
[TestClass]
public class AliasDataStoreTests
{
private string _dbPath;
private SQLiteConnection _connection;
[TestInitialize]
public void Setup()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"AliasDataStoreTest_{Guid.NewGuid():N}.db");
_connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;");
_connection.Open();
}
[TestCleanup]
public void Cleanup()
{
_connection?.Close();
_connection?.Dispose();
try { File.Delete(_dbPath); } catch { }
}
private AliasDataStore CreateStore()
{
var store = new AliasDataStore(_connection);
store.EnsureTableExists();
return store;
}
private AliasNodeIdentity Id(string displayPath, string indexPath = "")
=> new AliasNodeIdentity(indexPath, displayPath);
// ── CRUD ──
[TestMethod]
public void SetAndGetAlias_ShouldReturnSavedValue()
{
var store = CreateStore();
store.SetAlias(Id("building/floor", "0,1"), "1F-办公层");
Assert.AreEqual("1F-办公层", store.GetAlias(Id("building/floor", "0,1")));
}
[TestMethod]
public void GetAlias_Nonexistent_ShouldReturnNull()
{
var store = CreateStore();
Assert.IsNull(store.GetAlias(Id("nonexistent")));
}
[TestMethod]
public void SetAlias_Overwrite_ShouldUpdateValue()
{
var store = CreateStore();
store.SetAlias(Id("wall", "0,0"), "旧名称");
store.SetAlias(Id("wall", "0,0"), "新名称");
Assert.AreEqual("新名称", store.GetAlias(Id("wall", "0,0")));
}
[TestMethod]
public void SetAlias_EmptyString_ShouldDelete()
{
var store = CreateStore();
store.SetAlias(Id("x", "0,0"), "原本");
Assert.AreEqual(1, store.Count);
store.SetAlias(Id("x", "0,0"), " ");
Assert.IsNull(store.GetAlias(Id("x", "0,0")));
Assert.AreEqual(0, store.Count);
}
[TestMethod]
public void DeleteAlias_ShouldRemoveEntry()
{
var store = CreateStore();
store.SetAlias(Id("x"), "别名");
Assert.AreEqual(1, store.Count);
store.DeleteAlias(Id("x"));
Assert.AreEqual(0, store.Count);
}
// ── 批量 ──
[TestMethod]
public void DeleteByPathPrefix_ShouldRemoveAllUnderPath()
{
var store = CreateStore();
store.SetAlias(Id("building/floor/1"));
store.SetAlias(Id("building/floor/2"));
store.SetAlias(Id("building/roof/3"));
store.DeleteAliasesByPathPrefix("building/floor");
Assert.IsNull(store.GetAlias(Id("building/floor/1")));
Assert.IsNull(store.GetAlias(Id("building/floor/2")));
Assert.IsNotNull(store.GetAlias(Id("building/roof/3")));
}
[TestMethod]
public void LoadAll_ShouldReturnAll()
{
var store = CreateStore();
store.SetAlias(Id("a", "0,0"), "别名A");
store.SetAlias(Id("b", "1,0"), "别名B");
var all = store.LoadAll();
Assert.AreEqual(2, all.Count);
Assert.AreEqual("别名A", all[Id("a", "0,0").ToKey()]);
Assert.AreEqual("别名B", all[Id("b", "1,0").ToKey()]);
}
[TestMethod]
public void Count_ShouldReflectInserts()
{
var store = CreateStore();
Assert.AreEqual(0, store.Count);
store.SetAlias(Id("a"), "alias1");
store.SetAlias(Id("b"), "alias2");
Assert.AreEqual(2, store.Count);
}
// ── ImportBatch ──
[TestMethod]
public void ImportBatch_ShouldMergeAndOverwrite()
{
var store = CreateStore();
store.SetAlias(Id("existing", "0,0"), "保留值");
var entries = new System.Collections.Generic.Dictionary<string, string>
{
{ Id("existing", "0,0").ToKey(), "覆盖值" },
{ Id("new", "1,0").ToKey(), "新值" }
};
store.ImportBatch(entries);
Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", "0,0")));
Assert.AreEqual("新值", store.GetAlias(Id("new", "1,0")));
Assert.AreEqual(2, store.Count);
}
[TestMethod]
public void ClearAll_ShouldRemoveEverything()
{
var store = CreateStore();
store.SetAlias(Id("a"), "别名1");
store.SetAlias(Id("b"), "别名2");
store.ClearAll();
Assert.AreEqual(0, store.Count);
}
// ── 新格式 → 旧格式 兼容 ──
[TestMethod]
public void OldFormatKey_StillWorks()
{
var store = CreateStore();
string oldKey = "building/floor/wall#0";
store.SetAlias(AliasNodeIdentity.FromKey(oldKey), "别名");
Assert.AreEqual("别名", store.GetAlias(AliasNodeIdentity.FromKey(oldKey)));
// 同一个节点用新格式读取
var newId = new AliasNodeIdentity("", "building/floor/wall#0");
Assert.AreEqual("别名", store.GetAlias(newId));
}
}
}

View File

@ -1,3 +1,3 @@
# 版本号
0.15.0
0.15.4

View File

@ -0,0 +1,111 @@
# 快速取面工具设计
创建时间2026-06-02
## 目标
新增一个独立 ToolPlugin通过单点+像素邻域法快速推断鼠标点击位置所在面的法向量。
和取点工具 `PathClickToolPlugin` 并列,互斥激活。
## 核心算法
单点+像素邻域法 —— 一次点击内做三次 `PickItemFromPoint`
```
中心点: PickItemFromPoint(x, y) → p0
右邻域: PickItemFromPoint(x+δ, y) → p1
下邻域: PickItemFromPoint(x, y+δ) → p2
v1 = p1 - p0
v2 = p2 - p0
normal = normalize(cross(v1, v2))
```
偏移量 δ 三级递进2px → 5px → 10px小偏移共线时自动退到更大偏移。
## 新增文件
| 文件 | 职责 |
|---|---|
| `src/Models/FaceInferResult.cs` | 结果数据类 |
| `src/Core/FaceInferToolPlugin.cs` | ToolPlugin 主体 |
不修改任何现有文件。
## 架构
```
FaceInferToolPlugin : ToolPlugin
├── Plugin("FaceInferTool", "NavisworksTransport")
├── Static 成员
│ ├── event FaceInferred # 外部订阅事件
│ ├── AssemblyPath # 用于动态加载
│ └── TriggerFaceInferred() # 允许外部手动触发
├── GetCursor → Cursor.MarkupQuickPick
├── MouseDown(x, y)
│ ├── centerPick = PickItemFromPoint(x, y)
│ ├── rightPick = PickItemFromPoint(x + δ, y)
│ ├── downPick = PickItemFromPoint(x, y + δ)
│ ├── 同构件校验 (ModelItem 比较)
│ ├── 共线校验 (cross.Length < 0.001 退到下一级 δ)
│ ├── 法向量归一化
│ ├── 朝向统一 (dot with viewDir, 始终指向相机)
│ └── 触发 FaceInferred 事件
└── MouseMove → 暂不处理(轻量原则)
```
## FaceInferResult 数据结构
```csharp
public class FaceInferResult
{
public Point3D HitPoint { get; } // 命中点(宿主坐标系)
public Vector3D Normal { get; } // 面法向量(已归一化,朝向相机)
public ModelItem ModelItem { get; } // 命中的 ModelItem
public bool IsValid { get; } // 推断是否成功
}
```
所有坐标均为**宿主坐标系**`PickItemFromPoint` 原始输出)。
## 激活方式
`PathClickToolPlugin` 完全相同的流程,仅插件名不同:
```csharp
var assemblyPath = FaceInferToolPlugin.AssemblyPath;
Application.Plugins.AddPluginAssembly(assemblyPath);
var record = (ToolPluginRecord)Application.Plugins.FindPlugin("FaceInferTool.NavisworksTransport");
var plugin = record.LoadPlugin();
Application.MainDocument.Tool.SetCustomToolPlugin(plugin);
```
## 光标
使用 `Cursor.MarkupQuickPick`。Navisworks `Cursor` 枚举无平行四边形,此为最接近的选项。
## 与 PathClickToolPlugin 的关系
- 两者都是 `ToolPlugin`,同一时刻只有一个激活
- 互斥切换:取面时取点失活,取面结束切回取点
- 切换流程与项目中已有的「导航工具 ↔ ClickTool」切换模式一致
## 暂不实现
| 项目 | 原因 |
|---|---|
| 透视近大远小动态 δ 调整 | 先验证精度,不够再加 |
| 跨面距离检测 (maxDist) | 同构件判断已覆盖基本场景 |
| 曲面曲率分析 | 不同业务需求 |
| MouseMove 实时预览 | 保持轻量 |
## 下一步
1. 实现两个新文件
2. 在已知平面上验证法向量精度(误差应在 1° 以内)
3. 后续由调用方ViewModel按需集成激活/切换逻辑

View File

@ -0,0 +1,680 @@
# 别名树(节点重命名)实施方案
> 基于 [节点重命名参考方案.md](./节点重命名参考方案.md) 的评估与完善。
> 对应评估结论中 P0/P1/P2 的全部整改点。
---
## 1. 目标
为用户提供「给 Selection Tree 中任意节点打别名」的能力,解决 Revit 导入后 DisplayName 不可读(全叫"楼层""墙""管道")、无法靠名字快速定位的问题。
不做原生树扩展,走 **独立别名面板 + 自管数据层** 路线。
---
## 2. 架构概览
```
┌──────────────────────────┐
│ Navisworks 主窗口 │
│ ┌──────────┬──────────┐ │
│ │ Selection │ 3D View │ │
│ │ Tree │ │ │
│ │(原生只读) │ │ │
│ ├──────────┤ │ │
│ │ 别名导航树 │ │ │ ← 新增独立 DockPanePlugin
│ │(可编辑别名)│ │ │
│ └──────────┴──────────┘ │
│ │
│ SQLite: DocName.db │
│ ├─ Paths (已有) │
│ ├─ Collisions (已有) │
│ └─ AliasMap (新增) │ ← 别名持久化
└──────────────────────────┘
```
独立 `DockPanePlugin`,不并入 `LogisticsControlPanel`。用户在 **View → Docking Windows → 别名导航树** 打开后可手动 dock 到 Selection Tree 下方。
---
## 3. 文件清单(新增 9 个文件)
| 文件 | 职责 |
|------|------|
| `src/Core/AliasTree/AliasTreePlugin.cs` | DockPanePlugin 入口 + ElementHost |
| `src/Core/AliasTree/AliasNodeIdentity.cs` | 稳定的复合节点标识(替代 InstanceGuid |
| `src/Core/AliasTree/AliasNodeViewModel.cs` | 单节点 VM含 INotifyPropertyChanged |
| `src/Core/AliasTree/AliasDataStore.cs` | 别名数据的 CRUD + 与 PathDatabase 对接 |
| `src/UI/WPF/Views/AliasTreeControl.xaml` | TreeView 布局 + 图标工具栏 + 右键菜单 |
| `src/UI/WPF/Views/AliasTreeControl.xaml.cs` | 核心逻辑:联动、编辑、批量、高亮、导入导出 |
| `src/UI/WPF/Views/BatchAliasDialog.xaml` | 批量编辑对话框 |
| `src/UI/WPF/Views/BatchAliasDialog.xaml.cs` | 批量编辑逻辑 |
| `src/UI/WPF/Resources/AliasTreeIcons.xaml` | 工具栏图标Path Geometry 矢量图标) |
---
## 4. 稳定节点标识设计(核心创新点)
### 4.1 为什么不用 InstanceGuid
Revit 重新导出 NWC 再 append 进 NWD 后,`InstanceGuid` 可能整体变化。别名必须跨文件更新存活。
### 4.2 复合标识方案(双字段)
```csharp
public readonly struct AliasNodeIdentity
{
/// <summary>Navisworks 官方 PathIdCreatePathId 格式: "modelIndex:0/1/2"</summary>
public string IndexPath { get; }
/// <summary>父链 DisplayName 拼接路径(可读、调试用)</summary>
public string DisplayPath { get; }
/// <summary>序列化为 DB 主键: "IndexPath||DisplayPath"</summary>
public string ToKey() => $"{IndexPath}||{DisplayPath}";
public static AliasNodeIdentity FromModelItem(ModelItem item)
{
// IndexPath: 使用 Navisworks 官方 API CreatePathId
var pid = Application.ActiveDocument.Models.CreatePathId(item);
string indexPath = $"{pid.ModelIndex}:{pid.PathId}";
// DisplayPath: 父链 DisplayName 拼接(如 "模型/楼层/墙#0"
var parts = new List<string>();
var current = item;
while (current != null) { parts.Insert(0, current.DisplayName ?? "?"); current = current.Parent; }
string displayPath = string.Join("/", parts);
// 同级同名去重
int siblingIndex = ComputeSiblingIndex(item);
if (siblingIndex > 0) displayPath += $"#{siblingIndex}";
return new AliasNodeIdentity(indexPath, displayPath);
}
}
```
### 4.3 回溯定位
使用 Navisworks 官方 API `Models.ResolvePathId()`,与项目路径功能(`BatchQueueManager`、`PathAnimationManager`)完全一致:
```csharp
// IndexPath 格式: "modelIndex:PathId"(如 "0:0/0/35"
int colonIdx = indexPath.IndexOf(':');
int modelIndex = int.Parse(indexPath.Substring(0, colonIdx));
string pathId = indexPath.Substring(colonIdx + 1);
var pid = new ModelItemPathId { ModelIndex = modelIndex, PathId = pathId };
var item = doc.Models.ResolvePathId(pid);
```
不做名前缀匹配、不做手动树遍历。
### 4.4 容错
如果路径匹配不到目标节点(模型结构变化),标记为「孤儿别名」:
- 在别名树中以灰色 + 删除线显示
- 提供"清除孤儿别名"按钮
---
## 5. 别名数据持久化PathDatabase 扩展)
### 5.1 新增 SQLite 表
```sql
CREATE TABLE IF NOT EXISTS AliasMap (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
NodeKey TEXT NOT NULL UNIQUE, -- AliasNodeIdentity.ToKey()
AliasText TEXT NOT NULL,
CreatedAt TEXT DEFAULT (datetime('now')),
ModifiedAt TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey);
```
### 5.2 AliasDataStore 接口
```csharp
public class AliasDataStore
{
// 生命周期跟随 PathDatabase文档打开时连接关闭时断开
string GetAlias(AliasNodeIdentity id); // 读
void SetAlias(AliasNodeIdentity id, string alias); // 写upsert
void DeleteAlias(AliasNodeIdentity id); // 删
void DeleteAliasesByPrefix(string pathPrefix); // 批量删(清子节点)
Dictionary<string, string> LoadAll(); // 全量加载到内存缓存
int Count { get; } // 统计
// 孤儿别名
List<string> FindOrphans(HashSet<string> validKeys); // 检测
void PurgeOrphans(HashSet<string> validKeys); // 清理
}
```
### 5.3 与 PathDatabase 的关系
`AliasDataStore` 不持有独立 SQLiteConnection。通过 `PathDatabase.Connection` 操作,使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
---
## 6. 面板 UI 设计
### 6.1 布局
```
┌──────────────────────────────────────────┐
│ 顶部图标工具栏24px 高) │
│ [当前: 未命名节点标题] [💾] [⏬] [⏫] [⚙] [🗑] │
│ 保存 导出 导入 自动 清除 │
│ │
│ [别名输入框 ] [✓] │
├──────────────────────────────────────────┤
│ TreeView别名导航树
│ ├─ 模型名(灰色斜体=无别名) │
│ │ ├─ 一楼 → "1F-大厅" ◆蓝色粗体 │
│ │ ├─ 一楼 → 灰色斜体 │
│ │ ├─ 一楼_001 → 灰色斜体 │
│ │ └─ ... │
│ └─ 另一个模型 │
│ └─ ... │
└──────────────────────────────────────────┘
```
视觉规范:
- **有别名**:蓝色 (#1565C0) 粗体,加 ◆ 前缀图标
- **无别名**:灰色 (#888) 斜体,显示原 DisplayName
- **编辑中**`TextBox` 覆盖 TextBlock背景浅黄
- **孤儿别名**:灰色删除线
- **鼠标悬停行**:背景浅蓝 + 显示铅笔按钮 ✎
- **工具栏**:图标按钮 24×24Navisworks 蓝色系,鼠标悬停高亮
### 6.2 顶部图标工具栏
按钮统一使用 WPF `Path` 几何图形(与项目 `MediaControlIcons.xaml` 同模式),不依赖外部图片文件。
工具栏风格参考 Visual Studio / Navisworks 内置面板:紧凑横向排列,仅图标,悬停显示 ToolTip。
| 图标 | GeometryPath Data | ToolTip | 功能 |
|------|----------------------|---------|------|
| 💾 保存 | `M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z` | 保存别名 | 保存顶部输入框中的别名到当前选中节点 |
| ⏬ 导出 | `M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z` | 导出别名 (JSON) | 导出全量别名到 `.alias.json` |
| ⏫ 导入 | `M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z` | 导入别名 (JSON) | 从 `.alias.json` 导入(合并模式) |
| ⚙ 自动 | `M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.97 19.05,5.05L16.56,6.05C16.04,5.65 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.65 7.44,6.05L4.95,5.05C4.73,4.97 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.95C7.96,18.35 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.35 16.56,17.95L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z` | 自动生成 | 按父链路径自动生成前缀别名 |
| 🗑 清除 | `M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z` | 清除全部别名 | 清除所有别名和高亮,恢复原名显示 |
第二行保留文本输入区:
- `「当前: xxx」` — 选中节点的 DisplayName
- 别名输入框 — 直接输入别名
- `✓` 确认按钮 — 与回车等效,保存并刷新
### 6.3 图标按钮实现
沿用项目现有 `MediaControlIcons.xaml` 的模式 — 纯 WPF `Path` 几何图形,不依赖外部 .ico/.png 文件。
图标资源文件 `AliasTreeIcons.xaml`
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 保存图标(磁盘) -->
<Geometry x:Key="SaveIconGeometry">
M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z
</Geometry>
<!-- 导出图标(向上箭头出盒子) -->
<Geometry x:Key="ExportIconGeometry">
M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z
</Geometry>
<!-- 导入图标(向下箭头入盒子) -->
<Geometry x:Key="ImportIconGeometry">
M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z
</Geometry>
<!-- 自动生成图标(齿轮) -->
<Geometry x:Key="AutoGenIconGeometry">
M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12
A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 ...
</Geometry>
<!-- 清除图标(垃圾桶) -->
<Geometry x:Key="ClearIconGeometry">
M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z
</Geometry>
<!-- 别名工具栏图标按钮样式紧凑24×24 -->
<Style x:Key="AliasToolbarButtonStyle" TargetType="Button">
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Margin" Value="1,0"/>
<Setter Property="Padding" Value="2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource NavisworksTextBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="ToolTipService.ShowOnDisabled" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderThickness="0"
CornerRadius="2">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource NavisworksLightBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource NavisworksButtonBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#FFBBBBBB"/>
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
XAML 中的使用方式(参考 `MediaControlIcons.xaml` 模式):
```xml
<Button Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="保存别名" Click="OnSaveClick">
<Path Data="{StaticResource SaveIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
```
合并到控件 XAML 的 `ResourceDictionary.MergedDictionaries`
```xml
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="../Resources/NavisworksStyles.xaml"/>
<ResourceDictionary Source="../Resources/AliasTreeIcons.xaml"/>
</ResourceDictionary.MergedDictionaries>
```
### 6.4 右键菜单
| 菜单项 | 快捷键 | 功能 |
|--------|--------|------|
| 编辑别名 | F2 | 就地编辑 TextBox |
| 清除别名 | Del | 删除别名,恢复显示原名 |
| **批量编辑子节点…** | — | 弹出批量编辑对话框(见第 7 节) |
| 在视图中聚焦 | — | 3D 视图中选中 + 聚焦该节点 |
| 复制原名 | — | 复制 DisplayName 到剪贴板 |
---
## 7. 别名树显示原则(核心规则)
别名树不是 Selection Tree 的完整镜像。显示内容按以下规则决定:
### 7.1 二条件 OR 逻辑
| 条件 | 说明 | 示例 |
|------|------|------|
| **A: 层级限制内** | 深度 ≤ `MaxFullDisplayDepth`(默认 2的节点 | 根 → 第一层子节点 → 第二层子节点 |
| **B: 用户选中** | 在 Selection Tree 中选中的节点,无论多深 | 选中第 5 层节点 → 其完整路径显示在别名树中 |
满足任意一个条件即显示。两个条件都不满足的节点不显示。
### 7.2 深度限制
- `MaxFullDisplayDepth = 2`:初始构建只到第 2 层(根为第 0 层)
- 超限节点不添加展开箭头(无占位节点),用户无法手动展开
- 展开路径中的节点懒加载:有占位符 → `LazyLoadChildren`(全部子节点);无占位符 → `BuildNode`(只创建路径上的单个节点)
### 7.3 别名节点特殊规则
- **有别名的节点**:无论深度多少,启动时自动加载其完整路径并展开。由 `EnsureAliasedPathsVisible()``RebuildTree` / `SetDataStore` 后执行
- 超限别名节点的祖先会被展开,但祖先无别名则不保留占位符子节点
### 7.4 按需加载节点的生命周期
- 用户在 Selection Tree 中选择超限无别名节点 → 路径加载到别名树
- **下次选择另一个节点时**,上一次按需加载的无别名超限节点自动清理(`CleanupOnDemandNodes`
- 有别名的节点不受清理影响,始终保留
- `_onDemandNodeKeys` HashSet 追踪每次 `ExpandAndSelect` 中通过 `BuildNode` 创建的深层无别名节点
### 7.5 例外
- 开启别名面板后,树中已有节点被展开过的,其子节点保留(属用户主动操作)
- 通过内联编辑设置别名后,该节点自动变为「条件 A」的别名节点退出按需追踪
---
## 8. 批量编辑子节点(新增功能)
### 8.1 场景
Navisworks 中常见:一个父节点下 20 个子节点全叫「楼层」或「管道」,需要统一加前缀。
### 8.2 批量编辑对话框
```
┌──────────────────────────────────┐
│ 批量编辑子节点 — 父节点: "办公楼层" │
│ │
│ 编辑模式: │
│ ○ 统一前缀 [2F- ] │
│ ● 统一后缀 [ _办公区] │
│ ○ 替换原名 [自定义名称 ] │
│ ○ 序号模板 [管道_{0:D2} ] │ ← 自动编号
│ │
│ 预览: │
│ 楼层 → 楼层_办公区 │
│ 楼层 → 楼层_001_办公区 │ ← 同名自动去重
│ 走廊 → 走廊_办公区 │
│ 电梯间 → 电梯间_办公区 │
│ ...共 12 个子节点 │
│ │
│ [应用] [取消] │
└──────────────────────────────────┘
```
### 8.3 四种编辑模式
| 模式 | 输入 | 结果 |
|------|------|------|
| 统一前缀 | "2F-" | `2F-原名` |
| 统一后缀 | "_办公区" | `原名_办公区` |
| 替换原名 | "自定义名称" | 所有子节点用同一名称 + 去重后缀 `_001` |
| 序号模板 | "管道_{0:D2}" | `管道_01`, `管道_02`... |
### 8.4 去重保证
`MakeUnique()` 逻辑与 `AliasGenerator` 复用。预览中提前展示最终名称(含去重后缀)。
---
## 9. 别名节点 3D 高亮
### 9.1 触发时机
- 给节点设置别名时,自动在该节点上加永久颜色覆盖
- 清除别名时,同时清除颜色覆盖
### 9.2 颜色方案
```csharp
// 别名高亮色:半透明青色
static readonly Color AliasHighlightColor = Color.FromArgb(80, 0, 150, 200);
```
### 9.3 实现
```csharp
public void ApplyAliasHighlight(ModelItem item)
{
var items = new ModelItemCollection { item };
var doc = Application.ActiveDocument;
doc.Models.OverridePermanentColor(items, AliasHighlightColor);
}
public void RemoveAliasHighlight(ModelItem item)
{
var items = new ModelItemCollection { item };
var doc = Application.ActiveDocument;
doc.Models.OverridePermanentColor(items, Color.Empty); // 恢复默认
}
```
### 9.4 清理
- 文档关闭时自然清理(颜色覆盖不持久化到 NWD
- 清除别名时同时清除颜色
- 全部别名清除提供「清除所有别名高亮」操作
### 9.5 与其他高亮的兼容
项目已有 `ModelHighlightHelper`,使用 category 机制管理高亮。别名高亮用专用 category `"AliasHighlight"`
```csharp
ModelHighlightHelper.HighlightItems("AliasHighlight", itemCollection);
```
可与物流分类高亮、碰撞高亮共存,互不覆盖。
---
## 10. JSON 导入/导出
### 10.1 格式
```json
{
"version": 1,
"exportedAt": "2026-05-31T10:30:00",
"documentName": "Project.nwd",
"aliases": [
{
"key": "0:0/0/0||Floor2_mobile_yup.nwd/Architecture/2ND FLOOR/3' - 3\" x 4' - 3\"",
"alias": "第一个节点"
}
]
}
```
### 10.2 导入策略
- **合并模式**JSON 中的别名覆盖现有同名 keyJSON 中没有的不动。
### 10.3 字段说明
| 字段 | 用途 |
|------|------|
| `path` | 父链 DisplayName 拼接(对应 DB 的 HierarchicalPath |
| `siblingIndex` | 同级去重索引 |
| `alias` | 用户别名 |
---
## 11. 双向联动机制
### 11.1 点击别名树 → 选中模型
```csharp
private void OnTreeViewSelected(AliasNodeViewModel node)
{
if (node.ModelItem == null) return;
_isInternalSelection = true;
var coll = new ModelItemCollection { node.ModelItem };
doc.CurrentSelection.Clear();
doc.CurrentSelection.CopyFrom(coll);
_isInternalSelection = false;
}
```
### 11.2 Navisworks 选中变化 → 别名树导航
```csharp
private void OnNavisSelectionChanged(object sender, EventArgs e)
{
if (_isInternalSelection) return;
var selected = doc.CurrentSelection.SelectedItems;
if (selected.Count == 1)
ExpandAndSelect(selected.First());
}
```
### 11.3 展开路径(沿 ModelItem.Parent 链)
```csharp
private void ExpandAndSelect(ModelItem item)
{
// 1. 沿 Parent 链构建祖先列表(根 → 目标)
// 2. 在 rootNodes 中按 ModelItem.Equals 匹配根节点
// 3. 逐层向下:有占位 → LazyLoadChildren / 无占位 → BuildNode 单点创建
// 4. SelectTreeNode 选中最终节点
}
```
不使用 IndexPath 做运行时导航。IndexPath 仅在保存/加载别名时用于持久化(`CreatePathId` / `ResolvePathId`)。
---
## 12. 线程安全
**所有 Navisworks API 调用必须在主 STA 线程**(项目第 8 节硬约束)。
| 操作 | 线程 |
|------|------|
| 遍历 ModelItem构建树/生成别名) | 主线程(必须) |
| `Application.ActiveDocument` 访问 | 主线程(必须) |
| `OverridePermanentColor` | 主线程(必须) |
| `CurrentSelection.SelectedItems` | 主线程(必须) |
| 读/写 SQLiteAliasDataStore | 可通过 `Task.Run` 在后台 + 锁保护 |
| JSON 解析/序列化 | 后台线程安全 |
| `Dispatcher.BeginInvoke` 延迟 UI 更新 | 主线程回调 |
大模型优化:
- 树构建分批次:前 200 个根节点立即构建,后续通过 `Dispatcher.BeginInvoke(DispatcherPriority.Background, ...)` 分批追加
- 首屏只加载 Viewport 内可见项(`VirtualizingPanel` 已覆盖)
- 展开时才真正递归子节点(占位符机制)
---
## 13. 文件组织与集成
### 13.1 目录结构
```
src/
├── Core/
│ └── AliasTree/
│ ├── AliasTreePlugin.cs # DockPanePlugin
│ ├── AliasNodeIdentity.cs # 复合节点标识
│ └── AliasDataStore.cs # CRUD + SQLite
├── UI/
│ └── WPF/
│ ├── Views/
│ │ ├── AliasTreeControl.xaml
│ │ ├── AliasTreeControl.xaml.cs
│ │ ├── BatchAliasDialog.xaml
│ │ └── BatchAliasDialog.xaml.cs
│ └── ViewModels/
│ └── AliasNodeViewModel.cs # 单节点 VM
```
### 13.2 命名空间
- `NavisworksTransport.Core.AliasTree` — 插件入口、数据层
- `NavisworksTransport.UI.WPF.Views` — XAML 视图
- `NavisworksTransport.UI.WPF.ViewModels` — ViewModel
### 13.3 插件注册
```csharp
// src/Core/AliasTree/AliasTreePlugin.cs
[Plugin("NavisworksTransport.AliasTree", "Tian",
DisplayName = "别名导航树",
ToolTip = "自定义节点别名管理面板")]
[DockPanePlugin(300, 500, FixedSize = false)]
public class AliasTreePlugin : DockPanePlugin
{
public override Control CreateControlPane()
{
ElementHost eh = new ElementHost { AutoSize = true };
eh.Child = new AliasTreeControl();
eh.CreateControl();
return eh;
}
public override void DestroyControlPane(Control pane)
{
if (pane is ElementHost eh && eh.Child is AliasTreeControl ctrl)
ctrl.Cleanup();
pane?.Dispose();
}
}
```
### 13.4 用户操作步骤
1. 启动 Navisworks → 打开模型
2. **View → Docking Windows → 别名导航树** 打开面板
3. 手动拖动面板dock 到内置 Selection Tree 下方
4. 选中节点后,在顶部输入框或内联编辑打别名
5. 别名自动持久化到 `DocName.db`,下次打开模型自动恢复
---
## 14. 实施步骤(按优先级)
### 阶段 1数据层2 天)
1. ✅ `AliasNodeIdentity` — 复合标识 + 测试
2. ✅ `AliasDataStore` — CRUD + 集成到 PathDatabase.Connection
3. ✅ PathDatabase 新增 `AliasMap` 表 + 迁移逻辑
4. ✅ 单元测试identity 生成、去重、回溯定位
### 阶段 2VM + TreeView 骨架2 天)
5. ✅ `AliasNodeViewModel` — INotifyPropertyChanged
6. ✅ `AliasTreeControl.xaml` — TreeView + 工具栏布局
7. ✅ 延迟加载(占位节点 + Expanded 事件)
8. ✅ 顶部工具栏(当前名 + 输入框 + 保存)
### 阶段 3编辑功能1.5 天)
9. ✅ 内联编辑(文本框 + 铅笔按钮)
10. ✅ 右键菜单(编辑/清除/聚焦/复制原名)
11. ✅ `BatchAliasDialog` — 四种批量模式
12. ✅ `AliasGenerator` — 前缀生成(复用参考方案逻辑)
### 阶段 4联动 + 高亮1.5 天)
13. ✅ 点击别名树 → 选中模型(防循环)
14. ✅ 选中模型 → 展开别名树并高亮
15. ✅ 别名节点 3D 颜色高亮 + 清别名时清除
### 阶段 5导入导出 + 清理1 天)
16. ✅ JSON 导出
17. ✅ JSON 导入(合并模式)
18. ✅ 孤儿别名检测 + 清理
19. ✅ Cleanup 方法
### 阶段 6集成测试1 天)
20. ✅ 大模型性能测试10 万+ 节点)
21. ✅ 文档切换生命周期测试
22. ✅ 同名字兄弟节点去重测试
23. ✅ 回归:不影响现有物流分类/碰撞检测
---
## 15. 风险与已知限制
| 风险 | 缓解 |
|------|------|
| 父链路径在模型结构变化后断裂 | 孤儿别名标记 + 清理功能 |
| 同名兄弟节点计数漂移 | 同级插入/删除后索引可能偏移,需提供「刷新别名索引」 |
| `Children.Any()` 触发集合枚举 | 用 `Children.Count > 0` 代替,或从 COM API 取 path 来判空 |
| 大模型首次加载 TreeView 卡顿 | 分批构建 + Dispatcher.Background + 虚拟化 |
| Navisworks 不支持编程控制 dock 位置 | 首次使用文档中明确引导用户手动拖动面板 |
---
## 16. 与现有功能的隔离
- 不修改任何现有 `PathPlanningManager` / `PathDatabase` 核心逻辑(只在 DB 中加表)
- 不侵入 `LogisticsControlPanel`(独立 `DockPanePlugin`
- 别名高亮使用独立 `ModelHighlightHelper` category不干扰物流分类高亮
- `AliasDataStore` 只读 `PathDatabase.Connection`,不持有自己的连接

View File

@ -0,0 +1,724 @@
原生树无法扩展,自定义别名树面板就是唯一可靠的载体。在那个面板里,你可以完全自由地定制右键菜单,比如:
表格 菜单项 功能
编辑别名 就地改名
清除别名 恢复原名
在视图中聚焦 3D 定位
批量设置前缀 对当前节点及其子节点统一加前缀
导出此分支 只导出当前节点下的别名 CSV
实际工作流画面
想象你的屏幕布局:
plain
┌─────────────────┬──────────────────┐
│ 内置 Selection │ 3D 模型视图 │
│ Tree │ │
│ (原始名字) │ [框选操作区] │
│ │ │
├─────────────────┤ │
│ 别名导航树 │ │
│ (自定义名字) │ │
│ │ │
└─────────────────┴──────────────────┘
找东西:用户在别名树里看到"二楼-空调水管-123",一眼认出。
点一下别名树高亮3D 视图聚焦到那根管子,内置 Selection Tree 也同步跳转到对应节点。
反过来:用户在 3D 视图里框选一根管子,内置树亮了,别名树也自动展开高亮显示"二楼-空调水管-123"。
用户完全不需要记住原始命名规则,两个面板互相印证。
一个小提醒:首次加载性能
如果模型有 10万+ 节点,且你需要一次性把所有别名加载进字典,建议在插件初始化时做一个后台线程 + 进度条:
csharp
// 异步构建别名字典,避免 UI 卡死
private async void BuildAliasMapAsync()
{
await Task.Run(() =>
{
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
// 遍历所有节点生成 Guid->Alias 映射
// ...
});
RefreshTree();
}
结论
这个"双面板联动"方案是性价比最高的落地方式:
没有破坏 Navisworks 原生结构(稳定、升级兼容)
没有重写复杂树控件100 多行代码搞定)
** alias 数据完全由你掌控**Excel、数据库、规则生成均可
用户体验几乎等同于原生支持别名
下面是可直接编译的完整方案,共 6 个文件。核心思路:
AliasTreePaneNavisworks DockPane 外壳。
AliasTreeControlWPF 别名树面板支持悬停铅笔编辑、顶部快捷栏、右键菜单、CSV 导入导出、自动生成前缀别名。
双向联动:点击别名树选中模型;在 3D 视图/内置树选中后,别名树自动展开并高亮。
延迟加载:大模型下只加载可见节点,展开时才填充子节点。
1. AliasNode.cs数据模型
using Autodesk.Navisworks.Api;
using System;
using System.Collections.Generic;
using System.ComponentModel;
namespace AliasTree
{
public class AliasNode : INotifyPropertyChanged
{
public ModelItem ModelItem { get; set; }
public Guid Guid => ModelItem?.InstanceGuid ?? Guid.Empty;
private string _alias;
public string Alias
{
get => _alias;
set
{
_alias = value;
OnPropertyChanged(nameof(Alias));
OnPropertyChanged(nameof(DisplayText));
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set { _isEditing = value; OnPropertyChanged(nameof(IsEditing)); }
}
public string DisplayText => string.IsNullOrEmpty(Alias) ? (ModelItem?.DisplayName ?? "未命名") : Alias;
public List<<AliasNode> Children { get; set; } = new List<<AliasNode>();
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
2. AliasTreePlugin.cs插件入口
using Autodesk.Navisworks.Api.Plugins;
namespace AliasTree
{
[Plugin("AliasTreePane", "YourCompany", DisplayName = "别名导航树")]
[DockPanePlugin(400, 600, FixedSize = false)]
public class AliasTreePlugin : DockPanePlugin
{
public override Pane CreatePane() => new AliasTreePane();
}
}
3. AliasTreePane.csPane 外壳)
using Autodesk.Navisworks.Api.Plugins;
namespace AliasTree
{
public class AliasTreePane : Pane
{
public AliasTreePane()
{
this.Content = new AliasTreeControl();
}
}
}
4. AliasTreeControl.xaml界面布局
<UserControl x:Class="AliasTree.AliasTreeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AliasTree">
<DockPanel>
<!-- 顶部快捷栏 -->
<ToolBar DockPanel.Dock="Top" Height="32">
<TextBlock Text="当前选中:" VerticalAlignment="Center" Margin="4,0"/>
<TextBlock x:Name="TbCurrentName" Text="无" VerticalAlignment="Center"
FontWeight="Bold" Margin="4,0" MaxWidth="150" TextTrimming="CharacterEllipsis"/>
<Separator/>
<TextBox x:Name="TxtQuickAlias" Width="120" ToolTip="输入别名后回车"
KeyDown="TxtQuickAlias_KeyDown"/>
<Button Content="✓ 保存" Click="BtnQuickSave_Click" ToolTip="为当前选中节点保存别名"/>
<Separator/>
<Button Content="导出CSV" Click="BtnExport_Click"/>
<Button Content="导入CSV" Click="BtnImport_Click"/>
<Separator/>
<Button Content="自动生成" Click="BtnAutoGen_Click" ToolTip="按父节点路径自动生成别名"/>
</ToolBar>
<!-- 别名树 -->
<TreeView x:Name="AliasTreeView"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:AliasNode}" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1">
<!-- 显示模式:无别名时灰色斜体显示原名 -->
<TextBlock Text="{Binding DisplayText}" VerticalAlignment="Center" MinWidth="60">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Black"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Alias}" Value="">
<Setter Property="Foreground" Value="Gray"/>
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
<DataTrigger Binding="{Binding Alias}" Value="{x:Null}">
<Setter Property="Foreground" Value="Gray"/>
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 编辑模式默认隐藏IsEditing=True 时显示 -->
<TextBox Text="{Binding Alias, UpdateSourceTrigger=PropertyChanged}"
Width="120" Visibility="Collapsed" VerticalAlignment="Center"
KeyDown="AliasTextBox_KeyDown" LostFocus="AliasTextBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<!-- 铅笔图标:鼠标悬停该行时显示 -->
<Button Content="✎" Margin="4,0,0,0" Padding="2,0"
Click="BtnEdit_Click" Tag="{Binding}" VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=IsMouseOver}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="False"/>
<EventSetter Event="Expanded" Handler="TreeViewItem_Expanded"/>
<EventSetter Event="Selected" Handler="TreeViewItem_Selected"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="编辑别名" Click="CtxEdit_Click"/>
<MenuItem Header="清除别名" Click="CtxClear_Click"/>
<Separator/>
<MenuItem Header="在视图中聚焦" Click="CtxFocus_Click"/>
</ContextMenu>
</Setter.Value>
</Setter>
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
</DockPanel>
</UserControl>
5. AliasTreeControl.xaml.cs核心逻辑
using Autodesk.Navisworks.Api;
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AliasTree
{
public partial class AliasTreeControl : UserControl
{
private Dictionary<<Guid, string> _aliasMap = new Dictionary<<Guid, string>();
private List<<AliasNode> _rootNodes = new List<<AliasNode>();
public AliasTreeControl()
{
InitializeComponent();
AliasTreeView.ItemsSource = _rootNodes;
Autodesk.Navisworks.Api.Application.MainDocumentChanged += (s, e) =>
{
HookSelection();
RefreshTree();
};
HookSelection();
RefreshTree();
}
private void HookSelection()
{
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc != null)
doc.CurrentSelection.Changed += OnNavisSelectionChanged;
}
// ==================== 树构建与延迟加载 ====================
private void RefreshTree()
{
_rootNodes.Clear();
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null) return;
foreach (var root in doc.Models.RootItems)
{
var node = CreateAliasNode(root, true);
if (node != null) _rootNodes.Add(node);
}
}
private AliasNode CreateAliasNode(ModelItem item, bool lazyLoad)
{
if (item == null) return null;
var node = new AliasNode
{
ModelItem = item,
Alias = GetAlias(item.InstanceGuid)
};
if (item.Children.Any())
{
if (lazyLoad)
{
// 添加占位节点,使 TreeView 显示展开箭头
node.Children.Add(new AliasNode { ModelItem = null, Alias = "Loading..." });
}
else
{
foreach (var child in item.Children)
{
var childNode = CreateAliasNode(child, true);
if (childNode != null) node.Children.Add(childNode);
}
}
}
return node;
}
private void TreeViewItem_Expanded(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is TreeViewItem tvi && tvi.DataContext is AliasNode node)
{
// 检查是否是占位节点(尚未加载真实子节点)
if (node.Children.Count == 1 && node.Children[0].ModelItem == null)
{
node.Children.Clear();
if (node.ModelItem != null)
{
foreach (var child in node.ModelItem.Children)
{
var childNode = CreateAliasNode(child, true);
if (childNode != null) node.Children.Add(childNode);
}
}
}
}
}
// ==================== 双向联动 ====================
private void TreeViewItem_Selected(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is TreeViewItem tvi && tvi.DataContext is AliasNode node)
{
if (node.ModelItem == null) return;
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null) return;
var coll = new ModelItemCollection();
coll.Add(node.ModelItem);
doc.CurrentSelection.SelectedItems = coll;
}
}
private void OnNavisSelectionChanged(object sender, EventArgs e)
{
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null) return;
var selected = doc.CurrentSelection.SelectedItems;
Dispatcher.BeginInvoke(new Action(() =>
{
if (selected.Count == 1)
{
var item = selected.First();
TbCurrentName.Text = item.DisplayName;
TbCurrentName.Tag = item;
ExpandAndHighlight(item.InstanceGuid);
}
else
{
TbCurrentName.Text = selected.Count > 1 ? $"({selected.Count} 个选中)" : "无";
TbCurrentName.Tag = null;
}
}));
}
private void ExpandAndHighlight(Guid guid)
{
// 找到路径
var path = new List<<AliasNode>();
if (!FindPath(_rootNodes, guid, path)) return;
// 逐层展开并加载数据
ItemsControl current = AliasTreeView;
foreach (var node in path)
{
// 强制加载延迟加载的节点数据
if (node.Children.Count == 1 && node.Children[0].ModelItem == null)
{
node.Children.Clear();
if (node.ModelItem != null)
{
foreach (var child in node.ModelItem.Children)
{
var childNode = CreateAliasNode(child, true);
if (childNode != null) node.Children.Add(childNode);
}
}
}
if (current is TreeViewItem tvi)
{
tvi.IsExpanded = true;
tvi.UpdateLayout();
}
current = (TreeViewItem)current.ItemContainerGenerator.ContainerFromItem(node);
if (current == null) break; // 虚拟化下可能未生成,中断
}
// 高亮目标
if (current is TreeViewItem target && target.DataContext is AliasNode targetNode && targetNode.Guid == guid)
{
target.IsSelected = true;
target.BringIntoView();
}
}
private bool FindPath(List<<AliasNode> nodes, Guid guid, List<<AliasNode> path)
{
foreach (var n in nodes)
{
path.Add(n);
if (n.Guid == guid) return true;
if (FindPath(n.Children, guid, path)) return true;
path.RemoveAt(path.Count - 1);
}
return false;
}
// ==================== 内联编辑(铅笔图标) ====================
private void BtnEdit_Click(object sender, RoutedEventArgs e)
{
if ((sender as Button)?.Tag is not AliasNode node) return;
ClearAllEditing(_rootNodes);
node.IsEditing = true;
Dispatcher.BeginInvoke(new Action(() =>
{
var container = AliasTreeView.ItemContainerGenerator.ContainerFromItem(node) as TreeViewItem;
if (container == null) return;
var txt = FindVisualChild<TextBox>(container);
if (txt != null)
{
txt.Focus();
txt.SelectAll();
}
}), System.Windows.Threading.DispatcherPriority.Render);
}
private void AliasTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
(sender as TextBox)?.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
}
private void AliasTextBox_LostFocus(object sender, RoutedEventArgs e)
{
if ((sender as TextBox)?.DataContext is not AliasNode node) return;
SaveAlias(node.Guid, node.Alias);
node.IsEditing = false;
node.Alias = GetAlias(node.Guid); // 同步最终值
}
// ==================== 右键菜单 ====================
private void CtxEdit_Click(object sender, RoutedEventArgs e)
{
if (AliasTreeView.SelectedItem is AliasNode node)
BtnEdit_Click(new Button { Tag = node }, null);
}
private void CtxClear_Click(object sender, RoutedEventArgs e)
{
if (AliasTreeView.SelectedItem is not AliasNode node) return;
SaveAlias(node.Guid, null);
node.Alias = null;
}
private void CtxFocus_Click(object sender, RoutedEventArgs e)
{
if (AliasTreeView.SelectedItem is not AliasNode node || node.ModelItem == null) return;
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null) return;
var coll = new ModelItemCollection();
coll.Add(node.ModelItem);
doc.CurrentSelection.SelectedItems = coll;
doc.ActiveView.FocusOnItem(node.ModelItem, false);
}
// ==================== 顶部快捷栏 ====================
private void TxtQuickAlias_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) BtnQuickSave_Click(null, null);
}
private void BtnQuickSave_Click(object sender, RoutedEventArgs e)
{
if (TbCurrentName.Tag is not ModelItem item) return;
if (string.IsNullOrWhiteSpace(TxtQuickAlias.Text)) return;
SaveAlias(item.InstanceGuid, TxtQuickAlias.Text.Trim());
TxtQuickAlias.Clear();
RefreshNodeDisplay(item.InstanceGuid);
}
// ==================== 自动生成别名 ====================
private void BtnAutoGen_Click(object sender, RoutedEventArgs e)
{
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null) return;
var gen = new AliasGenerator();
var generated = gen.Generate(doc.Models.RootItems.FirstOrDefault(), "/");
foreach (var kv in generated)
_aliasMap[kv.Key] = kv.Value;
RefreshTree();
}
// ==================== CSV 导入导出 ====================
private void BtnExport_Click(object sender, RoutedEventArgs e)
{
var dlg = new SaveFileDialog { Filter = "CSV 文件|*.csv", Title = "导出别名映射" };
if (dlg.ShowDialog() != true) return;
var lines = new List<string> { "Guid,OriginalName,Alias" };
foreach (var kv in _aliasMap)
{
var item = Autodesk.Navisworks.Api.Application.ActiveDocument?.Models.FindItemByGuid(kv.Key);
string orig = item?.DisplayName ?? "";
lines.Add($"{kv.Key},\"{orig}\",\"{kv.Value}\"");
}
File.WriteAllLines(dlg.FileName, lines);
MessageBox.Show($"已导出 {_aliasMap.Count} 条别名记录。", "导出完成",
MessageBoxButton.OK, MessageBoxImage.Information);
}
private void BtnImport_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "CSV 文件|*.csv", Title = "导入别名映射" };
if (dlg.ShowDialog() != true) return;
int count = 0;
foreach (var line in File.ReadLines(dlg.FileName).Skip(1))
{
var parts = ParseCsvLine(line);
if (parts.Length >= 3 && Guid.TryParse(parts[0], out var guid))
{
string alias = parts[2].Trim('"');
if (!string.IsNullOrWhiteSpace(alias))
{
_aliasMap[guid] = alias;
count++;
}
}
}
RefreshTree();
MessageBox.Show($"已导入 {count} 条别名记录。", "导入完成",
MessageBoxButton.OK, MessageBoxImage.Information);
}
// ==================== 辅助方法 ====================
private string GetAlias(Guid guid) =>
_aliasMap.ContainsKey(guid) ? _aliasMap[guid] : null;
private void SaveAlias(Guid guid, string alias)
{
if (string.IsNullOrWhiteSpace(alias))
{
if (_aliasMap.ContainsKey(guid)) _aliasMap.Remove(guid);
}
else
{
_aliasMap[guid] = alias;
}
}
private void RefreshNodeDisplay(Guid guid)
{
var node = FindNode(_rootNodes, guid);
if (node != null) node.Alias = GetAlias(guid);
}
private AliasNode FindNode(List<<AliasNode> nodes, Guid guid)
{
foreach (var n in nodes)
{
if (n.Guid == guid) return n;
var found = FindNode(n.Children, guid);
if (found != null) return found;
}
return null;
}
private void ClearAllEditing(List<<AliasNode> nodes)
{
foreach (var n in nodes)
{
n.IsEditing = false;
ClearAllEditing(n.Children);
}
}
private static T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T t) return t;
var result = FindVisualChild<T>(child);
if (result != null) return result;
}
return null;
}
private static string[] ParseCsvLine(string line)
{
var result = new List<string>();
bool inQuotes = false;
var sb = new System.Text.StringBuilder();
foreach (char c in line)
{
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
result.Add(sb.ToString());
sb.Clear();
}
else
{
sb.Append(c);
}
}
result.Add(sb.ToString());
return result.ToArray();
}
}
}
6. AliasGenerator.cs批量自动生成别名
using Autodesk.Navisworks.Api;
using System;
using System.Collections.Generic;
namespace AliasTree
{
public class AliasGenerator
{
private Dictionary<<Guid, string> _result = new Dictionary<<Guid, string>();
private HashSet<string> _usedNames = new HashSet<string>();
public Dictionary<<Guid, string> Generate(ModelItem root, string separator = "/")
{
_result.Clear();
_usedNames.Clear();
if (root != null)
Traverse(root, "", separator, 0);
return _result;
}
private void Traverse(ModelItem item, string parentPath, string sep, int depth)
{
string rawName = item.DisplayName?.Trim() ?? "未命名";
// 一级节点保持原名;二级开始加父前缀
string alias = depth == 0
? rawName
: (string.IsNullOrEmpty(parentPath) ? rawName : $"{parentPath}{sep}{rawName}");
string uniqueAlias = MakeUnique(alias);
_result[item.InstanceGuid] = uniqueAlias;
// 下一级前缀:最多保留最近一级父名,避免路径无限膨胀
string nextPrefix = depth < 1 ? uniqueAlias : rawName;
foreach (var child in item.Children)
Traverse(child, nextPrefix, sep, depth + 1);
}
private string MakeUnique(string name)
{
if (!_usedNames.Contains(name))
{
_usedNames.Add(name);
return name;
}
int idx = 1;
string candidate;
do
{
candidate = $"{name}_{idx:D3}";
idx++;
} while (_usedNames.Contains(candidate));
_usedNames.Add(candidate);
return candidate;
}
}
}
编译与部署说明
| 项目 | 内容 |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **项目类型** | Visual Studio → C# → 类库(.NET Framework) |
| **目标框架** | .NET Framework 4.8(与 Navisworks 版本匹配) |
| **引用 DLL** | `Autodesk.Navisworks.Api.dll`<br>`Autodesk.Navisworks.Automation.dll`<br>位置通常在:`C:\Program Files\Autodesk\Navisworks Manage 2024\` |
| **输出路径** | 编译后,将生成的 DLL 与 XAML/BAML 一起放入:<br>`%APPDATA%\Autodesk Navisworks Manage 2024\Plugins\AliasTree\` |
| **启动** | 重启 Navisworks**View → Docking Windows** 中找到 **别名导航树** |
使用流程(假设一个"八个楼层"的模型节点)
打开插件面板dock 到内置 Selection Tree 旁边。
在内置树或 3D 视图中点选一个名叫"楼层"的节点。
别名树自动高亮该节点,顶部工具栏显示原名。
任选一种方式打别名:
鼠标悬停别名树节点 → 点 ✎ 铅笔 → 就地输入"8F-办公层" → 回车。
看 3D 视图确认位置 → 在顶部输入框敲"8F-办公层" → 点 ✓ 保存。
右键节点 → 编辑别名。
如果八个子节点全叫"楼层",可点 自动生成 按钮,一键生成 楼层/楼层_001 … 楼层/楼层_007你再精修成 1F大堂、2F办公 等。
工作完成后 导出 CSV下次打开 Navisworks 时 导入 CSV 即可恢复所有别名。

View File

@ -2,6 +2,11 @@
## 功能点
### [2026/5/27]
1. [x] (功能)对任意三维姿态的模型,自动调整最小投影到路径方向
2. [x] (优化)插件打开时,如果模型文件有数据库,自动加载
### [2026/5/18]
1. [x] BUG修复剖面盒导出后程序偶尔崩溃的问题

View File

@ -84,6 +84,11 @@ width_limit_meters = 3.0
# ZUp: 强制使用 Z-Up 坐标系Navisworks 默认)
# YUp: 强制使用 Y-Up 坐标系(常见于 Revit 导出)
type = "AutoDetect"
[alias_tree]
# 别名树完整展示的最大层级深度0 = 仅根节点)
# 默认值 2即展示根节点 + 两层子节点
max_full_display_depth = 2
# 自定义物流类别
# 用于扩展内置类别,只需填写类别名称

View File

@ -158,6 +158,11 @@ namespace NavisworksTransport.Commands
/// </summary>
public int SkippedCount { get; set; }
/// <summary>
/// 因重名被重命名的路径数量
/// </summary>
public int RenamedCount { get; set; }
/// <summary>
/// 失败的路径数量
/// </summary>
@ -434,10 +439,23 @@ namespace NavisworksTransport.Commands
break;
case DuplicateNameHandling.Rename:
finalRouteName = GenerateUniqueName(route.Name, _pathPlanningManager.Routes);
{
var originalName = route.Name;
finalRouteName = GenerateUniqueName(originalName, _pathPlanningManager.Routes);
route.Name = finalRouteName;
LogInfo($"重命名路径: {route.Name} -> {finalRouteName}");
route.Id = Guid.NewGuid().ToString();
foreach (var pt in route.Points)
{
pt.Id = Guid.NewGuid().ToString();
}
route.RecalculateRoute("导入重命名");
if (finalRouteName != originalName)
{
result.RenamedCount++;
LogInfo($"重命名路径: {originalName} -> {finalRouteName}");
}
break;
}
case DuplicateNameHandling.Overwrite:
// 先删除现有的同名路径
@ -512,7 +530,7 @@ namespace NavisworksTransport.Commands
result.ExecutionTimeMs = (long)elapsedMs;
LogInfo($"导入路径执行完成,总耗时: {elapsedMs:F0}ms");
LogInfo($"导入结果 - 文件中: {result.TotalInFile}, 导入: {result.ImportedCount}, 失败: {result.FailedCount}, 跳过: {result.SkippedCount}");
LogInfo($"导入结果 - 文件中: {result.TotalInFile}, 导入: {result.ImportedCount}, 失败: {result.FailedCount}, 跳过: {result.SkippedCount}, 重命名: {result.RenamedCount}");
// 创建结果对象
string resultMessage;
@ -531,6 +549,10 @@ namespace NavisworksTransport.Commands
{
resultMessage += $",跳过 {result.SkippedCount} 项";
}
if (result.RenamedCount > 0)
{
resultMessage += $",重命名 {result.RenamedCount} 项";
}
}
var commandResult = PathPlanningResult<ImportPathResult>.Success(result, resultMessage);

View File

@ -0,0 +1,260 @@
using NavisworksTransport.Utils;
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.Linq;
namespace NavisworksTransport.Core.AliasTree
{
/// <summary>
/// 别名数据存储 — CRUD 操作 + 与 PathDatabase 对接。
///
/// 不持有独立 SQLiteConnection通过 PathDatabase.Connection 操作,
/// 使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
///
/// 线程安全:
/// - 构造/打开/关闭在主线程PathDatabase 生命周期同步)
/// - 后台线程读操作通过 Connection 上的锁保护
/// </summary>
public class AliasDataStore
{
private readonly SQLiteConnection _connection;
private readonly object _lock = new object();
/// <summary>
/// 构造 AliasDataStore使用 PathDatabase 的已有连接
/// </summary>
public AliasDataStore(SQLiteConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
/// <summary>
/// 确保 AliasMap 表存在(在 PathDatabase.CreateTables 中已自动创建,
/// 这是提供给独立初始化场景的保障)
/// </summary>
public void EnsureTableExists()
{
lock (_lock)
{
using (var cmd = new SQLiteCommand(@"
CREATE TABLE IF NOT EXISTS AliasMap (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
NodeKey TEXT NOT NULL UNIQUE,
AliasText TEXT NOT NULL,
CreatedAt TEXT DEFAULT (datetime('now')),
ModifiedAt TEXT DEFAULT (datetime('now'))
)
", _connection))
{
cmd.ExecuteNonQuery();
}
using (var cmd = new SQLiteCommand(
"CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey)", _connection))
{
cmd.ExecuteNonQuery();
}
}
}
// ── 单条操作 ──
/// <summary>
/// 读取别名。返回 null 表示无别名。
/// </summary>
public string GetAlias(AliasNodeIdentity id)
{
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"SELECT AliasText FROM AliasMap WHERE NodeKey = @key", _connection))
{
cmd.Parameters.AddWithValue("@key", id.ToKey());
var result = cmd.ExecuteScalar();
return result as string;
}
}
}
/// <summary>
/// 写入别名Upsert 语义 — 存在则更新,不存在则插入)
/// </summary>
public void SetAlias(AliasNodeIdentity id, string alias)
{
if (string.IsNullOrWhiteSpace(alias))
{
DeleteAlias(id);
return;
}
string key = id.ToKey();
lock (_lock)
{
// 尝试更新
using (var cmd = new SQLiteCommand(
"UPDATE AliasMap SET AliasText = @alias, ModifiedAt = datetime('now') WHERE NodeKey = @key",
_connection))
{
cmd.Parameters.AddWithValue("@key", key);
cmd.Parameters.AddWithValue("@alias", alias.Trim());
int affected = cmd.ExecuteNonQuery();
if (affected == 0)
{
// 不存在,插入
using (var insertCmd = new SQLiteCommand(
"INSERT INTO AliasMap (NodeKey, AliasText) VALUES (@key, @alias)",
_connection))
{
insertCmd.Parameters.AddWithValue("@key", key);
insertCmd.Parameters.AddWithValue("@alias", alias.Trim());
insertCmd.ExecuteNonQuery();
}
}
}
}
}
/// <summary>
/// 删除别名
/// </summary>
public void DeleteAlias(AliasNodeIdentity id)
{
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"DELETE FROM AliasMap WHERE NodeKey = @key", _connection))
{
cmd.Parameters.AddWithValue("@key", id.ToKey());
cmd.ExecuteNonQuery();
}
}
}
// ── 批量操作 ──
/// <summary>
/// 删除指定路径前缀下的所有别名(用于"清除某分支下全部别名"
/// </summary>
public void DeleteAliasesByPathPrefix(string pathPrefix)
{
if (string.IsNullOrEmpty(pathPrefix)) return;
// 使用 LIKE 'prefix%' 匹配所有以该前缀开头的路径
string escapedPrefix = pathPrefix.Replace("'", "''").Replace("%", "\\%").Replace("_", "\\_");
string likePattern = escapedPrefix + "%";
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"DELETE FROM AliasMap WHERE NodeKey LIKE @pattern ESCAPE '\\'",
_connection))
{
cmd.Parameters.AddWithValue("@pattern", likePattern);
cmd.ExecuteNonQuery();
}
}
}
/// <summary>
/// 全量加载所有别名到内存字典。key=NodeKey, value=AliasText
/// </summary>
public Dictionary<string, string> LoadAll()
{
var result = new Dictionary<string, string>();
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"SELECT NodeKey, AliasText FROM AliasMap", _connection))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string key = reader.GetString(0);
string alias = reader.GetString(1);
result[key] = alias;
}
}
}
return result;
}
/// <summary>
/// 当前别名总数
/// </summary>
public int Count
{
get
{
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"SELECT COUNT(*) FROM AliasMap", _connection))
{
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
}
}
/// <summary>
/// 批量导入(合并模式:覆盖已有 key新增不存在的 key
/// </summary>
public void ImportBatch(Dictionary<string, string> entries)
{
if (entries == null || entries.Count == 0) return;
lock (_lock)
{
using (var tx = _connection.BeginTransaction())
{
try
{
using (var upsertCmd = new SQLiteCommand(
@"INSERT INTO AliasMap (NodeKey, AliasText) VALUES (@key, @alias)
ON CONFLICT(NodeKey) DO UPDATE SET
AliasText = excluded.AliasText,
ModifiedAt = datetime('now')",
_connection))
{
var keyParam = upsertCmd.Parameters.Add("@key", System.Data.DbType.String);
var aliasParam = upsertCmd.Parameters.Add("@alias", System.Data.DbType.String);
foreach (var kvp in entries)
{
keyParam.Value = kvp.Key;
aliasParam.Value = kvp.Value;
upsertCmd.ExecuteNonQuery();
}
}
tx.Commit();
}
catch
{
tx.Rollback();
throw;
}
}
}
}
/// <summary>
/// 清除所有别名(危险操作)
/// </summary>
public void ClearAll()
{
lock (_lock)
{
using (var cmd = new SQLiteCommand("DELETE FROM AliasMap", _connection))
{
cmd.ExecuteNonQuery();
}
}
}
}
}

View File

@ -0,0 +1,213 @@
using Autodesk.Navisworks.Api;
using NavisworksTransport.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NavisworksTransport.Core.AliasTree
{
/// <summary>
/// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。
///
/// 双字段设计:
/// - <see cref="IndexPath"/>COM API 树索引路径(如 "0,2,5,1"
/// 复用 <see cref="ModelItemAnalysisHelper.GetModelItemIndexPaths"/> 生成。
/// 定位时优先使用,精度高、速度快。
/// - <see cref="DisplayPath"/>:父链 DisplayName 拼接路径(如 "模型/楼层/墙#0"
/// 可读可调试IndexPath 失效时作为降级定位手段。
///
/// 序列化格式ToKey "IndexPath||DisplayPath"
/// </summary>
public readonly struct AliasNodeIdentity : IEquatable<AliasNodeIdentity>
{
/// <summary>COM 树索引路径(如 "0,2,5,1"),优先用于定位</summary>
public string IndexPath { get; }
/// <summary>父链 DisplayName 拼接路径 + 同级索引(如 "模型/楼层/墙#0"</summary>
public string DisplayPath { get; }
/// <summary>
/// 从全量信息构造
/// </summary>
public AliasNodeIdentity(string indexPath, string displayPath)
{
IndexPath = indexPath ?? string.Empty;
DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath));
}
// ── 工厂方法 ──
/// <summary>
/// 从 ModelItem 构造复合标识。
/// 必须在主线程调用(涉及 Navisworks API + COM API
/// </summary>
public static AliasNodeIdentity FromModelItem(ModelItem item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
// COM API 必须在 STA 主线程
if (System.Threading.Thread.CurrentThread.GetApartmentState() != System.Threading.ApartmentState.STA)
LogManager.Error($"[别名树] FromModelItem 不在 STA 线程!");
// 1. IndexPath — 使用 Navisworks 官方 API CreatePathId
string indexPath;
try
{
var pid = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(item);
indexPath = $"{pid.ModelIndex}:{pid.PathId}";
}
catch (Exception ex)
{
LogManager.Error($"[别名树] CreatePathId 异常: {ex.Message}");
indexPath = string.Empty;
}
// 2. DisplayPath — 父链 DisplayName + 每层同名去重索引
var parts = new List<string>();
var current = item;
while (current != null)
{
int siblingIdx = ComputeSiblingIndex(current);
string name = current.DisplayName ?? "?";
parts.Insert(0, siblingIdx > 0 ? $"{name}#{siblingIdx}" : name);
current = current.Parent;
}
string displayPathWithIndex = string.Join("/", parts);
return new AliasNodeIdentity(indexPath, displayPathWithIndex);
}
/// <summary>
/// 计算同名兄弟节点中的位置
/// </summary>
private static int ComputeSiblingIndex(ModelItem item)
{
var parent = item.Parent;
if (parent == null) return 0;
string targetName = item.DisplayName;
int matchIndex = 0;
foreach (var child in parent.Children)
{
if (child.Equals(item))
return matchIndex;
if (string.Equals(child.DisplayName, targetName, StringComparison.Ordinal))
matchIndex++;
}
return 0;
}
// ── 序列化 ──
/// <summary>
/// 序列化为 DB 主键 / JSON key
/// 格式: "IndexPath||DisplayPath"
/// </summary>
public string ToKey()
{
if (string.IsNullOrEmpty(IndexPath))
return DisplayPath;
return $"{IndexPath}||{DisplayPath}";
}
/// <summary>
/// 从 key 反序列化
/// </summary>
public static AliasNodeIdentity FromKey(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentException("key 不能为空", nameof(key));
// key 必须包含 "||" 分隔符
int separatorIndex = key.IndexOf("||", StringComparison.Ordinal);
if (separatorIndex < 0)
throw new FormatException($"key 缺少 || 分隔符: {key}");
string indexPath = key.Substring(0, separatorIndex);
string displayPath = key.Substring(separatorIndex + 2);
if (string.IsNullOrEmpty(displayPath))
throw new FormatException($"DisplayPath 为空: {key}");
return new AliasNodeIdentity(indexPath, displayPath);
}
/// <summary>
/// 提前验证 key 字符串格式是否合法
/// </summary>
public static bool TryParseKey(string key, out AliasNodeIdentity identity)
{
identity = default;
if (string.IsNullOrEmpty(key)) return false;
try
{
identity = FromKey(key);
return true;
}
catch
{
return false;
}
}
// ── 查询辅助 ──
/// <summary>
/// 获取 DisplayPath 部分中的 HierarchicalPath去掉尾部 #索引)
/// </summary>
public string GetHierarchicalPath()
{
int hashIdx = DisplayPath.LastIndexOf('#');
return hashIdx > 0 ? DisplayPath.Substring(0, hashIdx) : DisplayPath;
}
/// <summary>
/// 获取 DisplayPath 部分中的 SiblingIndex
/// </summary>
public int GetSiblingIndex()
{
int hashIdx = DisplayPath.LastIndexOf('#');
if (hashIdx < 0 || hashIdx >= DisplayPath.Length - 1) return 0;
string indexStr = DisplayPath.Substring(hashIdx + 1);
int.TryParse(indexStr, out int idx);
return idx;
}
/// <summary>
/// 检查 DisplayPath 是否以指定前缀开头(用于"清除某分支下全部别名"
/// </summary>
public bool HasPathPrefix(string prefix)
{
return GetHierarchicalPath().StartsWith(prefix, StringComparison.Ordinal);
}
// ── 值语义 ──
public bool Equals(AliasNodeIdentity other)
{
return string.Equals(IndexPath, other.IndexPath, StringComparison.Ordinal)
&& string.Equals(DisplayPath, other.DisplayPath, StringComparison.Ordinal);
}
public override bool Equals(object obj)
=> obj is AliasNodeIdentity other && Equals(other);
public override int GetHashCode()
{
unchecked
{
return (IndexPath?.GetHashCode() ?? 0 * 397) ^ (DisplayPath?.GetHashCode() ?? 0);
}
}
public static bool operator ==(AliasNodeIdentity left, AliasNodeIdentity right)
=> left.Equals(right);
public static bool operator !=(AliasNodeIdentity left, AliasNodeIdentity right)
=> !left.Equals(right);
public override string ToString() => ToKey();
}
}

View File

@ -0,0 +1,107 @@
using System;
using Autodesk.Navisworks.Api.Plugins;
using NavisworksTransport.UI.WPF.Views;
using NavisworksTransport.Utils;
using System.Windows.Forms.Integration;
using NavisApplication = Autodesk.Navisworks.Api.Application;
namespace NavisworksTransport.Core.AliasTree
{
[Plugin("NavisworksTransport.AliasTree", "Tian",
DisplayName = "别名树",
ToolTip = "自定义节点别名管理面板 — 双击编辑、右键菜单、JSON 导入导出")]
[DockPanePlugin(300, 400, FixedSize = false, AutoScroll = true)]
public class AliasTreePlugin : DockPanePlugin
{
private AliasTreeControl _control;
public override System.Windows.Forms.Control CreateControlPane()
{
LogManager.Info("[别名树] 创建 DockPane 面板");
var eh = new ElementHost { AutoSize = true, BackColor = System.Drawing.Color.White };
_control = new AliasTreeControl();
_control.OnDatabaseNeeded += TryConnectToDatabase;
_control.BeforeSaveAlias += EnsureDatabaseCreated;
eh.Child = _control;
eh.CreateControl();
// 面板创建时如果文档已存在,立即连接数据库
TryConnectToDatabase();
return eh;
}
public override void DestroyControlPane(System.Windows.Forms.Control pane)
{
LogManager.Info("[别名树] 销毁 DockPane 面板");
if (pane is ElementHost eh && eh.Child is AliasTreeControl ctrl)
{
ctrl.Cleanup();
eh.Child = null;
}
_control = null;
pane?.Dispose();
}
protected override void OnLoaded()
{
base.OnLoaded();
NavisApplication.ActiveDocumentChanged += OnDocumentChanged;
// 面板自动恢复时文档可能已加载,主动尝试连接
TryConnectToDatabase();
}
protected override void OnUnloading()
{
NavisApplication.ActiveDocumentChanged -= OnDocumentChanged;
base.OnUnloading();
}
private void OnDocumentChanged(object sender, System.EventArgs e)
{
TryConnectToDatabase();
}
private void TryConnectToDatabase()
{
if (_control == null) return;
if (_control.GetDataStore() != null) return;
try
{
var doc = NavisApplication.ActiveDocument;
if (doc == null || string.IsNullOrEmpty(doc.FileName)) return;
// 只连接,不创建(和路径规划一致)
var pathManager = PathPlanningManager.GetActivePathManager();
pathManager?.DatabaseInitialize(createIfMissing: false);
var db = pathManager?.GetPathDatabase();
if (db?.Connection == null) return;
var store = new AliasDataStore(db.Connection);
store.EnsureTableExists();
_control.SetDataStore(store);
LogManager.Info("[别名树] 已连接别名数据库");
}
catch (Exception ex)
{
LogManager.Error($"[别名树] 连接数据库失败: {ex.Message}");
}
}
/// <summary>
/// 确保数据库已创建(保存别名时调用)
/// </summary>
private void EnsureDatabaseCreated()
{
if (_control?.GetDataStore() != null) return;
var pathManager = PathPlanningManager.GetActivePathManager();
pathManager?.DatabaseInitialize(createIfMissing: true);
TryConnectToDatabase();
}
}
}

View File

@ -484,7 +484,7 @@ namespace NavisworksTransport.Core.Animation
string objectName = isVirtual ? "虚拟物体" : objectToRestore.DisplayName;
_railPreservedPoseTrackedCenterOffset = new Vector3D(0, 0, 0);
_hasRailPreservedPoseTrackedCenterOffset = false;
LogManager.Info($"[归位] {objectName} 已彻底恢复到原始位置, yaw={_currentYaw:F3}");
LogManager.Debug($"[归位] {objectName} 已彻底恢复到原始位置, yaw={_currentYaw:F3}");
}
catch (Exception ex)
{
@ -666,7 +666,6 @@ namespace NavisworksTransport.Core.Animation
if (CurrentControlledObject != null || IsVirtualObjectMode)
{
RestoreObjectToCADPosition();
LogManager.Info($"[移动到起点] 已恢复物体到原始状态, _currentYaw={_currentYaw * 180 / Math.PI:F2}°");
}
// 如果提供了参数,更新内部状态
@ -714,11 +713,6 @@ namespace NavisworksTransport.Core.Animation
IsRealObjectMode &&
(_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting);
if (shouldUsePlanarPureIncrementStart)
{
LogManager.Info($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体起点改用纯增量姿态主链");
}
// 根据路径类型调整起点位置
Point3D pathStartPoint = _pathPoints[0];
Point3D startPosition = pathStartPoint;
@ -772,17 +766,21 @@ namespace NavisworksTransport.Core.Animation
LogManager.Debug($"[移动到起点] 地面路径中心点=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})");
}
LogManager.Info(
LogManager.Debug(
$"[路径起点诊断] 路径point0=({pathStartPoint.X:F3},{pathStartPoint.Y:F3},{pathStartPoint.Z:F3}), " +
$"起点目标trackedPoint=({startPosition.X:F3},{startPosition.Y:F3},{startPosition.Z:F3}), 路径类型={_route.PathType.GetDisplayName()}");
LogManager.Debug(
$"[移动到起点-诊断] _trackedPosition=({_trackedPosition.X:F3},{_trackedPosition.Y:F3},{_trackedPosition.Z:F3}), " +
$"startPosition=({startPosition.X:F3},{startPosition.Y:F3},{startPosition.Z:F3})");
if (shouldUsePlanarPureIncrementStart &&
TryApplyPlanarRealObjectStartIncrementalTransform(_route.PathType, startPosition, out double planarTargetYawRadians))
{
LogManager.Info($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体已应用纯增量旋转+平移");
LogManager.Debug($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体已应用纯增量旋转+平移");
var startAppliedPoint = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject);
LogManager.Info(
LogManager.Debug(
$"[路径起点诊断] 起点应用后实际包围盒中心=({startAppliedPoint.X:F3},{startAppliedPoint.Y:F3},{startAppliedPoint.Z:F3}), " +
$"相对目标偏差=({startAppliedPoint.X - startPosition.X:F3},{startAppliedPoint.Y - startPosition.Y:F3},{startAppliedPoint.Z - startPosition.Z:F3})");
@ -795,17 +793,14 @@ namespace NavisworksTransport.Core.Animation
startOffset.Y * startOffset.Y +
startOffset.Z * startOffset.Z);
LogManager.Info(
$"[路径起点诊断] 落位偏差=({startOffset.X:F3},{startOffset.Y:F3},{startOffset.Z:F3}), 长度={startOffsetLength:F3} (仅记录,不二次补偿)");
if (_route.PathType == PathType.Ground)
{
_groundRealObjectBaseRotation = _trackedRotation;
_groundRealObjectBaseYaw = planarTargetYawRadians;
_hasGroundRealObjectBasePose = true;
LogManager.Info(
$"[Ground真实物体基姿态] {animatedObject.DisplayName} BaseYaw={_groundRealObjectBaseYaw * 180.0 / Math.PI:F2}°, " +
LogManager.Debug(
$"[Ground真实物体基姿态] {(CurrentControlledObject ?? _animatedObject)?.DisplayName ?? "()"} BaseYaw={_groundRealObjectBaseYaw * 180.0 / Math.PI:F2}°, " +
$"已记录基姿态={_hasGroundRealObjectBasePose} (纯增量链)");
}
else
@ -822,10 +817,10 @@ namespace NavisworksTransport.Core.Animation
TryCreatePlanarPathRotationAtStart(out var planarRotation))
{
ApplyFullPoseUpdate(startPosition, planarRotation);
LogManager.Info("[移动到起点] 地面/吊装路径已应用完整三维姿态");
LogManager.Debug("[移动到起点] 地面/吊装路径已应用完整三维姿态");
var startAppliedPoint = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject);
LogManager.Info(
LogManager.Debug(
$"[路径起点诊断] 起点应用后实际包围盒中心=({startAppliedPoint.X:F3},{startAppliedPoint.Y:F3},{startAppliedPoint.Z:F3}), " +
$"相对目标偏差=({startAppliedPoint.X - startPosition.X:F3},{startAppliedPoint.Y - startPosition.Y:F3},{startAppliedPoint.Z - startPosition.Z:F3})");
@ -841,17 +836,14 @@ namespace NavisworksTransport.Core.Animation
startOffset.Y * startOffset.Y +
startOffset.Z * startOffset.Z);
LogManager.Info(
$"[路径起点诊断] 落位偏差=({startOffset.X:F3},{startOffset.Y:F3},{startOffset.Z:F3}), 长度={startOffsetLength:F3} (仅记录,不二次补偿)");
}
if (IsRealObjectMode && _route.PathType == PathType.Ground)
{
_groundRealObjectBaseRotation = planarRotation;
_hasGroundRealObjectBasePose = TryResolveGroundRealObjectBaseYaw(out _groundRealObjectBaseYaw);
LogManager.Info(
$"[Ground真实物体基姿态] {animatedObject.DisplayName} BaseYaw={_groundRealObjectBaseYaw * 180.0 / Math.PI:F2}°, " +
LogManager.Debug(
$"[Ground真实物体基姿态] {(CurrentControlledObject ?? _animatedObject)?.DisplayName ?? "()"} BaseYaw={_groundRealObjectBaseYaw * 180.0 / Math.PI:F2}°, " +
$"已记录基姿态={_hasGroundRealObjectBasePose} (旋转偏移由逐帧方法内部处理)");
}
else if (IsRealObjectMode && _route.PathType == PathType.Hoisting)
@ -873,8 +865,6 @@ namespace NavisworksTransport.Core.Animation
return false;
}
string pathTypeName = _route.PathType.GetDisplayName();
LogManager.Info($"物体已初始化到路径起点并对齐朝向: pos=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2}), 路径类型={pathTypeName}");
return true;
}
catch (Exception ex)
@ -1197,7 +1187,15 @@ namespace NavisworksTransport.Core.Animation
// 吊装路径所有线段都使用水平吊运方向与MoveObjectToPathStart保持一致
if (_route.PathType == PathType.Hoisting)
{
yawRadians = Math.Atan2(_pathPoints[2].Y - _pathPoints[1].Y, _pathPoints[2].X - _pathPoints[1].X);
if (_pathPoints.Count >= 3)
{
yawRadians = Math.Atan2(_pathPoints[2].Y - _pathPoints[1].Y, _pathPoints[2].X - _pathPoints[1].X);
}
else
{
// 2点路径纯垂直无水平方向保持当前朝向
yawRadians = _currentYaw;
}
}
// 🔥 空中路径:根据路径类型和线段索引调整物体位置
@ -1207,38 +1205,52 @@ namespace NavisworksTransport.Core.Animation
if (_route.PathType == PathType.Hoisting)
{
// 吊装路径:支持动态数量的路径点
// 路径点结构:
// 点0起吊点地面位置
// 点1提升点悬挂点
// 点2...点N-2中间悬挂点用户添加的转向点
// 点N-1下降点悬挂点
// 点N落地点地面位置
//
// 线段结构:
// 线段0起吊段地面→悬挂点- 垂直
// 线段1...线段N-2平移段悬挂点→悬挂点- 水平
// 线段N-1下降段悬挂点→地面- 垂直
int totalSegments = _pathPoints.Count - 1;
int firstSegment = 0; // 起吊段
int lastSegment = totalSegments - 1; // 下降段
if (segmentIndex == firstSegment)
if (_route.IsTwoPointVertical)
{
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, segmentProgress);
LogManager.Debug($"[吊装路径-起吊段] 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
// 2点纯垂直路径起点加半高补偿地面→中心终点不加终点即中心位置
var startCenter = ResolveGroundTrackedCenter(p1, GetAnimatedObjectGroundContactHeight());
var endCenter = p2;
framePosition = new Point3D(
startCenter.X + segmentProgress * (endCenter.X - startCenter.X),
startCenter.Y + segmentProgress * (endCenter.Y - startCenter.Y),
startCenter.Z + segmentProgress * (endCenter.Z - startCenter.Z));
LogManager.Debug($"[吊装路径-2点垂直] 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
}
else if (segmentIndex > firstSegment && segmentIndex < lastSegment)
else
{
// 平移段(中间的所有线段):参考点位于顶部悬挂点
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, 1.0);
LogManager.Debug($"[吊装路径-平移段] 线段{segmentIndex}, 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
}
else if (segmentIndex == lastSegment)
{
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, 1.0 - segmentProgress);
LogManager.Debug($"[吊装路径-下降段] 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
// 吊装路径:支持动态数量的路径点
// 路径点结构:
// 点0起吊点地面位置
// 点1提升点悬挂点
// 点2...点N-2中间悬挂点用户添加的转向点
// 点N-1下降点悬挂点
// 点N落地点地面位置
//
// 线段结构:
// 线段0起吊段地面→悬挂点- 垂直
// 线段1...线段N-2平移段悬挂点→悬挂点- 水平
// 线段N-1下降段悬挂点→地面- 垂直
int totalSegments = _pathPoints.Count - 1;
int firstSegment = 0; // 起吊段
int lastSegment = totalSegments - 1; // 下降段
if (segmentIndex == firstSegment)
{
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, segmentProgress);
LogManager.Debug($"[吊装路径-起吊段] 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
}
else if (segmentIndex > firstSegment && segmentIndex < lastSegment)
{
// 平移段(中间的所有线段):参考点位于顶部悬挂点
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, 1.0);
LogManager.Debug($"[吊装路径-平移段] 线段{segmentIndex}, 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
}
else if (segmentIndex == lastSegment)
{
framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, 1.0 - segmentProgress);
LogManager.Debug($"[吊装路径-下降段] 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})");
}
}
}
else if (_route.PathType == PathType.Rail)
@ -1379,6 +1391,7 @@ namespace NavisworksTransport.Core.Animation
frame.HasCustomRotation = true;
}
else if ((_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) &&
!_route.IsTwoPointVertical &&
TryCreatePlanarPathRotationForFrame(previousFramePoint, framePosition, nextFramePoint, out var planarRotation))
{
if (_route.PathType == PathType.Ground &&
@ -1394,7 +1407,8 @@ namespace NavisworksTransport.Core.Animation
frame.HasCustomRotation = true;
}
}
else if (_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting)
else if ((_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) &&
!_route.IsTwoPointVertical)
{
throw new InvalidOperationException(
$"平面路径第 {i} 帧未能生成完整姿态,已禁止退回旧 yaw 链路。");
@ -4368,8 +4382,9 @@ namespace NavisworksTransport.Core.Animation
var adapter = CoordinateSystemManager.Instance.CreateHostAdapter();
if (!PathTargetFrameResolver.TryResolvePlanarHostYaw(hostForward, adapter.HostType, out targetYawRadians))
{
LogManager.Error($"[{pathType.GetDisplayName()}纯增量起点] 无法从路径方向解析宿主平面角。");
return false;
// 纯垂直路径无水平方向,无法从路径计算 yaw保持物体当前朝向
LogManager.Debug($"[{pathType.GetDisplayName()}纯增量起点] 路径方向为纯垂直,保持当前朝向。");
targetYawRadians = _currentYaw;
}
// 提取非 up 轴修正角度
@ -4387,13 +4402,22 @@ namespace NavisworksTransport.Core.Animation
double currentYawRadians = _currentYaw;
double deltaYawRadians = NormalizeRadians(targetYawRadians - currentYawRadians);
if (Math.Abs(angleXRadians) > 1e-9 || Math.Abs(angleNonUpRadians) > 1e-9 || Math.Abs(deltaYawRadians) > 1e-9)
bool hasRotation = Math.Abs(angleXRadians) > 1e-9 ||
Math.Abs(angleNonUpRadians) > 1e-9 ||
Math.Abs(deltaYawRadians) > 1e-9;
double posDiff = Math.Abs(_trackedPosition.X - targetTrackedPosition.X) +
Math.Abs(_trackedPosition.Y - targetTrackedPosition.Y) +
Math.Abs(_trackedPosition.Z - targetTrackedPosition.Z);
bool hasTranslation = posDiff > 1e-6;
if (hasRotation || hasTranslation)
{
LogManager.Info(
LogManager.Debug(
$"[{pathType.GetDisplayName()}纯增量起点] 当前Yaw={currentYawRadians * 180.0 / Math.PI:F2}°, " +
$"目标Yaw={targetYawRadians * 180.0 / Math.PI:F2}°, 增量Yaw={deltaYawRadians * 180.0 / Math.PI:F2}°, " +
$"当前跟踪点=({_trackedPosition.X:F3},{_trackedPosition.Y:F3},{_trackedPosition.Z:F3}), " +
$"目标跟踪点=({targetTrackedPosition.X:F3},{targetTrackedPosition.Y:F3},{targetTrackedPosition.Z:F3})");
$"目标跟踪点=({targetTrackedPosition.X:F3},{targetTrackedPosition.Y:F3},{targetTrackedPosition.Z:F3}), " +
$"旋转变化={hasRotation}, 位移变化={hasTranslation}");
ModelItemTransformHelper.MoveItemCombinedAxisRotationAndTranslation(
CurrentControlledObject,
@ -5233,7 +5257,7 @@ namespace NavisworksTransport.Core.Animation
return false;
}
if (_route?.PathType == PathType.Ground)
if (!ShouldUseReferenceBasedRealObjectPlanarPose(_route?.PathType ?? PathType.Ground))
{
return false;
}
@ -5326,7 +5350,7 @@ namespace NavisworksTransport.Core.Animation
}
if (!isVirtualObject &&
_route?.PathType == PathType.Ground &&
!ShouldUseReferenceBasedRealObjectPlanarPose(_route?.PathType ?? PathType.Ground) &&
ModelItemTransformHelper.TryGetCurrentGeometryRotation(sourceObject, out var groundActualGeometryRotation))
{
_trackedRotation = groundActualGeometryRotation;
@ -5334,7 +5358,7 @@ namespace NavisworksTransport.Core.Animation
_currentYaw = ModelItemTransformHelper.GetYawFromRotation(_trackedRotation);
var linear = new Transform3D(_trackedRotation).Linear;
LogManager.Info(
LogManager.Debug(
$"[真实物体当前姿态] {sourceObject.DisplayName} Ground 路径已改用实际几何姿态同步,不再读取 fragment 参考姿态: " +
$"X=({linear.Get(0, 0):F4},{linear.Get(1, 0):F4},{linear.Get(2, 0):F4}), " +
$"Y=({linear.Get(0, 1):F4},{linear.Get(1, 1):F4},{linear.Get(2, 1):F4}), " +
@ -5351,6 +5375,15 @@ namespace NavisworksTransport.Core.Animation
return;
}
if (!ShouldUseReferenceBasedRealObjectPlanarPose(_route?.PathType ?? PathType.Ground))
{
// Ground/Hoisting 路径:无需参考姿态,使用单位旋转
_trackedRotation = Rotation3D.Identity;
_hasTrackedRotation = true;
_currentYaw = 0.0;
return;
}
throw new InvalidOperationException(
$"[真实物体参考姿态] {sourceObject.DisplayName} 无法同步参考姿态未能读取当前实际几何姿态fragment 代表姿态也不可用,已禁止回退 Transform。");
}
@ -5437,7 +5470,7 @@ namespace NavisworksTransport.Core.Animation
_objectRotationCorrection = request.RotationCorrection;
_groundPathObjectLiftHeight = UnitsConverter.ConvertFromMeters(request.VerticalLiftInMeters);
LogManager.Info(
LogManager.Debug(
$"[起点摆放] 已应用物体调整请求: 模式={_objectStartPlacementMode}, 角度={_objectRotationCorrection}, " +
$"上下偏移={request.VerticalLiftInMeters:F3}m");

View File

@ -492,6 +492,16 @@ namespace NavisworksTransport.Core.Config
}
}
// 别名树配置(可选)
if (model.ContainsKey("alias_tree"))
{
var aliasTree = model["alias_tree"] as TomlTable;
if (aliasTree != null)
{
config.AliasTree.MaxFullDisplayDepth = GetIntValueWithDefault(aliasTree, "max_full_display_depth", 2, missingItems);
}
}
// 加载自定义类别配置(可选)
if (model.ContainsKey("custom_category"))
{
@ -540,6 +550,7 @@ namespace NavisworksTransport.Core.Config
Animation = new AnimationConfig(),
Logistics = new LogisticsConfig(),
CoordinateSystem = new CoordinateSystemConfig(),
AliasTree = new AliasTreeConfig(),
CustomCategories = new List<CustomCategoryConfigItem>()
};
}
@ -714,6 +725,15 @@ namespace NavisworksTransport.Core.Config
}
}
if (model.ContainsKey("alias_tree"))
{
var aliasTree = model["alias_tree"] as TomlTable;
if (aliasTree != null && config.AliasTree != null)
{
aliasTree["max_full_display_depth"] = config.AliasTree.MaxFullDisplayDepth;
}
}
// 更新自定义类别配置
// 注意由于Tomlyn对数组的处理限制自定义类别通过专门的API管理
// 这里不直接操作TOML模型而是在保存前通过模板合并

View File

@ -43,6 +43,11 @@ namespace NavisworksTransport.Core.Config
/// </summary>
public CoordinateSystemConfig CoordinateSystem { get; set; }
/// <summary>
/// 别名树配置
/// </summary>
public AliasTreeConfig AliasTree { get; set; }
/// <summary>
/// 自定义类别列表
/// </summary>
@ -310,4 +315,15 @@ namespace NavisworksTransport.Core.Config
public string Type { get; set; } = "AutoDetect";
}
/// <summary>
/// 别名树配置
/// </summary>
public class AliasTreeConfig
{
/// <summary>
/// 别名树完整展示的最大层级深度0 = 仅根节点,默认 2
/// </summary>
public int MaxFullDisplayDepth { get; set; } = 2;
}
}

View File

@ -0,0 +1,35 @@
using Autodesk.Navisworks.Api;
namespace NavisworksTransport
{
/// <summary>
/// 快速取面结果。
/// 所有坐标为宿主坐标系。
/// </summary>
public class FaceInferResult
{
/// <summary>点击命中点(宿主坐标系)</summary>
public Point3D HitPoint { get; }
/// <summary>面法向量(已归一化,朝向相机)</summary>
public Vector3D Normal { get; }
/// <summary>命中的 ModelItem</summary>
public ModelItem ModelItem { get; }
/// <summary>推断是否成功</summary>
public bool IsValid { get; }
public FaceInferResult(Point3D hitPoint, Vector3D normal, ModelItem modelItem)
{
HitPoint = hitPoint;
Normal = normal;
ModelItem = modelItem;
IsValid = modelItem != null;
}
/// <summary>创建失败结果</summary>
public static FaceInferResult Invalid { get; } = new FaceInferResult(
new Point3D(), new Vector3D(), null);
}
}

View File

@ -0,0 +1,171 @@
using System;
using Autodesk.Navisworks.Api;
using Autodesk.Navisworks.Api.Plugins;
using NavisworksTransport.Utils;
namespace NavisworksTransport
{
/// <summary>
/// 快速取面工具。单点+像素邻域法推断点击位置所在面的法向量。
/// 与 PathClickToolPlugin 并列,互斥激活。
/// </summary>
[Plugin("FaceInferTool", "NavisworksTransport")]
public class FaceInferToolPlugin : ToolPlugin
{
/// <summary>像素偏移递进序列(共线时自动退到下一级)</summary>
private static readonly int[] PixelOffsets = { 2, 5, 10 };
/// <summary>最小叉积长度阈值(低于此值视为三点共线)</summary>
private const double MinCrossLength = 0.001;
/// <summary>
/// 面推断结果事件。命中物体时传递 FaceInferResult未命中时传递 Invalid。
/// </summary>
public static event EventHandler<FaceInferResult> FaceInferred;
/// <summary>
/// 获取插件的程序集路径(用于动态加载)
/// </summary>
public static string AssemblyPath =>
System.Reflection.Assembly.GetExecutingAssembly().Location;
/// <summary>
/// 允许外部代码手动触发 FaceInferred 事件
/// </summary>
public static void TriggerFaceInferred(object sender, FaceInferResult result)
{
FaceInferred?.Invoke(sender, result);
}
/// <summary>
/// 插件构造函数
/// </summary>
public FaceInferToolPlugin()
{
LogManager.Debug("[FaceInferTool] FaceInferToolPlugin 实例已创建");
}
/// <summary>
/// 鼠标按下事件 — 单次点击内做三次 PickItemFromPoint 推断面法向量
/// </summary>
public override bool MouseDown(View view, KeyModifiers modifiers, ushort button, int x, int y, double timeOffset)
{
if (button != 1) return false; // 只响应左键
LogManager.Debug("[FaceInferTool] ===== 鼠标点击 =====");
LogManager.Debug($"[FaceInferTool] 屏幕坐标: ({x}, {y})");
var result = InferFace(view, x, y);
if (result.IsValid)
{
LogManager.Debug($"[FaceInferTool] ✓ 面推断成功");
LogManager.Debug($"[FaceInferTool] 命中点: ({result.HitPoint.X:F3}, {result.HitPoint.Y:F3}, {result.HitPoint.Z:F3})");
LogManager.Debug($"[FaceInferTool] 法向量: ({result.Normal.X:F3}, {result.Normal.Y:F3}, {result.Normal.Z:F3})");
LogManager.Debug($"[FaceInferTool] 构件: {ModelItemAnalysisHelper.GetSafeDisplayName(result.ModelItem)}");
}
else
{
LogManager.Debug("[FaceInferTool] ✗ 面推断失败(未命中/跨构件/共线)");
}
FaceInferred?.Invoke(this, result);
return false;
}
/// <summary>
/// 鼠标移动事件 — 暂不处理(保持轻量)
/// </summary>
public override bool MouseMove(View view, KeyModifiers modifiers, int x, int y, double timeOffset)
{
return false;
}
/// <summary>
/// 光标样式 — 使用 MarkupQuickPick 与取点工具的 Measure 形成视觉区分
/// </summary>
public override Cursor GetCursor(View view, KeyModifiers modifier)
{
return Cursor.MarkupAutoPoly;
}
/// <summary>
/// 核心面推断逻辑。
/// 在 (x, y) 处做三次 PickItemFromPoint中心+右下邻域),
/// 通过叉积计算法向量,γ 递进规避共线场景。
/// </summary>
private static FaceInferResult InferFace(View view, int x, int y)
{
// 拾取中心点
var centerPick = view.PickItemFromPoint(x, y);
if (centerPick == null)
return FaceInferResult.Invalid;
var p0 = centerPick.Point;
foreach (int offset in PixelOffsets)
{
var rightPick = view.PickItemFromPoint(x + offset, y);
if (rightPick == null) continue;
var downPick = view.PickItemFromPoint(x, y + offset);
if (downPick == null) continue;
// 必须命中同一构件
if (rightPick.ModelItem != centerPick.ModelItem ||
downPick.ModelItem != centerPick.ModelItem)
continue;
var p1 = rightPick.Point;
var p2 = downPick.Point;
var v1 = new Vector3D(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z);
var v2 = new Vector3D(p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z);
var normal = Cross(v1, v2);
double len = Length(normal);
if (len < MinCrossLength)
{
LogManager.Debug($"[FaceInferTool] 偏移{offset}px 三点近似共线,尝试更大偏移");
continue; // 退到下一级偏移
}
// 归一化
normal = new Vector3D(normal.X / len, normal.Y / len, normal.Z / len);
// 统一法向量朝向:指向相机一侧
var camPos = view.Document.CurrentViewpoint.Value.Position;
var toCam = new Vector3D(camPos.X - p0.X, camPos.Y - p0.Y, camPos.Z - p0.Z);
if (Dot(normal, toCam) < 0)
{
normal = new Vector3D(-normal.X, -normal.Y, -normal.Z);
}
return new FaceInferResult(p0, normal, centerPick.ModelItem);
}
LogManager.Debug("[FaceInferTool] 所有偏移量均无法推断平面");
return FaceInferResult.Invalid;
}
// ---- Vector3D 辅助方法(避免依赖 Navisworks Vector3D 静态方法在不同版本的可用性差异)----
private static Vector3D Cross(Vector3D a, Vector3D b)
{
return new Vector3D(
a.Y * b.Z - a.Z * b.Y,
a.Z * b.X - a.X * b.Z,
a.X * b.Y - a.Y * b.X);
}
private static double Length(Vector3D v)
{
return Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}
private static double Dot(Vector3D a, Vector3D b)
{
return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
}
}
}

View File

@ -1594,10 +1594,12 @@ namespace NavisworksTransport
var point = ParsePointElement(pointNode);
if (point != null)
{
route.AddPoint(point);
route.Points.Add(point);
}
}
}
// 仅在内存中重建几何/索引,不触发数据库保存
route.RecalculateRoute("XML导入");
}
return route;

View File

@ -113,6 +113,7 @@ namespace NavisworksTransport
RailPreferredNormalX REAL,
RailPreferredNormalY REAL,
RailPreferredNormalZ REAL,
IsTwoPointVertical INTEGER DEFAULT 0,
CreatedTime DATETIME,
LastModified DATETIME
)
@ -446,6 +447,21 @@ namespace NavisworksTransport
)
");
SafeCreateIndex("CREATE INDEX IF NOT EXISTS idx_detection_manual_record ON CollisionDetectionManualTargets(DetectionRecordId)", "CollisionDetectionManualTargets", "DetectionRecordId");
// 别名映射表(节点重命名 — 别名导航树)
ExecuteNonQuery(@"
CREATE TABLE IF NOT EXISTS AliasMap (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
NodeKey TEXT NOT NULL UNIQUE,
AliasText TEXT NOT NULL,
CreatedAt TEXT DEFAULT (datetime('now')),
ModifiedAt TEXT DEFAULT (datetime('now'))
)
");
SafeCreateIndex("CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey)", "AliasMap", "NodeKey");
// 设置数据库版本
ExecuteNonQuery("PRAGMA user_version = 10010");
}
/// <summary>
@ -523,8 +539,8 @@ namespace NavisworksTransport
// 路径长度由 PathRoute.TotalLength 计算属性实时从 Edges/Points 计算
var sql = @"
INSERT OR REPLACE INTO PathRoutes
(Id, Name, EstimatedTime, TurnRadius, IsCurved, MaxObjectLength, MaxObjectWidth, MaxObjectHeight, SafetyMargin, GridSize, PathType, LiftHeight, RailMountMode, RailPathDefinitionMode, RailNormalOffset, RailPreferredNormalX, RailPreferredNormalY, RailPreferredNormalZ, CreatedTime, LastModified)
VALUES (@id, @name, @time, @turnRadius, @isCurved, @maxLength, @maxWidth, @maxHeight, @safetyMargin, @gridSize, @pathType, @liftHeightMeters, @railMountMode, @railPathDefinitionMode, @railNormalOffset, @railPreferredNormalX, @railPreferredNormalY, @railPreferredNormalZ, @created, @modified)
(Id, Name, EstimatedTime, TurnRadius, IsCurved, MaxObjectLength, MaxObjectWidth, MaxObjectHeight, SafetyMargin, GridSize, PathType, LiftHeight, RailMountMode, RailPathDefinitionMode, RailNormalOffset, RailPreferredNormalX, RailPreferredNormalY, RailPreferredNormalZ, IsTwoPointVertical, CreatedTime, LastModified)
VALUES (@id, @name, @time, @turnRadius, @isCurved, @maxLength, @maxWidth, @maxHeight, @safetyMargin, @gridSize, @pathType, @liftHeightMeters, @railMountMode, @railPathDefinitionMode, @railNormalOffset, @railPreferredNormalX, @railPreferredNormalY, @railPreferredNormalZ, @isTwoPointVertical, @created, @modified)
";
using (var cmd = new SQLiteCommand(sql, _connection))
@ -547,6 +563,7 @@ namespace NavisworksTransport
cmd.Parameters.AddWithValue("@railPreferredNormalX", route.RailPreferredNormal != null ? (object)route.RailPreferredNormal.X : DBNull.Value);
cmd.Parameters.AddWithValue("@railPreferredNormalY", route.RailPreferredNormal != null ? (object)route.RailPreferredNormal.Y : DBNull.Value);
cmd.Parameters.AddWithValue("@railPreferredNormalZ", route.RailPreferredNormal != null ? (object)route.RailPreferredNormal.Z : DBNull.Value);
cmd.Parameters.AddWithValue("@isTwoPointVertical", route.IsTwoPointVertical ? 1 : 0);
cmd.Parameters.AddWithValue("@created", route.CreatedTime);
cmd.Parameters.AddWithValue("@modified", DateTime.Now);
cmd.ExecuteNonQuery();
@ -1605,6 +1622,7 @@ namespace NavisworksTransport
RailMountMode = (RailMountMode)Convert.ToInt32(reader["RailMountMode"]),
RailPathDefinitionMode = (RailPathDefinitionMode)Convert.ToInt32(reader["RailPathDefinitionMode"]),
RailNormalOffset = reader["RailNormalOffset"] != DBNull.Value ? Convert.ToDouble(reader["RailNormalOffset"]) : 0.0,
IsTwoPointVertical = reader["IsTwoPointVertical"] != DBNull.Value && Convert.ToInt32(reader["IsTwoPointVertical"]) == 1,
CreatedTime = Convert.ToDateTime(reader["CreatedTime"]),
LastModified = Convert.ToDateTime(reader["LastModified"])
};
@ -2797,7 +2815,7 @@ namespace NavisworksTransport
SELECT Id, Name, CreatedTime, LastModified,
TurnRadius, IsCurved, MaxObjectLength, MaxObjectWidth, MaxObjectHeight,
SafetyMargin, GridSize, PathType, LiftHeight, RailMountMode, RailPathDefinitionMode,
RailNormalOffset, RailPreferredNormalX, RailPreferredNormalY, RailPreferredNormalZ
RailNormalOffset, RailPreferredNormalX, RailPreferredNormalY, RailPreferredNormalZ, IsTwoPointVertical
FROM PathRoutes
WHERE Id = @Id";
@ -2824,7 +2842,8 @@ namespace NavisworksTransport
LiftHeight = Convert.ToDouble(reader["LiftHeight"]),
RailMountMode = (RailMountMode)Convert.ToInt32(reader["RailMountMode"]),
RailPathDefinitionMode = (RailPathDefinitionMode)Convert.ToInt32(reader["RailPathDefinitionMode"]),
RailNormalOffset = reader["RailNormalOffset"] != DBNull.Value ? Convert.ToDouble(reader["RailNormalOffset"]) : 0.0
RailNormalOffset = reader["RailNormalOffset"] != DBNull.Value ? Convert.ToDouble(reader["RailNormalOffset"]) : 0.0,
IsTwoPointVertical = reader["IsTwoPointVertical"] != DBNull.Value && Convert.ToInt32(reader["IsTwoPointVertical"]) == 1
};
if (reader["RailPreferredNormalX"] != DBNull.Value &&
@ -2875,7 +2894,8 @@ namespace NavisworksTransport
["railnormaloffset"] = "ALTER TABLE PathRoutes ADD COLUMN RailNormalOffset REAL DEFAULT 0",
["railpreferrednormalx"] = "ALTER TABLE PathRoutes ADD COLUMN RailPreferredNormalX REAL",
["railpreferrednormaly"] = "ALTER TABLE PathRoutes ADD COLUMN RailPreferredNormalY REAL",
["railpreferrednormalz"] = "ALTER TABLE PathRoutes ADD COLUMN RailPreferredNormalZ REAL"
["railpreferrednormalz"] = "ALTER TABLE PathRoutes ADD COLUMN RailPreferredNormalZ REAL",
["istwopointvertical"] = "ALTER TABLE PathRoutes ADD COLUMN IsTwoPointVertical INTEGER DEFAULT 0"
};
foreach (var column in columnsToAdd)

View File

@ -2693,6 +2693,16 @@ namespace NavisworksTransport
// 吊装路径智能处理Z坐标
if (route.PathType == PathType.Hoisting)
{
if (route.IsTwoPointVertical)
{
// 2点垂直路径自由修改坐标不限制高度更新保存的提升高度
pointToUpdate.Position = newPosition;
route.LiftHeight = Math.Abs(GetHoistingElevation(route.Points[1].Position) - GetHoistingElevation(route.Points[0].Position));
LogManager.Info($"[更新路径点] 2点垂直路径 {pointToUpdate.Name},提升高度已更新为: {UnitsConverter.ConvertToMeters(route.LiftHeight):F3}米");
RaiseStatusChanged($"吊装高度: {UnitsConverter.ConvertToMeters(route.LiftHeight):F3}米", PathPlanningStatusType.Info);
}
else
{
bool isLiftPoint = pointToUpdate.Name == "提升点";
bool isDescendPoint = pointToUpdate.Name == "下降点";
bool isStartPoint = pointToUpdate.Name == "起吊点";
@ -2755,6 +2765,7 @@ namespace NavisworksTransport
// 吊装路径:检测并移除矩形环路
LogManager.Info($"[吊装路径] 修改路径点后检测矩形环路");
RemoveRectangularLoops(route);
}
}
else
{
@ -4010,8 +4021,64 @@ namespace NavisworksTransport
LogManager.Error($"[吊装模式] 生成起吊点和提升点失败: {ex.Message}", ex);
}
// 提示用户点击添加转向点
RaiseStatusChanged($"吊装高度已设置为 {liftHeightMetersInput:F2}米,点击添加转向点(点击地面位置),完成后点击完成按钮", PathPlanningStatusType.Info);
// 检查完成模式
if (heightDialog.IsTwoPointMode)
{
// 2点路径提升点直接作为终点无下降段落
LogManager.Info("[吊装模式] 首次点击2点路径提升点即为终点");
var lastPoint = CurrentRoute.Points.Last();
lastPoint.Type = PathPointType.EndPoint;
lastPoint.Name = "终点";
// 标记为2点纯垂直路径
CurrentRoute.IsTwoPointVertical = true;
// 清理吊装模式临时变量
_hoistingStartPoint = null;
_hoistingLiftHeight = 0;
_hoistingGroundIntermediatePoints.Clear();
_enableHoistingMode = false;
// 渲染最终路径
if (_renderPlugin != null)
{
_renderPlugin.RenderPath(CurrentRoute);
}
// 添加路径到集合
if (!_routes.Contains(CurrentRoute))
{
_routes.Add(CurrentRoute);
}
// 切换回查看状态
SwitchToViewingState();
// 触发事件通知UI刷新
RaisePathPointsListUpdated(CurrentRoute, "2点路径完成");
SetCurrentRouteInternal(CurrentRoute, triggerEvent: true);
RaiseRouteGenerated(CurrentRoute, RouteGenerationMethod.Manual);
RaiseStatusChanged("2点垂直吊装路径创建完成", PathPlanningStatusType.Success);
}
else if (heightDialog.IsSetAsEndPoint)
{
LogManager.Info("[吊装模式] 首次点击设为终点,直接完成路径");
bool finished = FinishEditing();
if (finished && CurrentRoute != null)
{
RaisePathPointsListUpdated(CurrentRoute, "设为终点完成路径");
}
else
{
RaiseStatusChanged("吊装路径完成失败", PathPlanningStatusType.Warning);
}
}
else
{
// 提示用户点击添加转向点
RaiseStatusChanged($"吊装高度已设置为 {liftHeightMetersInput:F2}米,点击添加转向点(点击地面位置),完成后点击完成按钮", PathPlanningStatusType.Info);
}
}
catch (Exception ex)
{

View File

@ -751,6 +751,11 @@ namespace NavisworksTransport
/// </summary>
public double LiftHeight { get; set; }
/// <summary>
/// 是否为2点纯垂直路径无水平段仅 PathType == Hoisting 时有效)
/// </summary>
public bool IsTwoPointVertical { get; set; }
/// <summary>
/// Rail 路径安装方式(仅当 PathType == Rail 时有效)
/// </summary>

View File

@ -36,7 +36,7 @@ namespace NavisworksTransport.UI.WPF.Converters
string text = value as string;
if (IsIntermediateText(text))
{
throw new FormatException("请输入有效的数字。");
return Binding.DoNothing;
}
text = text.Trim();
@ -47,7 +47,7 @@ namespace NavisworksTransport.UI.WPF.Converters
return parsedValue;
}
throw new FormatException("请输入有效的数字。");
return Binding.DoNothing;
}
public static bool IsIntermediateText(string text)

View File

@ -183,6 +183,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
private string _name;
private ObservableCollection<PathPointViewModel> _points;
private bool _isActive;
private bool _isSelected;
private string _description;
private DateTime _createdTime;
private DateTime _lastModifiedTime;
@ -313,6 +314,15 @@ namespace NavisworksTransport.UI.WPF.ViewModels
}
}
/// <summary>
/// 是否被选中DataGrid多选绑定
/// </summary>
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
/// <summary>
/// 路径描述
/// </summary>

View File

@ -0,0 +1,141 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ── 别名树工具栏图标 ── -->
<!-- 保存图标(磁盘) -->
<Geometry x:Key="SaveIconGeometry">
M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z
</Geometry>
<!-- 导出图标(向上箭头出盒子) -->
<Geometry x:Key="ExportIconGeometry">
M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z
</Geometry>
<!-- 导入图标(向下箭头入盒子) -->
<Geometry x:Key="ImportIconGeometry">
M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z
</Geometry>
<!-- 自动生成图标(齿轮) -->
<Geometry x:Key="AutoGenIconGeometry">
M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12
A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12
C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95
21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.97 19.05,5.05L16.56,6.05
C16.04,5.65 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2
H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.65 7.44,6.05
L4.95,5.05C4.73,4.97 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22
2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65
4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73
C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.95C7.96,18.35 8.5,18.68
9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82
14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.35 16.56,17.95L19.05,18.95
C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78
21.54,14.63L19.43,12.97Z
</Geometry>
<!-- 清除图标(垃圾桶) -->
<Geometry x:Key="ClearIconGeometry">
M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z
</Geometry>
<!-- 批量编辑图标(列表+铅笔) -->
<Geometry x:Key="BatchEditIconGeometry">
M3,17.25V21H6.75L17.81,9.94L14.06,6.19L3,17.25ZM20.71,7.04C21.1,6.65
21.1,6.02 20.71,5.63L18.37,3.29C17.98,2.9 17.35,2.9 16.96,3.29
L15.13,5.12L18.88,8.87L20.71,7.04Z
</Geometry>
<!-- 聚焦图标(瞄准镜) -->
<Geometry x:Key="FocusIconGeometry">
M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52
22,12C22,6.48 17.52,2 12,2ZM12,20C7.59,20 4,16.41 4,12C4,7.59
7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20ZM12,9
C10.34,9 9,10.34 9,12C9,13.66 10.34,15 12,15C13.66,15 15,13.66
15,12C15,10.34 13.66,9 12,9Z
</Geometry>
<!-- 铅笔编辑图标 -->
<Geometry x:Key="EditIconGeometry">
M3,17.25V21H6.75L17.81,9.94L14.06,6.19L3,17.25ZM20.71,7.04C21.1,6.65
21.1,6.02 20.71,5.63L18.37,3.29C17.98,2.9 17.35,2.9 16.96,3.29
L15.13,5.12L18.88,8.87L20.71,7.04Z
</Geometry>
<!-- 刷新图标 -->
<Geometry x:Key="RefreshIconGeometry">
M17.65,6.35C16.2,4.9 14.21,4 12,4C7.58,4 4.01,7.58 4.01,12
C4.01,16.42 7.58,20 12,20C15.73,20 18.84,17.45 19.73,14H17.65
C16.83,16.33 14.61,18 12,18C8.69,18 6,15.31 6,12C6,8.69 8.69,6
12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z
</Geometry>
<!-- ── 别名树工具栏按钮样式 ── -->
<!-- 图标按钮:紧凑 24×24透明背景悬停蓝色高亮 -->
<Style x:Key="AliasToolbarButtonStyle" TargetType="Button">
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Margin" Value="1,0"/>
<Setter Property="Padding" Value="2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="#FF666666"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="ToolTipService.ShowOnDisabled" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderThickness="0"
CornerRadius="2">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#FFD4E7FF"/>
<Setter Property="Foreground" Value="#FF2B579A"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="#FF4472C4"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#FFBBBBBB"/>
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 紧凑分隔线 -->
<Style x:Key="AliasToolbarSeparatorStyle" TargetType="Separator">
<Setter Property="Width" Value="1"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Margin" Value="3,0"/>
<Setter Property="Background" Value="#FFCCCCCC"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ── 别名树 TreeViewItem 样式 ── -->
<Style x:Key="AliasTreeViewItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="False"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Padding" Value="2,1"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="White"/>
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,136 @@
using System;
using System.ComponentModel;
using System.Windows.Input;
using Autodesk.Navisworks.Api;
namespace NavisworksTransport.UI.WPF.ViewModels
{
/// <summary>
/// 别名树单节点 ViewModel。
/// 支持 INotifyPropertyChanged 以驱动 TreeView 实时更新。
/// </summary>
public class AliasNodeViewModel : INotifyPropertyChanged
{
private string _displayName;
private string _originalName;
private string _alias;
private bool _isEditing;
private bool _isOrphan;
private bool _hasAlias;
private int _childCount;
/// <summary>节点的序列化标识 key</summary>
public string NodeKey { get; set; }
/// <summary>Navisworks ModelItem 引用(用于选中同步的比较)</summary>
public Autodesk.Navisworks.Api.ModelItem ModelItem { get; set; }
/// <summary>当前显示文本(别名优先,无别名回退到原名)</summary>
public string DisplayName
{
get => _displayName;
set { _displayName = value; OnPropertyChanged(nameof(DisplayName)); }
}
/// <summary>Navisworks 原始名称(只读)</summary>
public string OriginalName
{
get => _originalName;
set { _originalName = value; OnPropertyChanged(nameof(OriginalName)); }
}
/// <summary>用户别名</summary>
public string Alias
{
get => _alias;
set
{
_alias = value;
HasAlias = !string.IsNullOrEmpty(value);
// 更新显示文本:别名 [原名]
DisplayName = HasAlias ? value : (_originalName ?? "?");
OnPropertyChanged(nameof(Alias));
}
}
/// <summary>是否处于就地编辑状态</summary>
public bool IsEditing
{
get => _isEditing;
set
{
if (_isEditing == value) return;
_isEditing = value;
OnPropertyChanged(nameof(IsEditing));
// 进入编辑时初始化 EditValue 为当前别名(无别名用原名)
if (value)
EditValue = HasAlias ? Alias : (OriginalName ?? "");
}
}
/// <summary>编辑框的临时文本(进入编辑时初始化为 DisplayName</summary>
public string EditValue
{
get => _editValue;
set { _editValue = value; OnPropertyChanged(nameof(EditValue)); }
}
private string _editValue;
/// <summary>是否孤儿节点(模型结构变化后无法定位)</summary>
public bool IsOrphan
{
get => _isOrphan;
set { _isOrphan = value; OnPropertyChanged(nameof(IsOrphan)); }
}
/// <summary>是否有别名</summary>
public bool HasAlias
{
get => _hasAlias;
set { _hasAlias = value; OnPropertyChanged(nameof(HasAlias)); }
}
/// <summary>子节点数量(用于展开箭头)</summary>
public int ChildCount
{
get => _childCount;
set { _childCount = value; OnPropertyChanged(nameof(ChildCount)); OnPropertyChanged(nameof(HasChildren)); }
}
/// <summary>树深度0=根)</summary>
public int Depth { get; set; }
/// <summary>是否有子节点</summary>
public bool HasChildren => ChildCount > 0;
/// <summary>子节点集合</summary>
public System.Collections.ObjectModel.ObservableCollection<AliasNodeViewModel> Children { get; }
= new System.Collections.ObjectModel.ObservableCollection<AliasNodeViewModel>();
/// <summary>父节点引用(用于回溯路径)</summary>
public AliasNodeViewModel Parent { get; set; }
public AliasNodeViewModel()
{
StartEditCommand = new RelayCommand(() => IsEditing = true);
}
/// <summary>启动内联编辑的命令</summary>
public ICommand StartEditCommand { get; }
/// <summary>
/// 刷新显示文本(别名变更新调用)
/// </summary>
public void RefreshDisplay()
{
DisplayName = HasAlias ? Alias : (OriginalName ?? "?");
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -359,6 +359,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 角度修正相关字段
private LocalEulerRotationCorrection _objectRotationCorrection = LocalEulerRotationCorrection.Zero; // 物体绕宿主 X/Y/Z 轴的角度修正
private double _objectGroundLiftHeightInMeters;
private double? _autoAabbSx, _autoAabbSy, _autoAabbSz;
// 移动物体原始尺寸(米,在选择物体时保存)
private double _objectOriginalLength; // 物体原始长度X方向
@ -2189,6 +2190,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels
_objectGroundLiftHeightInMeters = placementRequest.VerticalLiftInMeters;
_pathAnimationManager?.ApplyObjectStartPlacementRequest(placementRequest);
ObjectRotationCorrection = placementRequest.RotationCorrection;
if (dialog.AutoAdjustAabbSizes.HasValue)
(_autoAabbSx, _autoAabbSy, _autoAabbSz) = dialog.AutoAdjustAabbSizes.Value;
string liftSummary = enableGroundLift
? $", 上下偏移={_objectGroundLiftHeightInMeters:F3}m"
@ -2229,10 +2232,10 @@ namespace NavisworksTransport.UI.WPF.ViewModels
return ObjectPassageProjectionOptimizationResult.CreateFailure("动画物体为空,无法自动调整。");
}
if (!TryCreatePassageProjectionAxes(request, out Vector3 hostSide, out Vector3 hostUp))
{
return ObjectPassageProjectionOptimizationResult.CreateFailure("路径方向退化,无法计算通行截面。");
}
// 固定评估坐标系:前向=宿主X轴截面=YZ平面
CoordinateSystemType hst = CoordinateSystemManager.Instance.ResolvedType;
var hostUp = hst == CoordinateSystemType.YUp ? Vector3.UnitY : Vector3.UnitZ;
var hostSide = Vector3.Cross(Vector3.UnitX, hostUp);
var originalCorrection = _objectRotationCorrection;
double originalLiftInMeters = _objectGroundLiftHeightInMeters;
@ -2244,27 +2247,52 @@ namespace NavisworksTransport.UI.WPF.ViewModels
request,
correction =>
{
// 从 correction 扣除路径 yaw动画链路会在起点再次加上
// 这样 targetYaw = pathYaw + (correctionY - pathYaw) = correctionY
// correction 成为「从 CAD 出发的总 yaw 角度」
CoordinateSystemType hst = CoordinateSystemManager.Instance.ResolvedType;
double pyAdj = 0;
if (PathTargetFrameResolver.TryResolvePlanarHostYaw(request.HostPathForward, hst, out double pyRad))
{
pyAdj = pyRad * 180.0 / Math.PI;
}
var corr = correction;
var baseAdjusted = new LocalEulerRotationCorrection(
corr.XDegrees,
hst == CoordinateSystemType.YUp ? corr.YDegrees - pyAdj : corr.YDegrees,
hst == CoordinateSystemType.ZUp ? corr.ZDegrees - pyAdj : corr.ZDegrees);
var placementRequest = ObjectStartPlacementRequest.CreateRotationCorrection(
baseAdjusted,
correction,
originalLiftInMeters);
_pathAnimationManager.ApplyObjectStartPlacementRequest(placementRequest);
BoundingBox3D bounds = animatedObject.BoundingBox();
return CalculateMeasuredAabbProjectionScore(bounds, hostSide, hostUp);
var score = CalculateMeasuredAabbProjectionScore(bounds, hostSide, hostUp);
bool meetsConstraint = request.RequireWidthLarger
? score.WidthAcrossPath >= score.HeightAlongHostUp
: score.HeightAlongHostUp >= score.WidthAcrossPath;
if (!meetsConstraint)
return ObjectPassageProjectionScore.Invalid;
return score;
});
// 计算上下偏移:应用最优旋转后,使物体底面贴合物流车高度
if (result.Success && CurrentPathRoute?.PathType == PathType.Ground)
{
var measureRequest = ObjectStartPlacementRequest.CreateRotationCorrection(result.Correction, 0);
_pathAnimationManager.ApplyObjectStartPlacementRequest(measureRequest);
BoundingBox3D measureBounds = animatedObject.BoundingBox();
// 物体底面和地面在世界坐标系中的差值
double objectBottomWorld = hst == CoordinateSystemType.YUp
? measureBounds.Min.Y : measureBounds.Min.Z;
Point3D pathStartPoint = new Point3D(
CurrentPathRoute.Points[0].X,
CurrentPathRoute.Points[0].Y,
CurrentPathRoute.Points[0].Z);
double groundLevel = hst == CoordinateSystemType.YUp
? pathStartPoint.Y : pathStartPoint.Z;
// 需要的上下偏移 = (地面 + 车高) - 物体当前底面
double neededLiftModelUnits = groundLevel + request.VehicleHeightModelUnits - objectBottomWorld;
result = ObjectPassageProjectionOptimizationResult.CreateSuccess(
result.Correction, result.Score, neededLiftModelUnits,
measureBounds.Max.X - measureBounds.Min.X,
measureBounds.Max.Y - measureBounds.Min.Y,
measureBounds.Max.Z - measureBounds.Min.Z);
LogManager.Debug(
$"[自动调整] 物体底面={objectBottomWorld:F3}, 地面={groundLevel:F3}, " +
$"车高={request.VehicleHeightModelUnits:F3}, 计算偏移={neededLiftModelUnits:F3} (模型单位)");
}
}
finally
{
@ -2276,7 +2304,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
if (result.Success)
{
LogManager.Info(
LogManager.Debug(
$"[自动调整实测] 最优角度={result.Correction}, " +
$"实测截面宽度={result.Score.WidthAcrossPath:F3}, 高度={result.Score.HeightAlongHostUp:F3}, 面积={result.Score.Area:F3}");
}
@ -4702,7 +4730,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels
}
// 计算旋转后的有效尺寸(考虑角度修正)
(double effectiveLength, double effectiveWidth, double effectiveHeight) = CalculateRotatedDimensions();
double effectiveLength, effectiveWidth, effectiveHeight;
if (_autoAabbSx.HasValue)
{
effectiveLength = _autoAabbSx.Value;
effectiveWidth = _autoAabbSz.Value;
effectiveHeight = _autoAabbSy.Value;
_autoAabbSx = null;
}
else
{
(effectiveLength, effectiveWidth, effectiveHeight) = CalculateRotatedDimensions();
}
var renderPlugin = PathPointRenderPlugin.Instance;
if (renderPlugin == null)
@ -4736,7 +4775,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 地面路径物体的长度方向X轴前进方向朝向路径方向
// 通行空间沿路径方向 = X方向尺寸长度垂直于路径方向 = Y方向尺寸宽度高度上方加间隙下方不需要因为有地面
passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向)
passageNormalToPath = effectiveHeight + (_objectGroundLiftHeightInMeters * metersToUnitsPassage) + safetyMargin; // 物体高度 + 上下偏移 + 上方间隙
passageNormalToPath = effectiveHeight + safetyMargin + Math.Max(0, _objectGroundLiftHeightInMeters * metersToUnitsPassage); // 负lift不压缩高度
passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向)
// 地面路径不需要分段参数
passageNormalToPathVertical = passageNormalToPath;
@ -4780,7 +4819,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 物体的长度方向X轴前进方向朝向路径方向
// 通行空间沿路径方向 = X方向尺寸长度垂直于路径方向 = Y方向尺寸宽度高度上方加间隙下方不需要因为有地面
passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向)
passageNormalToPath = effectiveHeight + (_objectGroundLiftHeightInMeters * metersToUnitsPassage) + safetyMargin; // 物体高度 + 上下偏移 + 上方间隙
passageNormalToPath = effectiveHeight + safetyMargin + Math.Max(0, _objectGroundLiftHeightInMeters * metersToUnitsPassage); // 物体高度 + 上方间隙负lift不压缩高度
passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向)
// 地面路径不需要分段参数
passageNormalToPathVertical = passageNormalToPath;

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using System.Windows.Input;
using System.Windows.Threading;
using Autodesk.Navisworks.Api;
using Autodesk.Navisworks.Api.Plugins;
using ComApiBridge = Autodesk.Navisworks.Api.ComApi.ComApiBridge;
using NavisworksTransport.UI.WPF.Collections;
using NavisworksTransport.Core;
@ -184,6 +185,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public ICommand CoordinateSystemExplorerCommand { get; private set; }
public ICommand CaptureRealObjectTransformCommand { get; private set; }
public ICommand DetectFragmentDefaultUpCommand { get; private set; }
public ICommand TestFaceInferCommand { get; private set; }
// 坐标系设置
@ -339,6 +341,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
CoordinateSystemExplorerCommand = new RelayCommand(() => ExecuteCoordinateSystemExplorer());
CaptureRealObjectTransformCommand = new RelayCommand(() => ExecuteCaptureRealObjectTransform());
DetectFragmentDefaultUpCommand = new RelayCommand(() => ExecuteDetectFragmentDefaultUp());
TestFaceInferCommand = new RelayCommand(() => ExecuteTestFaceInfer());
// 初始化坐标系选项
@ -2024,6 +2027,102 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public Vector3 SampleTranslation { get; set; }
}
/// <summary>
/// 快速取面测试 — 激活 FaceInferToolPlugin用户在模型上点击后显示面法向量
/// </summary>
private void ExecuteTestFaceInfer()
{
SafeExecute(() =>
{
var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
if (doc == null || doc.IsClear)
{
System.Windows.MessageBox.Show(
"没有活动的文档!请先打开一个模型。",
"错误",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return;
}
// 加载并激活 FaceInferToolPlugin
var assemblyPath = FaceInferToolPlugin.AssemblyPath;
Application.Plugins.AddPluginAssembly(assemblyPath);
var record = (ToolPluginRecord)Application.Plugins.FindPlugin("FaceInferTool.NavisworksTransport");
if (record == null)
{
System.Windows.MessageBox.Show(
"无法找到 FaceInferTool 插件!",
"错误",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return;
}
var plugin = record.LoadPlugin();
if (plugin == null)
{
System.Windows.MessageBox.Show(
"FaceInferTool 插件加载失败!",
"错误",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return;
}
UpdateMainStatus("请在模型上点击一个面…");
// 一次性事件处理器:取到面后自动取消订阅并恢复工具
EventHandler<FaceInferResult> handler = null;
handler = (sender, result) =>
{
FaceInferToolPlugin.FaceInferred -= handler;
// 恢复为选择工具
try
{
Application.MainDocument.Tool.Value = Tool.Select;
}
catch { }
if (!result.IsValid)
{
UpdateMainStatus("面推断失败:未命中有效面");
return;
}
var info = new StringBuilder();
info.AppendLine("=== 面推断结果 ===");
info.AppendLine();
var namedParent = ModelItemAnalysisHelper.FindNamedParentContainer(result.ModelItem);
var displayName = ModelItemAnalysisHelper.GetSafeDisplayName(namedParent);
info.AppendLine($"构件: {displayName}");
info.AppendLine($"命中点: ({result.HitPoint.X:F3}, {result.HitPoint.Y:F3}, {result.HitPoint.Z:F3})");
info.AppendLine($"法向量: ({result.Normal.X:F6}, {result.Normal.Y:F6}, {result.Normal.Z:F6})");
string message = info.ToString();
LogManager.Info("[面推断测试]\n" + message);
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var dialog = new NavisworksTransport.UI.WPF.Views.CoordinateSystemResultDialog
{
Title = "面推断结果",
ResultText = message
};
DialogHelper.SetOwnerSafely(dialog);
dialog.ShowDialog();
});
var statusName = ModelItemAnalysisHelper.GetSafeDisplayName(namedParent);
UpdateMainStatus($"面推断完成 — {statusName}");
};
FaceInferToolPlugin.FaceInferred += handler;
Application.MainDocument.Tool.SetCustomToolPlugin(plugin);
}, "面推断测试");
}
/// <summary>
/// 读取选中对象的Transform信息并测试旋转
/// </summary>

View File

@ -1,7 +1,7 @@
<Window x:Class="NavisworksTransport.UI.WPF.Views.AerialHeightDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="设置吊装提升高度" Height="240" Width="400"
Title="设置吊装提升高度" Height="240" Width="500"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ShowInTaskbar="False"
@ -71,6 +71,15 @@
Background="#FFE8F5E9"
Foreground="#FF2E7D32"
ToolTip="将此点设为路径终点,直接降落完成路径"/>
<Button Content="2点路径"
Click="OnTwoPointPathClick"
Style="{StaticResource SecondaryButtonStyle}"
Width="90"
Height="32"
Margin="0,0,10,0"
Background="#FFFFF3E0"
Foreground="#FFE65100"
ToolTip="仅生成起点到终点的垂直路径2个点无下降段"/>
<Button Content="取消"
Click="OnCancelClick"
Style="{StaticResource SecondaryButtonStyle}"

View File

@ -18,6 +18,11 @@ namespace NavisworksTransport.UI.WPF.Views
/// </summary>
public bool IsSetAsEndPoint { get; private set; }
/// <summary>
/// 获取是否2点路径模式仅起点→终点无下降段
/// </summary>
public bool IsTwoPointMode { get; private set; }
/// <summary>
/// 构造函数
/// </summary>
@ -59,6 +64,18 @@ namespace NavisworksTransport.UI.WPF.Views
}
}
/// <summary>
/// 2点路径按钮点击事件 - 仅起点到终点的垂直路径
/// </summary>
private void OnTwoPointPathClick(object sender, RoutedEventArgs e)
{
if (ValidateHeight())
{
IsTwoPointMode = true;
DialogResult = true;
}
}
/// <summary>
/// 取消按钮点击事件
/// </summary>

View File

@ -0,0 +1,243 @@
<UserControl x:Class="NavisworksTransport.UI.WPF.Views.AliasTreeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:NavisworksTransport.UI.WPF.ViewModels"
xmlns:converters="clr-namespace:NavisworksTransport.UI.WPF.Converters"
Background="White"
PreviewKeyDown="UserControl_PreviewKeyDown">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="../Resources/NavisworksStyles.xaml"/>
<ResourceDictionary Source="../Resources/AliasTreeIcons.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- Converters -->
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
<!-- 别名树 TreeView 的模板 -->
<HierarchicalDataTemplate DataType="{x:Type vm:AliasNodeViewModel}"
ItemsSource="{Binding Children}">
<Grid VerticalAlignment="Center">
<!-- 显示模式StackPanel 包含别名(红色)+ [原文](黑色) -->
<StackPanel Orientation="Horizontal">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<!-- 别名/原名 -->
<TextBlock Text="{Binding DisplayName}"
FontSize="12" FontWeight="Bold"
TextTrimming="CharacterEllipsis" MaxWidth="140">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#FF1A1A1A"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasAlias}" Value="True">
<Setter Property="Foreground" Value="#FFC62828"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsOrphan}" Value="True">
<Setter Property="TextDecorations" Value="Strikethrough"/>
<Setter Property="Foreground" Value="#FFAAAAAA"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- [原文] 后缀,黑色加粗 -->
<TextBlock FontSize="12" FontWeight="Bold"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#FF1A1A1A"/>
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasAlias}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
<Run Text=" ["/>
<Run Text="{Binding OriginalName}"/>
<Run Text="]"/>
</TextBlock>
</StackPanel>
<!-- 编辑模式 -->
<TextBox Text="{Binding EditValue, UpdateSourceTrigger=PropertyChanged}"
FontSize="12"
KeyDown="OnAliasTextBoxKeyDown"
LostFocus="OnAliasTextBoxLostFocus"
IsVisibleChanged="OnEditTextBoxVisibleChanged"
Background="#FFFFFDE6"
BorderBrush="#FF1565C0" BorderThickness="1">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
</Grid>
</HierarchicalDataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<DockPanel>
<!-- ── 顶部工具栏 ── -->
<Border DockPanel.Dock="Top"
Background="#FFF5F5F5"
BorderBrush="#FFDDDDDD"
BorderThickness="0,0,0,1"
Padding="4,2">
<DockPanel>
<!-- 当前选中节点名 -->
<TextBlock x:Name="TbCurrentNode"
DockPanel.Dock="Left"
Text="当前: 无"
VerticalAlignment="Center"
Margin="2,0,6,0"
FontSize="11"
Foreground="#FF666666"
MaxWidth="120"
TextTrimming="CharacterEllipsis"/>
<!-- 图标工具栏 -->
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
<Separator Style="{StaticResource AliasToolbarSeparatorStyle}"/>
<Button x:Name="BtnAutoGen" Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="自动生成别名"
Click="OnAutoGenClick">
<Path Data="{StaticResource AutoGenIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
<Separator Style="{StaticResource AliasToolbarSeparatorStyle}"/>
<Button x:Name="BtnExport" Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="导出别名 (JSON)"
Click="OnExportClick">
<Path Data="{StaticResource ExportIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
<Button x:Name="BtnImport" Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="导入别名 (JSON)"
Click="OnImportClick">
<Path Data="{StaticResource ImportIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
<Separator Style="{StaticResource AliasToolbarSeparatorStyle}"/>
<Button x:Name="BtnClearAll" Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="清除全部别名"
Click="OnClearAllClick">
<Path Data="{StaticResource ClearIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
<Button x:Name="BtnRefresh" Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="刷新树结构"
Click="OnRefreshClick">
<Path Data="{StaticResource RefreshIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
</StackPanel>
<!-- 别名输入框 -->
<TextBox x:Name="TxtAliasInput"
DockPanel.Dock="Right"
Width="100"
Height="20"
FontSize="11"
Margin="0,0,4,0"
ToolTip="输入别名后回车保存"
KeyDown="OnAliasInputKeyDown"/>
</DockPanel>
</Border>
<!-- ── 别名树 ── -->
<TreeView x:Name="AliasTreeView"
MinWidth="300"
VirtualizingPanel.IsVirtualizing="False"
Background="White"
BorderThickness="0"
ItemContainerStyle="{StaticResource AliasTreeViewItemStyle}"
TreeViewItem.Expanded="OnTreeViewItemExpanded"
TreeViewItem.Selected="OnTreeViewItemSelected"
TreeViewItem.PreviewMouseRightButtonDown="OnTreeViewItemRightClick"
TreeViewItem.PreviewMouseLeftButtonDown="OnTreeViewItemLeftClick">
<TreeView.Resources>
<!-- 无焦点时也显示蓝色选中态(浅灰蓝底色 + 白字,匹配选择树) -->
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}"
Color="#3A6EA5"/>
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}"
Color="White"/>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
Color="#3A6EA5"/>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}"
Color="White"/>
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}"
Color="#1A1A1A"/>
</TreeView.Resources>
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem Header="编辑别名" Click="OnCtxEditClick">
<MenuItem.Icon>
<Path Data="{StaticResource EditIconGeometry}"
Fill="#FF666666" Width="14" Height="14" Stretch="Uniform"
Margin="0,0,4,0"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="批量编辑子节点..." Click="OnCtxBatchEditClick">
<MenuItem.Icon>
<Path Data="{StaticResource BatchEditIconGeometry}"
Fill="#FF666666" Width="14" Height="14" Stretch="Uniform"
Margin="0,0,4,0"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="清除别名" Click="OnCtxClearClick"/>
<Separator/>
<MenuItem Header="在视图中聚焦" Click="OnCtxFocusClick">
<MenuItem.Icon>
<Path Data="{StaticResource FocusIconGeometry}"
Fill="#FF666666" Width="14" Height="14" Stretch="Uniform"
Margin="0,0,4,0"/>
</MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="复制原名" Click="OnCtxCopyNameClick"/>
</ContextMenu>
</TreeView.ContextMenu>
</TreeView>
<!-- ── 底部状态栏 ── -->
<StatusBar DockPanel.Dock="Bottom"
Background="#FFF8F8F8"
BorderBrush="#FFDDDDDD"
BorderThickness="0,1,0,0"
Height="20"
Visibility="Collapsed">
<StatusBarItem x:Name="StatusBarItem">
<TextBlock x:Name="TbStatus" FontSize="10" Foreground="#FF666666"/>
</StatusBarItem>
</StatusBar>
</DockPanel>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@ -319,14 +319,9 @@ NavisworksTransport 检测动画页签视图 - 采用与类别设置和分层管
<!-- 移动物体来源选择 -->
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
<RadioButton Content="选择模型物体"
GroupName="ObjectSource"
VerticalAlignment="Center"/>
<RadioButton Content="使用虚拟物体"
<CheckBox Content="使用虚拟物体"
IsChecked="{Binding UseVirtualObject}"
GroupName="ObjectSource"
VerticalAlignment="Center"
Margin="10,0,0,0"/>
VerticalAlignment="Center"/>
</StackPanel>
<!-- 方式1选择模型物体 -->

View File

@ -367,15 +367,15 @@ NavisworksTransport 碰撞检测报告对话框 - 采用与主界面一致的Nav
<Border Background="#FFF5F5F5"
BorderBrush="#FFE0E0E0"
BorderThickness="1"
Padding="5"
Visibility="{Binding HasScreenshots, Converter={StaticResource BooleanToVisibilityConverter}}">
Padding="5">
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal">
<!-- 缩略图列表 -->
<ItemsControl x:Name="ThumbnailsItemsControl"
ItemsSource="{Binding Screenshots}"
VerticalAlignment="Center">
VerticalAlignment="Center"
Visibility="{Binding HasScreenshots, Converter={StaticResource BooleanToVisibilityConverter}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>

View File

@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:NavisworksTransport.UI.WPF.Converters"
Title="调整物体" Height="380" Width="470"
Title="调整物体" Height="430" Width="470"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ShowInTaskbar="False"
@ -127,6 +127,34 @@
<TextBlock Grid.Column="2" Text="m" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="8,0,0,0"/>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<TextBlock Text="物流车高:" VerticalAlignment="Center" FontSize="11" Foreground="#FF333333"/>
<TextBox Grid.Column="1"
x:Name="VehicleHeightTextBox"
Text="{Binding VehicleHeightInMeters, Mode=TwoWay, UpdateSourceTrigger=Explicit, Converter={StaticResource EditableNumberConverter}, ConverterParameter=0.###, ValidatesOnExceptions=True, NotifyOnValidationError=True}"
Height="28"
VerticalContentAlignment="Center"
Padding="8,0"
FontSize="11"
BorderBrush="#FFCCCCCC"
BorderThickness="1"/>
<TextBlock Grid.Column="2" Text="m" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="8,0,0,0"/>
</Grid>
<!-- 门型偏好 -->
<CheckBox x:Name="WidthLargerCheckBox"
IsChecked="{Binding RequireWidthLarger}"
Content="宽 > 高(否则高 > 宽)"
ToolTip="根据物流通道门型选择"
FontSize="10"
Foreground="#FF666666"
Margin="0,0,0,8"/>
<!-- 快捷按钮 -->
<TextBlock Text="快捷角度(作用于当前焦点输入框):" FontSize="10" Foreground="#FF666666" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">

View File

@ -5,6 +5,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using NavisworksTransport.UI.WPF.Converters;
using NavisworksTransport.Utils;
using NavisworksTransport.Utils.CoordinateSystem;
namespace NavisworksTransport.UI.WPF.Views
@ -27,6 +28,25 @@ namespace NavisworksTransport.UI.WPF.Views
private readonly ObjectPassageProjectionOptimizationRequest _autoAdjustmentRequest;
private readonly Func<ObjectPassageProjectionOptimizationResult> _autoAdjustmentProvider;
private readonly bool _isGroundLiftEnabled;
private double _vehicleHeightInMeters = 0.15;
private bool _requireWidthLarger = false;
/// <summary>
/// 门型偏好true=宽>高false=高>宽(默认)
/// </summary>
public bool RequireWidthLarger
{
get => _requireWidthLarger;
set => _requireWidthLarger = value;
}
public (double sx, double sy, double sz)? AutoAdjustAabbSizes { get; private set; }
public double VehicleHeightInMeters
{
get => _vehicleHeightInMeters;
set => _vehicleHeightInMeters = value;
}
public double RotationXDegrees
{
@ -139,21 +159,47 @@ namespace NavisworksTransport.UI.WPF.Views
return;
}
ObjectPassageProjectionOptimizationResult result = _autoAdjustmentProvider != null
? _autoAdjustmentProvider()
: ObjectPassageProjectionOptimizer.Optimize(_autoAdjustmentRequest);
if (!result.Success)
// 同步门型偏好和车辆高度到请求
if (_autoAdjustmentRequest != null)
{
MessageBox.Show(result.ErrorMessage ?? "自动调整失败。", "自动调整", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
_autoAdjustmentRequest.RequireWidthLarger = _requireWidthLarger;
_autoAdjustmentRequest.VehicleHeightModelUnits =
UnitsConverter.ConvertFromMeters(_vehicleHeightInMeters);
}
RotationXDegrees = NormalizeDisplayDegrees(result.Correction.XDegrees);
RotationYDegrees = NormalizeDisplayDegrees(result.Correction.YDegrees);
RotationZDegrees = NormalizeDisplayDegrees(result.Correction.ZDegrees);
LogManager.Info(
$"[自动调整] 已写入角度: {RotationCorrection}, " +
$"截面宽度={result.Score.WidthAcrossPath:F3}, 高度={result.Score.HeightAlongHostUp:F3}, 面积={result.Score.Area:F3}");
var oldCursor = System.Windows.Input.Mouse.OverrideCursor;
try
{
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait;
ObjectPassageProjectionOptimizationResult result = _autoAdjustmentProvider != null
? _autoAdjustmentProvider()
: ObjectPassageProjectionOptimizer.Optimize(_autoAdjustmentRequest);
if (!result.Success)
{
MessageBox.Show(result.ErrorMessage ?? "自动调整失败。", "自动调整", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
PathTargetFrameResolver.TryResolvePlanarHostYaw(_autoAdjustmentRequest.HostPathForward, CoordinateSystemManager.Instance.ResolvedType, out double yawRad);
RotationXDegrees = NormalizeDisplayDegrees(result.Correction.XDegrees);
RotationYDegrees = NormalizeDisplayDegrees(result.Correction.YDegrees + yawRad * 180.0 / Math.PI);
RotationZDegrees = NormalizeDisplayDegrees(result.Correction.ZDegrees);
if (result.ComputedLiftOffsetModelUnits.HasValue)
{
GroundPathLiftHeightInMeters = Math.Round(UnitsConverter.ConvertToMeters(result.ComputedLiftOffsetModelUnits.Value), 3);
}
if (result.AabbSx.HasValue)
AutoAdjustAabbSizes = (result.AabbSx.Value, result.AabbSy.Value, result.AabbSz.Value);
LogManager.Debug(
$"[自动调整] 已写入角度: {RotationCorrection}, " +
$"截面宽度={result.Score.WidthAcrossPath:F3}, 高度={result.Score.HeightAlongHostUp:F3}, 面积={result.Score.Area:F3}");
}
finally
{
System.Windows.Input.Mouse.OverrideCursor = oldCursor;
}
}
private void OnQuickAngleClick(object sender, RoutedEventArgs e)
@ -287,6 +333,17 @@ namespace NavisworksTransport.UI.WPF.Views
return false;
}
if (!TryUpdateNumberTextBox(VehicleHeightTextBox, "请输入有效的物流车高度。"))
{
return false;
}
if (VehicleHeightInMeters < 0.0)
{
ShowInputError("物流车高度必须大于或等于 0。", VehicleHeightTextBox);
return false;
}
return true;
}

View File

@ -204,17 +204,17 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
<!-- 路径管理按钮 -->
<Grid Margin="0,5,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="手动创建"
<Button Content="地面"
Command="{Binding NewPathCommand}"
Style="{StaticResource ActionButtonStyle}"/>
<Button Content="传统空轨路径"
Command="{Binding NewRailPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
ToolTip="创建基于空轨基准线吸附的传统 Rail 路径"/>
<Button Content="吊装路径"
<Button Content="吊装"
Command="{Binding NewHoistingPathCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="创建吊装路径(支持多层)"/>
<Button Content="空轨"
Command="{Binding NewRailPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
ToolTip="创建基于空轨基准线吸附的传统 Rail 路径"/>
<Button Content="复制"
Command="{Binding DuplicatePathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
@ -236,15 +236,15 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
</Grid>
<!-- 路径列表 -->
<DataGrid ItemsSource="{Binding PathRoutes}"
<DataGrid x:Name="PathRoutesGrid" ItemsSource="{Binding PathRoutes}"
SelectedItem="{Binding SelectedPathRoute}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
SelectionMode="Extended"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
Height="200"
Height="340"
BorderBrush="#FFCCCCCC"
BorderThickness="1"
Margin="0,5,0,0">
@ -692,14 +692,14 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
<Button Content="导入"
Command="{Binding ImportPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"/>
<Button Content="导出"
Command="{Binding SaveAsPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
IsEnabled="{Binding CanExecuteSaveAsPath}"/>
<Button Content="导出全部"
Command="{Binding ExportPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
IsEnabled="{Binding CanExecuteExportPath}"/>
<Button Content="导出选中路径"
Command="{Binding SaveAsPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
IsEnabled="{Binding CanExecuteSaveAsPath}"/>
<Button Content="生成导航地图"
Click="OnGenerateNavigationMapButtonClick"
Style="{StaticResource SecondaryButtonStyle}"

View File

@ -17,30 +17,46 @@ namespace NavisworksTransport.UI.WPF.Views
public partial class PathEditingView : UserControl
{
/// <summary>
/// ViewModel属性用于外部访问
/// ViewModel属性,用于外部访问
/// </summary>
public PathEditingViewModel ViewModel { get; private set; }
/// <summary>
/// 时标服务实例
/// </summary>
private TimeTagService _timeTagService;
/// <summary>
/// 构造函数需要传入主ViewModel以支持统一状态栏
/// 构造函数,需要传入主ViewModel以支持统一状态栏
/// </summary>
/// <param name="mainViewModel">主ViewModel用于统一状态栏</param>
/// <param name="mainViewModel">主ViewModel,用于统一状态栏</param>
public PathEditingView(LogisticsControlViewModel mainViewModel)
{
InitializeComponent();
// 创建ViewModel传入主ViewModel引用
// 创建ViewModel,传入主ViewModel引用
ViewModel = new PathEditingViewModel(mainViewModel);
DataContext = ViewModel;
PathRoutesGrid.SelectionChanged += OnPathRoutesSelectionChanged;
LogManager.Info("PathEditingView初始化完成 - 支持统一状态栏");
}
/// <summary>
/// 同步 DataGrid 多选状态到 ViewModel 的 IsSelected
/// </summary>
private void OnPathRoutesSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ViewModel?.PathRoutes == null) return;
foreach (PathRouteViewModel item in e.RemovedItems)
item.IsSelected = false;
foreach (PathRouteViewModel item in e.AddedItems)
item.IsSelected = true;
}
/// <summary>
/// 清理资源
/// </summary>
@ -50,7 +66,7 @@ namespace NavisworksTransport.UI.WPF.Views
{
// 清理ViewModel
ViewModel?.Cleanup();
LogManager.Info("PathEditingView资源清理完成");
}
catch (Exception ex)
@ -60,14 +76,14 @@ namespace NavisworksTransport.UI.WPF.Views
}
/// <summary>
/// 路径列表中"时标"按钮点击打开时间标签窗口
/// 路径列表中"时标"按钮点击,打开时间标签窗口
/// </summary>
private void OnTimeTagButtonClick(object sender, RoutedEventArgs e)
{
try
{
LogManager.Info("时标按钮被点击");
// 从按钮的DataContext获取当前路径ViewModel
if (!(sender is Button button) || !(button.DataContext is PathRouteViewModel routeVm))
{
@ -82,7 +98,7 @@ namespace NavisworksTransport.UI.WPF.Views
LogManager.Warning("PathRouteViewModel中的Route为空");
return;
}
// 确保服务已初始化
if (_timeTagService == null)
{
@ -91,13 +107,13 @@ namespace NavisworksTransport.UI.WPF.Views
var db = pathManager.GetPathDatabase();
if (db == null)
{
throw new InvalidOperationException("时标服务初始化失败无法获取数据库实例");
throw new InvalidOperationException("时标服务初始化失败:无法获取数据库实例");
}
_timeTagService = new TimeTagService(db);
LogManager.Info("时标服务已初始化");
}
// 创建并显示时标对话框
var dlg = new TimeTagDialog(_timeTagService, route);
DialogHelper.SetOwnerFromUserControl(dlg, this);
@ -112,17 +128,17 @@ namespace NavisworksTransport.UI.WPF.Views
MessageBox.Show($"打开时标窗口失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// 路径分析按钮点击事件,打开路径规划分析对话框(非模态)
/// 路径分析按钮点击事件,打开路径规划分析对话框(非模态)
/// </summary>
private void OnPathAnalysisButtonClick(object sender, RoutedEventArgs e)
{
try
{
LogManager.Info("路径分析按钮被点击准备打开PathAnalysisDialog窗口");
// 使用静态方法显示(单例模式,非模态)
LogManager.Info("路径分析按钮被点击,准备打开PathAnalysisDialog窗口");
// 使用静态方法显示(单例模式,非模态)
var parentWindow = Window.GetWindow(this);
PathAnalysisDialog.ShowAnalysis(parentWindow);
}
@ -134,18 +150,18 @@ namespace NavisworksTransport.UI.WPF.Views
}
/// <summary>
/// 生成导航地图按钮点击事件打开生成导航地图对话框
/// 生成导航地图按钮点击事件,打开生成导航地图对话框
/// </summary>
private void OnGenerateNavigationMapButtonClick(object sender, RoutedEventArgs e)
{
try
{
LogManager.Info("生成导航地图按钮被点击准备打开GenerateNavigationMapDialog窗口");
LogManager.Info("生成导航地图按钮被点击,准备打开GenerateNavigationMapDialog窗口");
// 检查前置条件
if (ViewModel?.SelectedPathRoute == null || ViewModel.SelectedPathRoute.Points.Count == 0)
{
LogManager.Warning("无法生成导航地图未选择路径或路径为空");
LogManager.Warning("无法生成导航地图:未选择路径或路径为空");
return;
}
@ -235,32 +251,32 @@ namespace NavisworksTransport.UI.WPF.Views
}
/// <summary>
/// 测量包围盒按钮点击事件打开元素包围盒信息窗口
/// 测量包围盒按钮点击事件,打开元素包围盒信息窗口
/// </summary>
private void OnMeasureBoundsButtonClick(object sender, RoutedEventArgs e)
{
try
{
LogManager.Info("测量包围盒按钮被点击准备打开ModelItemBoundsWindow窗口");
LogManager.Info("测量包围盒按钮被点击,准备打开ModelItemBoundsWindow窗口");
// 创建或复用窗口实例
if (_boundsWindow == null || !_boundsWindow.IsVisible)
{
_boundsWindow = new ModelItemBoundsWindow();
// 设置父窗口(非模态窗口)
// 设置父窗口(非模态窗口)
DialogHelper.SetOwnerFromUserControl(_boundsWindow, this);
// 使用 Show() 而非 ShowDialog()允许窗口一直开着
// 使用 Show() 而非 ShowDialog(),允许窗口一直开着
_boundsWindow.Show();
LogManager.Info("ModelItemBoundsWindow窗口已显示(非模态)");
LogManager.Info("ModelItemBoundsWindow窗口已显示(非模态)");
}
else
{
// 窗口已存在刷新信息并激活
// 窗口已存在,刷新信息并激活
_boundsWindow.RefreshBoundsInfo();
_boundsWindow.Activate();
LogManager.Info("ModelItemBoundsWindow窗口已存在已刷新并激活");
LogManager.Info("ModelItemBoundsWindow窗口已存在,已刷新并激活");
}
}
catch (Exception ex)
@ -271,7 +287,7 @@ namespace NavisworksTransport.UI.WPF.Views
}
/// <summary>
/// 包围盒信息窗口实例保持引用以防止被GC回收
/// 包围盒信息窗口实例(保持引用以防止被GC回收)
/// </summary>
private ModelItemBoundsWindow _boundsWindow;
}

View File

@ -256,39 +256,56 @@ NavisworksTransport 系统管理页签视图 - 采用与其他页签一致的Nav
<!-- 区域4: 功能测试 -->
<Border BorderBrush="#FFD4E7FF" BorderThickness="1" CornerRadius="0" Margin="0,0,0,15" Padding="12">
<StackPanel>
<Label Content="功能测试" Style="{StaticResource SectionHeaderStyle}"/>
<Expander IsExpanded="False">
<Expander.Header>
<Label Content="🔬 功能测试" Style="{StaticResource SectionHeaderStyle}" Padding="0"/>
</Expander.Header>
<StackPanel Margin="0,8,0,0">
<TextBlock Text="测试新功能和实验性特性"
FontSize="10"
Foreground="{StaticResource NavisworksDarkBrush}"
Margin="0,5,0,10"
TextWrapping="Wrap"/>
<Grid Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 体素网格测试 -->
<StackPanel Orientation="Horizontal" Margin="0,5,0,5">
<!-- 测试体素网格按钮 -->
<Button Content="测试体素网格 (SDF方法)"
Command="{Binding TestVoxelGridSDFCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="使用 MeshSignedDistanceGrid 进行精确体素化,计算距离场"/>
<Button Grid.Row="0" Grid.Column="0" Content="体素网格 SDF"
Command="{Binding TestVoxelGridSDFCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="使用 MeshSignedDistanceGrid 进行精确体素化,计算距离场"
Margin="0,0,2,2"/>
<!-- 测试体素路径规划按钮 -->
<Button Content="测试体素路径规划 (3D A*)"
Command="{Binding TestVoxelPathFindingCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="使用3D A*算法在体素网格中规划路径,支持楼梯、坡道等垂直移动"/>
<Button Grid.Row="0" Grid.Column="1" Content="体素路径规划 3D A*"
Command="{Binding TestVoxelPathFindingCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="使用3D A*算法在体素网格中规划路径,支持楼梯、坡道等垂直移动"
Margin="2,0,2,2"/>
<!-- 读取Transform测试按钮 -->
<Button Content="读取Transform"
Command="{Binding ReadTransformTestCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中对象的Transform信息包括旋转角度"/>
<Button Grid.Row="0" Grid.Column="2" Content="读取 Transform"
Command="{Binding ReadTransformTestCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中对象的Transform信息包括旋转角度"
Margin="2,0,0,2"/>
<Button Content="捕获真实物体位姿"
Command="{Binding CaptureRealObjectTransformCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中真实物体的原始Transform并在3D视图中可视化X/Y/Z三轴"/>
</StackPanel>
<Button Grid.Row="1" Grid.Column="0" Content="捕获真实物体位姿"
Command="{Binding CaptureRealObjectTransformCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中真实物体的原始Transform并在3D视图中可视化X/Y/Z三轴"
Margin="0,2,2,0"/>
<Button Grid.Row="1" Grid.Column="1" Content="🧭 快速取面"
Command="{Binding TestFaceInferCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="单点+像素邻域法快速推断面法向量。点击后在模型面上点击。"
Margin="2,2,2,0"/>
</Grid>
</StackPanel>
</Expander>
</StackPanel>
</Border>
</StackPanel>

View File

@ -7,14 +7,14 @@ namespace NavisworksTransport.Utils.CoordinateSystem
{
/// <summary>
/// Navisworks 外部坐标与内部 Canonical Space 之间的统一适配器。
///
/// 约定
///
/// 约定:
/// - 外部坐标 = Navisworks API 返回的世界坐标
/// - 内部坐标 = Canonical Space固定为 Z-up
///
/// 当前仅支持
/// - Host Z-up -> Canonical Z-up(恒等)
/// - Host Y-up -> Canonical Z-up(绕 X 轴 +90° 的等价变换)
/// - 内部坐标 = Canonical Space,固定为 Z-up
///
/// 当前仅支持:
/// - Host Z-up -> Canonical Z-up(恒等)
/// - Host Y-up -> Canonical Z-up(绕 X 轴 +90° 的等价变换)
/// </summary>
public sealed class HostCoordinateAdapter
{
@ -30,7 +30,7 @@ namespace NavisworksTransport.Utils.CoordinateSystem
public CoordinateSystemType HostType { get; }
/// <summary>
/// 宿主坐标系中“向上轴”的索引。
/// 宿主坐标系中"向上轴"的索引。
/// Y-up => 1, Z-up => 2。
/// </summary>
public int HostUpAxisIndex => HostType == CoordinateSystemType.YUp ? 1 : 2;
@ -45,7 +45,7 @@ namespace NavisworksTransport.Utils.CoordinateSystem
{
if (hostType == CoordinateSystemType.AutoDetect)
{
throw new ArgumentException("HostCoordinateAdapter 不接受 AutoDetect必须传入明确的宿主坐标系。", nameof(hostType));
throw new ArgumentException("HostCoordinateAdapter 不接受 AutoDetect,必须传入明确的宿主坐标系。", nameof(hostType));
}
HostType = hostType;
@ -202,13 +202,13 @@ namespace NavisworksTransport.Utils.CoordinateSystem
}
/// <summary>
/// 将“宿主坐标系语义下”的四元数直接包装成 Navisworks 的 Rotation3D。
/// 将"宿主坐标系语义下"的四元数直接包装成 Navisworks 的 Rotation3D。
///
/// 项目硬约束
/// 项目硬约束:
/// - Rotation3D(x, y, z, w) 的参数顺序固定是 (x, y, z, w)
/// - Rotation3D.A/B/C/D 分别对应 x/y/z/w
///
/// 这里不做任何轴交换、符号翻转或重新解释只负责把已经确定好的宿主四元数写入 Navisworks。
/// 这里不做任何轴交换、符号翻转或重新解释,只负责把已经确定好的宿主四元数写入 Navisworks。
/// </summary>
public Rotation3D FromHostQuaternion(Quaternion hostRotation)
{
@ -238,12 +238,13 @@ namespace NavisworksTransport.Utils.CoordinateSystem
/// 根据宿主坐标系的世界轴,构造一个“宿主 X/Y/Z 角度修正”四元数。
///
/// 关键约束:
/// - correction.XDegrees 永远表示绕宿主世界 X 轴旋转
/// - correction.YDegrees 永远表示绕宿主世界 Y 轴旋转
/// - correction.ZDegrees 永远表示绕宿主世界 Z 轴旋转
/// - 角度应用顺序固定为 X -> Y -> Z对应四元数组合 qz * qy * qx
/// - XDegrees 永远表示绕宿主世界 X 轴旋转
/// - YDegrees 永远表示绕宿主世界 up 轴旋转
/// - ZDegrees 永远表示绕宿主世界 non-up 轴旋转
/// - 乘法顺序和 MoveItemCombinedAxisRotationAndTranslation 一致:
/// up * nonUp * X即先X、再nonUp、最后up
///
/// 本方法只负责生成“宿主世界轴修正”本身,不负责和某个基姿态做组合
/// 通过 HostUpVector3 自动兼容 YUp 和 ZUp不引入 HostType 分支
/// </summary>
public Quaternion CreateHostRotationCorrection(LocalEulerRotationCorrection correction)
{
@ -252,23 +253,27 @@ namespace NavisworksTransport.Utils.CoordinateSystem
return Quaternion.Identity;
}
Quaternion qx = Quaternion.CreateFromAxisAngle(Vector3.UnitX, DegreesToRadians(correction.XDegrees));
Quaternion qy = Quaternion.CreateFromAxisAngle(Vector3.UnitY, DegreesToRadians(correction.YDegrees));
Quaternion qz = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, DegreesToRadians(correction.ZDegrees));
return Quaternion.Normalize(qz * qy * qx);
Vector3 hostForward = Vector3.UnitX;
Vector3 hostUp = HostUpVector3;
Vector3 hostNonUp = Vector3.Normalize(Vector3.Cross(hostForward, hostUp));
Quaternion qx = Quaternion.CreateFromAxisAngle(hostForward, DegreesToRadians(correction.XDegrees));
Quaternion qnonUp = Quaternion.CreateFromAxisAngle(hostNonUp, DegreesToRadians(correction.ZDegrees));
Quaternion qup = Quaternion.CreateFromAxisAngle(hostUp, DegreesToRadians(correction.YDegrees));
return Quaternion.Normalize(qup * qnonUp * qx);
}
/// <summary>
/// 将宿主世界轴角度修正叠加到一个已经位于宿主坐标系中的基姿态上。
///
/// 参数语义
/// 参数语义:
/// - baselineQuaternion: 已经求好的宿主基姿态
/// - correction: 仍按宿主世界 X/Y/Z 解释的角度修正
///
/// 注意
/// - 这里的组合顺序直接决定“修正是绕宿主世界轴”还是“绕基姿态当前轴”
/// - 后续若修改乘法顺序,必须同步更新真实场景测试,不能靠肉眼猜
/// - 本方法只用于宿主坐标系不要把资产坐标系或内部坐标系的四元数传进来混用
/// 注意:
/// - 这里的组合顺序直接决定"修正是绕宿主世界轴"还是"绕基姿态当前轴"
/// - 后续若修改乘法顺序,必须同步更新真实场景测试,不能靠肉眼猜
/// - 本方法只用于宿主坐标系;不要把资产坐标系或内部坐标系的四元数传进来混用
/// </summary>
public Quaternion ComposeHostQuaternion(Quaternion baselineQuaternion, LocalEulerRotationCorrection correction)
{
@ -278,7 +283,7 @@ namespace NavisworksTransport.Utils.CoordinateSystem
}
/// <summary>
/// 将“宿主语义 X/Y/Z”的角度修正重映射为当前真实物体本地 X/Y/Z 的角度修正。
/// 将"宿主语义 X/Y/Z"的角度修正重映射为当前真实物体本地 X/Y/Z 的角度修正。
/// </summary>
public static LocalEulerRotationCorrection RemapHostSemanticCorrectionToLocalAxes(
LocalEulerRotationCorrection hostCorrection,
@ -305,13 +310,14 @@ namespace NavisworksTransport.Utils.CoordinateSystem
/// <summary>
/// 将宿主 X/Y/Z 三轴旋转角转换为内部 Canonical Space 中的旋转四元数。
///
/// 关键约束:
/// - 输入角度仍然按宿主世界 X/Y/Z 解释
/// - 输出四元数位于内部坐标系
/// - 角度应用顺序固定为 X -> Y -> Z对应四元数组合 qz * qy * qx
/// 关键约束:
/// - XDegrees 永远表示绕宿主世界 X 轴旋转
/// - YDegrees 永远表示绕宿主世界 Y 轴旋转(up轴)
/// - ZDegrees 永远表示绕宿主世界 Z 轴旋转(non-up轴)
/// - 乘法顺序和 MoveItemCombinedAxisRotationAndTranslation 一致:
/// up * nonUp * X,即先X、再nonUp、最后up
///
/// 该方法只做“宿主世界轴 -> 内部坐标系轴”的映射,
/// 不负责决定真实物体、虚拟物体或路径姿态链应该如何消费这个修正。
/// 通过 Canonical Space 的轴映射自动兼容 YUp 和 ZUp,不引入 HostType 分支。
/// </summary>
public Quaternion CreateCanonicalRotationCorrection(LocalEulerRotationCorrection correction)
{
@ -320,14 +326,14 @@ namespace NavisworksTransport.Utils.CoordinateSystem
return Quaternion.Identity;
}
Vector3 canonicalHostX = Vector3.Normalize(ToCanonicalVector3(Vector3.UnitX));
Vector3 canonicalHostY = Vector3.Normalize(ToCanonicalVector3(Vector3.UnitY));
Vector3 canonicalHostZ = Vector3.Normalize(ToCanonicalVector3(Vector3.UnitZ));
Vector3 canonicalForward = Vector3.Normalize(ToCanonicalVector3(Vector3.UnitX));
Vector3 canonicalUp = CanonicalUpVector3;
Vector3 canonicalNonUp = Vector3.Normalize(Vector3.Cross(canonicalForward, canonicalUp));
Quaternion qx = Quaternion.CreateFromAxisAngle(canonicalHostX, DegreesToRadians(correction.XDegrees));
Quaternion qy = Quaternion.CreateFromAxisAngle(canonicalHostY, DegreesToRadians(correction.YDegrees));
Quaternion qz = Quaternion.CreateFromAxisAngle(canonicalHostZ, DegreesToRadians(correction.ZDegrees));
return Quaternion.Normalize(qz * qy * qx);
Quaternion qx = Quaternion.CreateFromAxisAngle(canonicalForward, DegreesToRadians(correction.XDegrees));
Quaternion qnonUp = Quaternion.CreateFromAxisAngle(canonicalNonUp, DegreesToRadians(correction.ZDegrees));
Quaternion qup = Quaternion.CreateFromAxisAngle(canonicalUp, DegreesToRadians(correction.YDegrees));
return Quaternion.Normalize(qup * qnonUp * qx);
}
private static Rotation3D CreateRotationFromLinearComponents(

View File

@ -37,6 +37,16 @@ namespace NavisworksTransport.Utils.CoordinateSystem
public double AreaRelativeTieTolerance { get; }
public Quaternion BaselineHostRotation { get; }
/// <summary>
/// 门型偏好true=宽>高false=高>宽
/// </summary>
public bool RequireWidthLarger { get; set; }
/// <summary>
/// 物体下方物流车辆高度(模型单位)
/// </summary>
public double VehicleHeightModelUnits { get; set; }
}
public sealed class ObjectPassageProjectionOptimizationResult
@ -45,12 +55,16 @@ namespace NavisworksTransport.Utils.CoordinateSystem
bool success,
LocalEulerRotationCorrection correction,
ObjectPassageProjectionScore score,
string errorMessage)
string errorMessage,
double? computedLiftOffsetModelUnits = null,
double? aabbSx = null, double? aabbSy = null, double? aabbSz = null)
{
Success = success;
Correction = correction;
Score = score;
ErrorMessage = errorMessage;
ComputedLiftOffsetModelUnits = computedLiftOffsetModelUnits;
AabbSx = aabbSx; AabbSy = aabbSy; AabbSz = aabbSz;
}
public bool Success { get; }
@ -61,11 +75,25 @@ namespace NavisworksTransport.Utils.CoordinateSystem
public string ErrorMessage { get; }
/// <summary>
/// 自动调整后计算出的上下偏移(模型单位),使物体底面贴合物流车高度。仅地面路径有效。
/// </summary>
public double? ComputedLiftOffsetModelUnits { get; }
/// <summary>
/// 优化器实测的 AABB 尺寸(模型单位)。
/// </summary>
public double? AabbSx { get; }
public double? AabbSy { get; }
public double? AabbSz { get; }
public static ObjectPassageProjectionOptimizationResult CreateSuccess(
LocalEulerRotationCorrection correction,
ObjectPassageProjectionScore score)
ObjectPassageProjectionScore score,
double? computedLiftOffsetModelUnits = null,
double? aabbSx = null, double? aabbSy = null, double? aabbSz = null)
{
return new ObjectPassageProjectionOptimizationResult(true, correction, score, null);
return new ObjectPassageProjectionOptimizationResult(true, correction, score, null, computedLiftOffsetModelUnits, aabbSx, aabbSy, aabbSz);
}
public static ObjectPassageProjectionOptimizationResult CreateFailure(string errorMessage)

View File

@ -47,15 +47,26 @@ namespace NavisworksTransport.Utils.CoordinateSystem
if (pathType == NavisworksTransport.PathType.Hoisting)
{
if (pathPoints.Count < 3)
if (pathPoints.Count < 2)
{
return false;
}
hostForward = new Vector3(
(float)(pathPoints[2].X - pathPoints[1].X),
(float)(pathPoints[2].Y - pathPoints[1].Y),
(float)(pathPoints[2].Z - pathPoints[1].Z));
// 优先使用第一个水平段方向点2-点12点路径使用唯一线段方向
if (pathPoints.Count >= 3)
{
hostForward = new Vector3(
(float)(pathPoints[2].X - pathPoints[1].X),
(float)(pathPoints[2].Y - pathPoints[1].Y),
(float)(pathPoints[2].Z - pathPoints[1].Z));
}
else
{
hostForward = new Vector3(
(float)(pathPoints[1].X - pathPoints[0].X),
(float)(pathPoints[1].Y - pathPoints[0].Y),
(float)(pathPoints[1].Z - pathPoints[0].Z));
}
return hostForward.LengthSquared() >= 1e-9f;
}

View File

@ -617,12 +617,10 @@ namespace NavisworksTransport.Utils
var tp = new Vector3((float)trackedPosition.X, (float)trackedPosition.Y, (float)trackedPosition.Z);
var rotatedTp = Vector3.Transform(tp, totalQ);
// 计算组合变换
// 计算组合变换(调用方已在 RestoreObjectToCADPosition 中执行 ResetPermanentTransform此处不重复
var doc = Application.ActiveDocument;
var modelItems = new ModelItemCollection { item };
doc.Models.ResetPermanentTransform(modelItems);
var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0));
var components = identity.Factor();
components.Rotation = new Rotation3D(totalQ.X, totalQ.Y, totalQ.Z, totalQ.W);
@ -631,6 +629,12 @@ namespace NavisworksTransport.Utils
targetPosition.Y - rotatedTp.Y,
targetPosition.Z - rotatedTp.Z);
var combined = components.Combine();
LogManager.Debug(
$"[组合旋转平移] tracked=({trackedPosition.X:F3},{trackedPosition.Y:F3},{trackedPosition.Z:F3}), " +
$"target=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " +
$"translation=({components.Translation.X:F3},{components.Translation.Y:F3},{components.Translation.Z:F3})");
doc.Models.OverridePermanentTransform(modelItems, combined, false);
}