diff --git a/.agents/skills/geometry-transform/SKILL.md b/.agents/skills/geometry-transform/SKILL.md new file mode 100644 index 0000000..420f22b --- /dev/null +++ b/.agents/skills/geometry-transform/SKILL.md @@ -0,0 +1,56 @@ +--- +name: geometry-transform +description: NavisworksTransport 几何与姿态专用技能,用于坐标系变换、物体旋转平移、虚拟物体定位、通行空间几何和相关单元测试;处理 Navisworks 中的姿态问题时优先使用。 +--- + +# Geometry & Transform + +Use this skill for any work involving coordinate-system transforms, object pose, rotation, translation, virtual-object placement, passage-space geometry, and the tests that lock those behaviors down. + +## Scope + +- Keep host coordinates, internal canonical coordinates, and asset coordinates distinct. +- Prefer existing helpers over ad hoc math. +- Protect the object-start, restore, and playback chains from regressions. +- Treat geometry changes as test-first work whenever practical. + +## Expected Ownership + +- `src/Utils/CoordinateSystem/HostCoordinateAdapter.cs` +- `src/Utils/CoordinateSystem/ModelAxisConvention.cs` +- `src/Utils/CoordinateSystem/RotatedObjectExtentHelper.cs` +- `src/Utils/CoordinateSystem/CanonicalPlanarPoseBuilder.cs` +- `src/Utils/CoordinateSystem/CanonicalRailPoseBuilder.cs` +- `src/Utils/CoordinateSystem/CanonicalTrackedPositionResolver.cs` +- `src/Utils/CoordinateSystem/RealObjectPlanarPoseSolver.cs` +- `src/Utils/CoordinateSystem/FragmentRepresentativePoseHelper.cs` +- `src/Utils/ModelItemTransformHelper.cs` +- `src/Core/Animation/PathAnimationManager.cs` +- `src/Core/VirtualObjectManager.cs` +- `src/UI/WPF/ViewModels/AnimationControlViewModel.cs` +- geometry-focused tests under `UnitTests/CoordinateSystem/` + +## Invariants + +- UI, logs, and user inputs/output stay in host coordinates. +- Internal pose solving stays in canonical space. +- Asset coordinates only apply to plugin-owned assets such as the virtual object and the unit cylinder/reference rod. +- Real-object planar pose solving must stay in host coordinates and should keep the reference-pose source injectable so fragment-derived representative pose can be wired in later. +- Real-object planar pose solving should prefer fragment-derived representative pose when available; original `Transform` is only an explicit fallback, not the primary source. +- Do not assume `BoundingBox.Center` is a stable pose anchor after rotation unless the flow explicitly proves it. +- Do not add temporary force-sync or fallback logic unless it is removed after the root cause is fixed. +- Do not mix virtual-object behavior into real-object behavior through shared mutable mode flags. + +## Working Rules + +1. Identify which coordinate system each value lives in before changing code. +2. Reuse project helpers first. +3. If a rotation change affects position, extents, or restore behavior, update the corresponding tests in the same change. +4. For Navisworks API details and examples, also consult the `nw-api` skill and `doc/design/2026/NavisworksAPI使用方法.md`. + +## Test Expectations + +- Add or update host-type coverage for both `YUp` and `ZUp` when the change touches transforms. +- Lock baseline pose, rotated pose, and restore behavior. +- For passage-space or footprint changes, verify extents after rotation, not just orientation. +- For virtual objects, verify scale preservation and CAD restore behavior. diff --git a/.agents/skills/geometry-transform/agents/openai.yaml b/.agents/skills/geometry-transform/agents/openai.yaml new file mode 100644 index 0000000..4b1ab6a --- /dev/null +++ b/.agents/skills/geometry-transform/agents/openai.yaml @@ -0,0 +1,8 @@ +interface: + display_name: "Geometry Transform" + short_description: "坐标、姿态、位移与通行空间专用技能" + brand_color: "#2E8B57" + default_prompt: "Use $geometry-transform to analyze or implement a geometry, transform, or coordinate-system fix in NavisworksTransport." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/geometry-transform/references/geometry-debug-checklist.md b/.agents/skills/geometry-transform/references/geometry-debug-checklist.md new file mode 100644 index 0000000..946bfc2 --- /dev/null +++ b/.agents/skills/geometry-transform/references/geometry-debug-checklist.md @@ -0,0 +1,53 @@ +# 几何问题排查清单 + +## 先问自己 + +1. 这是虚拟物体还是真实物体? +2. 路径类型是 `Ground / Hoisting / Rail` 哪一种? +3. 错的是: + - 旋转轴 + - 起点位置 + - 逐帧位置 + - 通行空间 + - 终点诊断 + +## 日志优先看什么 + +### 平面路径 + +- `[移动到起点] 路径方向yaw` +- `[动画姿态入口] ... 目标姿态` +- `[模型增量姿态] ... 当前/目标/增量` + +### Rail + +- `[移动到起点] Rail旋转姿态` +- `RailPathPoseHelper` 相关诊断 + +### 虚拟物体 + +- `[虚拟物体姿态] 应用前` +- `[虚拟物体姿态] 应用后` +- 关注 `BoundingBox.Center` 是否符合资源原点预期 + +## 需要同步检查的链 + +出现几何问题时,至少对齐这几条: + +1. 起点落位 +2. 逐帧位置 +3. 通行空间 +4. 终点诊断 + +如果其中一条使用的是“原始高度”,另一条使用的是“旋转后法线尺寸”,就很容易出现看起来互相矛盾的问题。 + +## 测试建议 + +至少补一个最短链测试: + +- `YUp` 或 `ZUp` +- 指定单一路径类型 +- 指定单个旋转轴 +- 验证尺寸或姿态的关键不变量 + +能测数学层,就不要先靠现场猜。 diff --git a/.agents/skills/geometry-transform/references/navisworks-transform-patterns.md b/.agents/skills/geometry-transform/references/navisworks-transform-patterns.md new file mode 100644 index 0000000..48b47b6 --- /dev/null +++ b/.agents/skills/geometry-transform/references/navisworks-transform-patterns.md @@ -0,0 +1,34 @@ +# Navisworks Transform Patterns + +## Use These First + +- Use `HostCoordinateAdapter` for host <-> canonical coordinate conversion. +- Use `ModelAxisConvention` only when the object really has an asset axis convention. +- Use `RotatedObjectExtentHelper` for rotated footprint/height calculations. +- Use `CanonicalTrackedPositionResolver` for contact-point -> center-point conversion. + +## Real Objects + +- Real objects do not have their own asset coordinate system. +- Treat UI angle input as host-axis rotation. +- Keep the pose solve stable in canonical space, then apply the host-axis correction on the host-side result. + +## Virtual Objects + +- Virtual objects do have an asset coordinate system. +- Preserve current scale when moving or rotating the virtual object. +- When returning to CAD position, reset permanent transforms first, then restore the expected scale state. + +## Common Pitfalls + +- Do not use `BoundingBox.Center` as a stable anchor after rotation unless the flow has already been validated for that case. +- Do not infer height from the raw unrotated axis when the visual footprint already depends on the rotated extents. +- Do not mix host-axis correction and canonical correction in the same branch unless the branch semantics require it. + +## Testing Checklist + +- Verify `YUp` and `ZUp`. +- Verify zero-correction baseline first. +- Verify rotated extents and start-position compensation together. +- Verify that restore-to-CAD returns the object to the original state, not just an approximate pose. + diff --git a/.agents/skills/geometry-transform/references/navisworks-transform-rules.md b/.agents/skills/geometry-transform/references/navisworks-transform-rules.md new file mode 100644 index 0000000..d56a718 --- /dev/null +++ b/.agents/skills/geometry-transform/references/navisworks-transform-rules.md @@ -0,0 +1,58 @@ +# Navisworks 变换与旋转规则 + +本文件只记录和本项目最相关的结论。 + +## 关键结论 + +1. `Rotation` 不是“绕物体中心旋转”,而是和整体变换组合后共同决定结果。 +2. `ResetPermanentTransform` 会清掉覆盖变换,后续 `BoundingBox()` 与 `Transform` 都会回到设计文件原始状态。 +3. `OverridePermanentTransform` 与 `SetModelUnitsAndTransform` 会直接改变当前模型覆盖变换。 +4. 对真实模型做完整姿态时,要特别区分: + - 目标姿态算得对不对 + - 还是增量/绝对应用方式把正确姿态弄坏 +5. 对虚拟物体做完整姿态时,若尺寸依赖缩放实现,应用姿态时必须明确是否保留当前缩放。 + +## 本项目里的常见坑 + +### 1. UI 轴和内部轴混用 + +- UI `X/Y/Z` 一律是宿主坐标系 +- 内部坐标系只用于数学求解 +- 不要把 UI 输入直接当成资产坐标系角度 + +### 2. 通行空间和真实物体使用不同尺寸语义 + +如果出现: + +- 通行空间对,物体位置错 +- 或物体对,通行空间错 + +优先检查: + +- 是否一边用了原始高度 +- 另一边用了旋转后的法线尺寸 + +### 3. 虚拟物体资源原点问题 + +若虚拟物体在“原点”时 `BoundingBox.Center` 不是 `(0,0,0)`: + +- 先检查 `unit_cube.nwc` 是否为最新资源 +- 再查代码 + +不要一上来怀疑移动逻辑。 + +### 4. Rail 角度修正 + +`Rail` 不能像平面路径那样在宿主空间直接补转。 + +必须: + +- 先并入 `canonical -> rail pose` 链 +- 再落位 + +## 推荐排查顺序 + +1. 先确认资源文件是否正确 +2. 再确认日志里的目标姿态/目标位置是否合理 +3. 最后才看 Navisworks 应用变换的方法是否有问题 + diff --git a/.agents/skills/nw-api/SKILL.md b/.agents/skills/nw-api/SKILL.md index c08dd29..0f96cd5 100644 --- a/.agents/skills/nw-api/SKILL.md +++ b/.agents/skills/nw-api/SKILL.md @@ -16,7 +16,9 @@ description: Navisworks API 开发助手,用于开发 Navisworks 插件。功 | NET API | `doc/navisworks_api/NET/documentation/NET API.chm` | CHM 帮助文件 | | NET API HTML | `doc/navisworks_api/NET/documentation/NetAPIHtml/` | HTML 文档 | -**HTML 文档入口**: `doc/navisworks_api/NET/documentation/NetAPIHtml/html/index.html` +**推荐导航入口**: `doc/navisworks_api/NET/documentation/NetAPIHtml/index.html` + +**原始 HTML 文档入口**: `doc/navisworks_api/NET/documentation/NetAPIHtml/html/index.html` ### API 文档搜索方法 diff --git a/.agents/skills/project-tools/SKILL.md b/.agents/skills/project-tools/SKILL.md index cae0848..d814e38 100644 --- a/.agents/skills/project-tools/SKILL.md +++ b/.agents/skills/project-tools/SKILL.md @@ -1,3 +1,8 @@ +--- +name: project-tools +description: Use when writing code that needs tool utilities (DialogHelper, UnitsConverter, LogManager, GeometryHelper, PathHelper, ModelHighlightHelper) in the NavisworksTransport project. Check this skill before writing new utility code to avoid reinventing existing tools. +--- + # NavisworksTransport 项目工具类使用指南 ## 概述 diff --git a/.gitignore b/.gitignore index 4373112..30977ea 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ navisworks_api/ *.exe *.db + +.codex-temp \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d5d4b7e..b8719a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,644 +1,587 @@ # AGENTS.md -本文件为AI编码助手提供 NavisworksTransport 项目的完整开发指南。阅读本文件前,请确保你已经了解项目的基本结构和目标。 +本文件面向后续会话中的 AI 编码助手。目标不是写一份“大而全”的历史说明,而是让新会话能快速理解: -## 项目概述 +- 项目现在在做什么 +- 哪些架构已经稳定 +- 哪些开发原则不能再破坏 +- 遇到问题时优先看哪里 -**NavisworksTransport** 是专为 Autodesk Navisworks Manage 2026 开发的物流路径规划插件,用于BIM模型中的运输冲突检测和路径规划。 +如果本文件与更细的专项文档冲突,优先参考: -### 核心功能 - -- **物流属性管理**: 为模型分配类别属性(门、电梯、楼梯、通道、障碍物) -- **自动路径规划**: 基于A*算法的2.5D网格路径规划,支持通道优先策略 -- **碰撞检测**: 与Navisworks ClashDetective集成,实现动态碰撞检测 -- **动画仿真**: TimeLiner集成,支持路径动画播放和仿真 -- **吊装路径**: 支持空中吊装路径规划(两次点击模式) -- **数据导出**: 支持XML、JSON、CSV格式的路径数据导出,以及DELMIA数据格式 - -### 技术栈 - -| 组件 | 版本/说明 | -|------|----------| -| 目标平台 | Navisworks Manage 2026 | -| 框架 | .NET Framework 4.8 | -| 语言 | C# 7.3 | -| 架构 | x64 | -| UI框架 | WPF (MVVM模式) + Windows Forms集成 | - -### 项目文件 - -| 文件 | 说明 | -|------|------| -| `TransportPlugin.csproj` | 主插件项目(旧式csproj格式) | -| `NavisworksTransport.UnitTests.csproj` | 单元测试项目 | -| `TransportPlugin.sln` | Visual Studio 解决方案 | -| `packages.config` | NuGet包配置(旧式包管理) | -| `default_config.toml` | 默认配置文件模板 | -| `compile.bat` | 构建脚本 | -| `run-unit-tests.bat` | 测试脚本 | -| `deploy-plugin.bat` | 部署脚本 | - -## 构建命令 - -### 环境要求 - -- Windows 10 或更高版本 -- Visual Studio 2022(Community/Professional) -- Navisworks Manage 2026(已安装) -- .NET Framework 4.8 Developer Pack - -### 主要构建 - -```bash -./compile.bat -``` - -- 自动检测 Visual Studio 2022 的 MSBuild -- 构建 Release 配置的 x64 平台 -- 输出目录: `bin\x64\Release\` - -### 部署插件 - -```bash -./deploy-plugin.bat -``` - -自动复制插件文件到 Navisworks 插件目录: -`C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\` - -## 项目架构 - -### 目录结构 - -``` -src/ -├── Core/ # 核心插件和业务逻辑 -│ ├── MainPlugin.cs # DockPanePlugin - Ribbon UI + 停靠面板 -│ ├── PathClickToolPlugin.cs # ToolPlugin - 3D鼠标交互工具 -│ ├── PathPointRenderPlugin.cs # RenderPlugin - 3D可视化渲染 -│ ├── PathPlanningManager.cs # 路径规划管理器(与UI解耦) -│ ├── PathDataManager.cs # 路径数据管理 -│ ├── PathDatabase.cs # SQLite数据库操作 -│ ├── PathCurveEngine.cs # 路径曲线化引擎 -│ ├── UIStateManager.cs # 线程安全的UI状态管理 -│ ├── Animation/ # 动画系统 -│ │ ├── PathAnimationManager.cs -│ │ └── TimeLinerIntegrationManager.cs -│ ├── Collision/ # 碰撞检测 -│ │ ├── ClashDetectiveIntegration.cs -│ │ └── BatchCollisionProcessor.cs -│ ├── Spatial/ # 空间索引 -│ │ ├── SpatialHashGrid.cs -│ │ └── SpatialIndexManager.cs -│ ├── Properties/ # 属性管理 -│ │ ├── CategoryAttributeManager.cs -│ │ └── NavisworksComPropertyManager.cs -│ └── Config/ # 配置管理 -│ ├── SystemConfig.cs -│ └── ConfigManager.cs -├── Commands/ # 命令模式实现 -│ ├── CommandBase.cs -│ ├── CommandManager.cs -│ ├── AutoPathPlanningCommand.cs -│ └── ... -├── PathPlanning/ # A*算法和网格地图 -│ ├── GridMap.cs # 网格地图定义 -│ ├── GridMapGenerator.cs # 网格生成器 -│ ├── AutoPathFinder.cs # A*寻路实现 -│ ├── ChannelBasedGridBuilder.cs # 通道优先网格构建 -│ ├── VoxelGrid.cs # 3D体素网格(实验性) -│ └── PathOptimizer.cs # 路径优化 -├── UI/WPF/ # WPF用户界面 -│ ├── Views/ # XAML视图 -│ ├── ViewModels/ # MVVM视图模型 -│ ├── Models/ # 数据模型 -│ ├── Converters/ # 值转换器 -│ ├── Commands/ # WPF命令 -│ └── Services/ # UI服务 -└── Utils/ # 工具类 - ├── UnitsConverter.cs # 单位转换(关键!) - ├── LogManager.cs # 日志管理 - ├── GeometryHelper.cs # 几何计算 - └── ... - -UnitTests/ # 单元测试 -├── Core/ # 核心功能测试 -├── Commands/ # 命令测试 -└── Utils/ # 工具类测试 -``` - -### 插件类型说明 - -| 插件类 | 类型 | 功能 | -|--------|------|------| -| `MainPlugin.cs` | DockPanePlugin | 主UI面板,包含WPF控件宿主 | -| `PathClickToolPlugin.cs` | ToolPlugin | 鼠标点击工具,获取精确的3D坐标 | -| `PathPointRenderPlugin.cs` | RenderPlugin | 3D渲染,显示路径点和连线 | - -### 关键依赖 - -**Navisworks API(必须安装Navisworks 2026):** - -- `Autodesk.Navisworks.Api.dll` -- `Autodesk.Navisworks.ComApi.dll` -- `Autodesk.Navisworks.Interop.ComApi.dll` -- `Autodesk.Navisworks.Timeliner.dll` -- `Autodesk.Navisworks.Clash.dll` -- `Autodesk.Navisworks.Controls.dll` - -**NuGet包:** - -- `RoyT.AStar 3.0.2` - A*寻路算法 -- `geometry4Sharp 1.0.0` - 3D几何计算(体素路径规划) -- `System.Data.SQLite.Core 1.0.118.0` - SQLite数据库 -- `Tomlyn 0.19.0` - TOML配置文件解析 -- `MSTest.TestFramework 3.0.4` - 单元测试框架 - -## 代码规范 - -### 导入顺序 - -```csharp -// 1. System命名空间 -using System; -using System.Collections.Generic; -using System.Linq; - -// 2. 第三方库 -using RoyT.AStar; - -// 3. Navisworks API -using Autodesk.Navisworks.Api; -using Autodesk.Navisworks.ComApi; -using Autodesk.Navisworks.Api.Plugins; - -// 4. 项目命名空间(按字母顺序) -using NavisworksTransport.Commands; -using NavisworksTransport.Core; -using NavisworksTransport.PathPlanning; -using NavisworksTransport.UI.WPF.ViewModels; -using NavisworksTransport.Utils; -``` - -### 命名约定 - -| 类型 | 命名规则 | 示例 | -|------|----------|------| -| 类 | PascalCase | `PathPlanningManager` | -| 接口 | PascalCase + 'I'前缀 | `IPathPlanningCommand` | -| 方法 | PascalCase | `GenerateGridMap` | -| 属性 | PascalCase | `CellSize` | -| 字段 | camelCase + 下划线前缀 | `_uiStateManager` | -| 常量 | PascalCase | `MaxHeightDiff` | -| 枚举 | PascalCase | `GridGenerationMode` | - -### 代码开发原则 - -#### 1. 禁止硬编码默认值 - -函数的参数和初始化时不要使用硬编码的默认值,应从配置读取或使用命名常量: - -```csharp -// ❌ 错误:硬编码默认值 -public GridMap Generate(double cellSize = 0.5) { } - -// ❌ 错误:构造函数中硬编码 -public PathPlanner() { - _cellSize = 0.5; - _maxSlope = 15.0; -} - -// ✅ 正确:从配置读取 -public GridMap Generate(double cellSize) { - cellSize = cellSize > 0 ? cellSize : ConfigManager.Instance.Current.PathEditing.CellSizeMeters; - // ... -} - -// ✅ 正确:使用命名常量 -private const double DEFAULT_CELL_SIZE_METERS = 0.5; -public PathPlanner(double cellSize = DEFAULT_CELL_SIZE_METERS) { } -``` - -#### 2. 不向后兼容 - -本项目专门针对 Navisworks 2026 开发,**程序中不要写向后兼容代码**: - -```csharp -// ❌ 错误:向后兼容代码 -#if NAVISWORKS_2025 - // 2025 specific code -#elif NAVISWORKS_2026 - // 2026 specific code -#endif - -// ❌ 错误:运行时版本检查 -if (NavisworksVersion.Major < 2026) { - // 兼容旧版本的代码 -} - -// ✅ 正确:直接针对2026编写 -var clashResult = Autodesk.Navisworks.Api.Clash.ClashResult.GetAllResults(); -``` - -#### 3. 不随意加回退逻辑 - -程序中不要随意加回退逻辑,避免过度防御性编程导致隐藏问题: - -```csharp -// ❌ 错误:过度回退逻辑 -public double GetCellSize() { - try { - return ConfigManager.Instance.Current.PathEditing.CellSizeMeters; - } - catch { - return 0.5; // 隐藏了配置读取失败的问题! - } -} - -// ❌ 错误:静默回退 -double value = GetConfigValue("cellSize"); -if (value <= 0) value = 0.5; // 为什么<=0?配置验证应该保证这一点 - -// ✅ 正确:让问题暴露出来 -public double GetCellSize() { - return ConfigManager.Instance.Current.PathEditing.CellSizeMeters; - // 如果配置有问题,让它抛出异常,在源头解决 -} -``` - -#### 4. 代码复用优先 - -规划和编写新功能或新模块时,**尽量复用项目中的代码,尤其是工具和辅助类**: - -```csharp -// ❌ 错误:重复造轮子 -public static double MyConvertUnits(double value) { - // 自己写一套单位转换逻辑... -} - -// ✅ 正确:复用现有的 UnitsConverter -using NavisworksTransport.Utils; -double meters = UnitsConverter.ConvertToMeters(distance); - -// ❌ 错误:自己实现集合通知 -public class MyCollection : ObservableCollection { - // 重写一大堆线程安全代码... -} - -// ✅ 正确:复用 ThreadSafeObservableCollection -using NavisworksTransport.UI.WPF.Collections; -var collection = new ThreadSafeObservableCollection(); - -// ❌ 错误:自己写几何计算 -public double Distance(Point3D a, Point3D b) { - return Math.Sqrt(Math.Pow(a.X - b.X, 2) + ...); -} - -// ✅ 正确:复用 GeometryHelper -using NavisworksTransport.Utils; -double distance = GeometryHelper.Distance(pointA, pointB); -``` - -**复用检查清单:** - -- 需要单位转换?→ 使用 `UnitsConverter` -- 需要几何计算?→ 使用 `GeometryHelper` -- 需要日志记录?→ 使用 `LogManager` -- 需要线程安全的集合?→ 使用 `ThreadSafeObservableCollection` -- 需要坐标转换?→ 使用 `CoordinateConverter` -- 需要路径相关工具?→ 使用 `PathHelper` - -### 单位系统 - 极其重要 - -**所有网格地图和路径规划计算必须使用模型单位,严禁混用米制单位。** - -#### 变量命名铁律(强制执行) - -| 单位类型 | 命名规则 | 示例 | -|---------|---------|------| -| **米单位** | 变量名必须以 `InMeters` 结尾 | `lengthInMeters`, `heightInMeters` | -| **模型单位** | 变量名**无后缀** | `length`, `height`, `cellSize` | - -```csharp -// ✅ 正确:严格遵循命名规范 -public void SetSize(double lengthInMeters, double widthInMeters, double heightInMeters) -{ - double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(...); - - // 米单位 → 模型单位(无后缀) - double length = lengthInMeters * metersToUnits; - double width = widthInMeters * metersToUnits; - double height = heightInMeters * metersToUnits; - - // 后续计算使用模型单位(无后缀) - boundingBox = new BoundingBox3D(0, 0, 0, length, width, height); -} - -// ❌ 错误:命名不规范,导致单位混乱 -public void SetSize(double length, double width, double height) // 参数是米还是模型单位? -{ - double scale = length / baseSize; // 单位不明确! -} - -// ❌ 错误:后缀使用不一致 -public void SetSize(double lengthMeters, double widthMeters, double heightMeters) // 不要用Meters后缀 -{ - double lengthInModelUnits = lengthMeters * factor; // 不要用InModelUnits后缀 -} -``` - -#### 单位转换方法 - -`UnitsConverter` 提供以下方法: - -- `GetUnitsToMetersConversionFactor()` - 文档单位转米 -- `GetMetersToUnitsConversionFactor()` - 米转文档单位 -- `ConvertToMeters(distance)` - 转换距离为米 -- `ConvertFromMeters(distanceInMeters)` - 从米转换距离 - -#### 完整示例 - -```csharp -// ✅ 正确:在函数入口统一转换,严格遵循命名规范 -public GridMap GenerateFromBIM(BoundingBox3D bounds, double cellSizeInMeters, ...) -{ - double factor = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); - double cellSize = cellSizeInMeters * factor; // 模型单位,无后缀 - // 后续所有计算使用 cellSize(模型单位) -} - -// ❌ 错误:混用单位 -private const double MAX_HEIGHT_DIFF = 0.35; // 这是米! -if (heightDiff > MAX_HEIGHT_DIFF) // 单位不匹配,严重Bug! -``` - -### 错误处理 - -```csharp -// ✅ 正确:记录并适当处理异常 -try -{ - var result = SomeOperation(); - return result; -} -catch (Exception ex) -{ - LogManager.Error($"Operation failed: {ex.Message}"); - throw; // 或适当处理 -} - -// ❌ 错误:静默吞掉异常 -try -{ - var result = SomeOperation(); - return result; -} -catch -{ - return null; // 隐藏了问题! -} -``` - -### 线程安全和UI更新 - -所有UI操作必须编组到主线程: - -```csharp -// ✅ 正确:使用UIStateManager进行线程安全更新 -_uiStateManager.UpdateStatus("正在处理..."); - -// ✅ 正确:异步事件触发避免死锁 -private void OnStatusChanged(string status) -{ - Task.Run(() => - { - try - { - StatusChanged?.Invoke(this, status); - } - catch (Exception ex) - { - LogManager.Error($"StatusChanged事件失败: {ex.Message}"); - } - }); -} -``` - -### WPF UI开发注意事项 - -#### XAML资源引用原则 - -**原则**:所有 XAML 中引用的资源(Converter、Style、Brush 等)必须在当前文件或合并的资源字典中有定义。 - -**约束**: - -- 禁止复制其他文件的 XAML 代码而不检查资源定义 -- 每添加一个 `{StaticResource xxx}` 引用,立即确认 `x:Key="xxx"` 存在 -- 小步增量开发,每步验证窗口可正常打开 - -**关键错误**:资源键名拼写错误、未定义的 Converter、错误的外部资源字典路径 - -**参考**:详细检查清单和常见错误模式见 `doc/guide/design_principles.md` 第16节 - -#### UI色彩规范 - Material Design - -**项目整体使用 Google Material Design 色系**,确保视觉一致性。 - -**常用颜色定义**(来自 `PathPointRenderPlugin.cs`): - -| 用途 | 颜色名称 | RGB值 | 十六进制 | -|------|---------|-------|---------| -| 起点 | Material Green | (76, 175, 80) | #4CAF50 | -| 通行空间 | Material Light Green | (129, 199, 132) | #81C784 | -| 排除对象 | Material Light Green | (129, 199, 132) | #81C784 | - -**其他高亮颜色**(来自 `ModelHighlightHelper.cs`): - -| 类别 | 颜色 | RGB值 | -|------|------|-------| -| 预计算碰撞 | Material Purple | (156, 39, 176) | -| 手工指定对象 | 橙色 | (255, 170, 0) | -| 动画物体 | Amber/Yellow | (255, 193, 7) | -| ClashDetective结果 | 红色 | Color.Red | -| 通道预览 | 绿色 | Color.Green | - -**使用规范**: - -1. 新增UI元素时优先使用上述Material色系 -2. 如需新颜色,参考 [Material Design Color Palette](https://material.io/resources/color/) -3. 使用 `Color.FromByteRGB(r, g, b)` 定义颜色(Navisworks API) -4. 保持透明度一致:通行空间类用 0.8-0.9,碰撞类用不透明 - -#### 对话框置顶原则 - -**原则**:在Navisworks插件环境中,所有模态对话框必须确保置顶显示,避免被主窗口遮挡。 - -**约束**: - -- 所有模态对话框必须在 XAML 中设置 `Topmost="True"` -- 使用 `DialogHelper` 工具类统一处理 Owner 设置(`src/Utils/DialogHelper.cs`) - -**参考**:详细方案见 `doc/guide/design_principles.md` 第15节、第17节 - -## 配置系统 - -配置文件使用 TOML 格式,默认配置位于 `default_config.toml`: - -```toml -[path_editing] -cell_size_meters = 0.5 # 网格单元大小(米) -max_height_diff_meters = 0.35 # 最大高度差(米) -object_length_meters = 1.5 # 物体长度 -object_width_meters = 1.0 # 物体宽度 -object_height_meters = 2.0 # 物体高度 -safety_margin_meters = 0.1 # 安全间隙 -default_path_turn_radius = 2.5 # 默认转弯半径 -arc_sampling_step = 0.05 # 圆弧采样步长 - -[visualization] -margin_ratio = 0.1 # 地图边距比例 - -[animation] -frame_rate = 30 # 动画帧率 -duration_seconds = 10.0 # 动画持续时间 -detection_tolerance_meters = 0.05 # 检测容差 -spatial_index_cell_size = 1.0 # 空间索引格子大小 - -[logistics] -traversable = true # 默认可通行性 -priority = 5 # 优先级(1-5) -height_limit_meters = 3.0 # 高度限制 -speed_limit_meters_per_second = 0.8 # 速度限制 -width_limit_meters = 3.0 # 宽度限制 -``` - -运行时通过 `ConfigManager.Instance.Current` 访问配置。 - -## 测试策略 - -### 测试项目结构 - -``` -UnitTests/ -├── Core/ -│ ├── PathCurveEngineTests.cs -│ └── UIStateManagerBasicTests.cs -├── Commands/ -│ └── CommandBaseTests.cs -├── Collections/ -│ └── ThreadSafeObservableCollectionBasicTests.cs -└── Utils/ - └── UnitsConverterTests.cs -``` - -### 测试框架 - -- **框架**: MSTest 3.0.4 -- **运行器**: VSTest.Console.exe -- **目标框架**: .NET Framework 4.8 - -### 编写测试 - -```csharp -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace NavisworksTransport.UnitTests -{ - [TestClass] - public class ExampleTests - { - [TestMethod] - public void TestMethod_ShouldDoSomething() - { - // Arrange - var input = "test"; - - // Act - var result = SomeMethod(input); - - // Assert - Assert.AreEqual("expected", result); - } - } -} -``` - -### 注意事项 - -- 独立测试:不依赖Navisworks环境,测试核心算法逻辑 -- 集成测试:需要完整的Navisworks 2026环境 -- 日志位置: `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\debug.log` - -## 开发工作流 - -### 添加新功能的标准流程 - -1. **定义接口**(如果需要) - - 在 `src/Commands/` 或 `src/Core/` 中定义接口 - -2. **实现核心逻辑** - - 业务逻辑放在 `src/Core/` 或 `src/PathPlanning/` - - 确保单位转换正确 - -3. **添加命令封装**(如果需要) - - 在 `src/Commands/` 中实现命令类 - - 继承 `CommandBase` 或实现 `IPathPlanningCommand` - -4. **更新UI** - - ViewModel 在 `src/UI/WPF/ViewModels/` - - View 在 `src/UI/WPF/Views/` - -5. **注册到主插件** - - 在 `MainPlugin.cs` 或相关管理器中集成 - -6. **添加测试** - - 在 `UnitTests/` 对应目录添加测试 - -### 调试技巧 - -1. **查看日志**: 日志文件位于 `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\` -2. **使用 LogManager**: 所有重要操作都应记录日志 -3. **Navisworks插件调试**: - - 附加到 `Roamer.exe` 进程 - - 使用 `LogManager.Debug()` 输出调试信息 - -## 常见问题 - -### 构建失败 - -- **错误**: "MSBuild not found" - - **解决**: 安装 Visual Studio 2022 或 Build Tools - -- **错误**: "无法找到 Autodesk.Navisworks.Api" - - **解决**: 安装 Navisworks Manage 2026 - -### 运行时错误 - -- **错误**: "单位不匹配导致的计算错误" - - **解决**: 检查 `UnitsConverter` 使用是否正确 - -- **错误**: "跨线程UI操作异常" - - **解决**: 使用 `UIStateManager` 或 `Dispatcher.Invoke` - -- **错误**: "插件未加载" - - **解决**: 检查插件是否部署到正确目录,检查依赖项是否存在 - -## 文档资源 - -- **API文档**: `doc/navisworks_api/NET/documentation/NET API.chm` -- **COM API文档**: `doc/navisworks_api/COM/documentation/NavisWorksCOM.chm` -- **设计原则**: `doc/guide/design_principles.md`(线程安全、对话框置顶等设计模式) -- **设计文档**: `doc/design/2026/` -- **架构设计**: `doc/architecture/` -- **迁移指南**: `doc/migration/`(2017到2026的API变更) - -## 版本信息 - -- **当前版本**: 2.0.0.0 -- **程序集**: TransportPlugin -- **作者**: Tian -- **版权**: Copyright © 2024 +1. `doc/working/current-engineering-state.md` +2. `doc/design/2026/coordinate-system-canonical-space-design.md` +3. `doc/design/2026/NavisworksAPI使用方法.md` --- -**注意**: 本插件专门针对 Navisworks 2026 开发,不考虑向后兼容。 +## 1. 项目现状 + +**NavisworksTransport** 是一个面向 **Autodesk Navisworks Manage 2026** 的物流路径规划与动画仿真插件。 + +当前已经不只是“自动寻路”项目,而是一套同时覆盖以下能力的工程: + +- 物流属性与对象分类 +- 地面路径、吊装路径、Rail 路径编辑与可视化 +- 终端安装仿真 +- 动画播放、起点落位、终点诊断 +- ClashDetective 碰撞检测与恢复 +- 路径、检测记录、批处理相关数据存储 + +### 当前重点功能 + +- `Ground / Hoisting / Rail` 三类路径 +- 真实物体与虚拟物体的起点摆放、动画姿态、通行空间 +- `YUp / ZUp` 两类宿主模型坐标系支持 +- 终端安装仿真中的: + - 端面三点分析 + - 光轴辅助线 + - 安装面双点确定 + - 安装点与 Rail 法向联动 + +--- + +## 2. 技术栈与构建 + +- 平台:Navisworks Manage 2026 +- 框架:.NET Framework 4.8 +- 语言:C# 7.3 +- 架构:x64 +- UI:WPF + Navisworks DockPane +- 测试:MSTest + +### 关键脚本 + +- `compile.bat` +- `run-unit-tests.bat` +- `deploy-plugin.bat` + +### 极重要的执行顺序 + +构建和部署必须严格按顺序执行: + +1. `./run-unit-tests.bat`(需要时) +2. `./compile.bat` +3. **等待编译完整结束并确认成功** +4. `./deploy-plugin.bat` + +不要并行执行编译和部署。否则很容易把旧 DLL 部署到插件目录。 + +### 并行执行边界 + +后续会话中的 AI 助手必须把命令分成两类: + +- **允许并行的纯读取操作** + - `rg` + - `Get-Content` + - `ls / Get-ChildItem` + - 读取日志 + - 查询状态 +- **禁止并行的产出型/宿主相关操作** + - `run-unit-tests.bat` + - `compile.bat` + - `deploy-plugin.bat` + - 启动/关闭 Navisworks + - 会占用 DLL、修改 `bin/obj`、写插件部署目录、依赖上一步产物的任何命令 + +硬约束: + +- 只有“纯读取、无副作用、且互不依赖”的命令才允许并行 +- 只要命令会生成、覆盖、部署、锁定文件、启动宿主进程,必须串行执行 +- 对本仓库,默认把 `run-unit-tests -> compile -> deploy -> start Navisworks` 视为**单通道流水线** +- 不允许把这条流水线放进任何并行工具里,即使只是为了节省时间 + +### 插件部署目录 + +- `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\` + +日志目录: + +- `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\debug.log` + +--- + +## 3. 目录与职责 + +### 核心目录 + +- `src/Core/` + - 插件主入口、路径管理、动画、碰撞、渲染、配置 +- `src/UI/WPF/` + - 视图、ViewModel、交互命令 +- `src/Utils/` + - 单位、几何、坐标、变换、日志等公共工具 +- `src/PathPlanning/` + - 网格、A*、路径几何与优化 +- `UnitTests/` + - 数学层、工具层、姿态层的回归测试 + +### 当前最关键的文件 + +- `src/Core/Animation/PathAnimationManager.cs` +- `src/Core/VirtualObjectManager.cs` +- `src/Core/PathPointRenderPlugin.cs` +- `src/UI/WPF/ViewModels/AnimationControlViewModel.cs` +- `src/UI/WPF/ViewModels/PathEditingViewModel.cs` +- `src/Utils/CoordinateSystem/HostCoordinateAdapter.cs` +- `src/Utils/CoordinateSystem/CanonicalPlanarPoseBuilder.cs` +- `src/Utils/CoordinateSystem/CanonicalRailPoseBuilder.cs` +- `src/Utils/CoordinateSystem/CanonicalTrackedPositionResolver.cs` +- `src/Utils/CoordinateSystem/RotatedObjectExtentHelper.cs` +- `src/Utils/RailPathPoseHelper.cs` +- `src/Utils/ModelItemTransformHelper.cs` + +--- + +## 4. 当前稳定架构 + +### 4.1 坐标系三层语义 + +以后统一只使用这三种说法: + +- **宿主坐标系** + - Navisworks 文档坐标系 + - `YUp` 或 `ZUp` + - UI 输入输出、日志、拾取结果都按这一层解释 + +- **内部坐标系** + - 项目内部统一使用的 `Canonical Space` + - 固定 `ZUp` + - 纯数学姿态和几何计算优先在这里完成 + +- **资产坐标系** + - 只属于插件自带资源 + - 当前主要是: + - 虚拟物体 `unit_cube.nwc` + - 参考杆 `unit_cylinder.nwc` + +禁止再使用“本地坐标系”这种含糊说法。 + +### 4.1.2 坐标命名规则 + +以后凡是变量名、字段名、日志名里出现 `X/Y/Z`、正负轴、forward/up/side 等方向语义,必须在名字上直接带出所属坐标系,避免只看名字时无法判断语义层。 + +允许的前缀示例: + +- `Host...` + - 宿主坐标系 +- `Canonical...` + - 内部坐标系 +- `Asset...` + - 资产坐标系 +- `Local...` + - 仅当这里明确表示“对象自身局部轴”时才允许使用 + +正负轴命名示例: + +- `LocalPositiveX` +- `LocalNegativeY` +- `HostPositiveZ` +- `CanonicalPositiveX` +- `AssetPositiveY` + +方向/向量命名示例: + +- `hostForward` +- `hostUp` +- `canonicalForward` +- `assetUp` +- `localXAxis` +- `localZAxis` + +禁止继续使用这类脱离坐标系语义的名字: + +- `PositiveX` +- `NegativeZ` +- `xAxis` +- `upAxis` +- `forwardAxis` + +除非变量名里已经明确出现了所属坐标系前缀。 + +目标是做到:只看名字,就能知道这个方向、轴、向量到底属于宿主、内部、资产,还是对象自身局部轴。 + +### 4.1.3 对象局部轴业务映射 + +以后关于 `Local...`、前进轴、up 轴、side 轴,统一优先使用这个说法: + +- **对象局部轴业务映射** + - 它不是第四套全局坐标系 + - 也不是几何天然自带的“真理” + - 它表示:在当前业务链路里,我们准备把对象自身哪根局部轴解释成: + - `forward` + - `up` + - `side` + +这个概念的边界必须非常明确: + +- 它是**业务解释层** + - 不是宿主坐标系 + - 不是内部 `Canonical Space` + - 不是插件资源资产坐标系 +- 它不能脱离上下文单独存在 + - `up` 往往要结合宿主 `up` 语义来解释 + - `forward` 往往取决于: + - fragment 代表姿态解释 + - 资源默认轴约定 + - 用户选择 + - 当前路径类型 + +为什么必须保留这层概念: + +- 路径姿态求解必须回答: + - 对象哪根局部轴去对齐路径 `forward` + - 对象哪根局部轴去对齐目标 `up` +- 如果没有这层映射,下面这些都无法稳定定义: + - `Ground / Hoisting / Rail` 起点姿态 + - 逐帧姿态 + - `Rail` 角度修正 + - 通行空间尺寸投影 + - 终点原始姿态保持 + - `Rail` 平移模式的终点原位搬运 + +当前项目里的硬约束: + +- 不要把“对象局部轴业务映射”写成“对象自身天然就有 forward/up 定义” +- 对真实物体: + - 不能简单把 `ModelItem.Transform` 当成可靠的局部轴真值 + - 应优先通过 fragment 代表姿态 + `Fragment默认Up` 去解释 +- 对虚拟物体: + - 对象局部轴业务映射通常来自明确的资产轴约定 +- 如果只是讨论对象自身局部轴语义,允许继续使用 `Local...` 命名 + - 但文档、日志、设计讨论里优先说“对象局部轴业务映射” + +### 4.1.1 Quaternion / Rotation3D 固定定义 + +这是项目级硬约束,不允许在任何修复中重新猜测、重新验证或临时改口: + +- Navisworks `Rotation3D(double, double, double, double)` 的参数顺序固定为: + - `x, y, z, w` +- `Rotation3D.A/B/C/D` 与 quaternion 分量的对应关系固定为: + - `A = x` + - `B = y` + - `C = z` + - `D = w` +- 当代码里已经拿到 quaternion `(qx, qy, qz, qw)` 时,唯一允许的构造方式是: + +```csharp +var rotation = new Rotation3D(qx, qy, qz, qw); +``` + +- 严禁再写成: + +```csharp +var rotation = new Rotation3D(qw, qx, qy, qz); // 错误 +``` + +- 以后遇到姿态问题,禁止再把故障归因到 “Navisworks 四元数分量顺序可能又不一样了”。 +- 如果问题涉及真实物体或 fragment 参考姿态的轴语义,优先检查: + - fragment 三轴的业务解释是否正确 + - 参考姿态是否应该用显式三轴而不是只传 quaternion + - 宿主坐标系 / 内部坐标系 / 资产坐标系 是否被混用 + +### 4.2 真实物体 vs 虚拟物体 + +这是当前架构里最容易被改坏的地方。 + +#### 真实物体 + +- 没有独立资产坐标系 +- 可以视为直接生活在宿主坐标系里 +- 角度调整对话框里的 `X/Y/Z`,对真实物体应按**宿主坐标系**消费 + +#### 真实物体参考姿态 + +- 真实物体不要再直接依赖 `ModelItem.Transform.Factor().Rotation` + - 对很多 Revit 导入件,这个值是单位旋转,不代表真实显示姿态 +- 真实物体参考姿态应优先来自 fragment 代表姿态 +- `Fragment默认Up` 的用途是: + - 把 fragment 参考框架解释成当前宿主坐标语义下的真实姿态 + - 不是仅仅挑一个“竖直候选轴” +- 在真实物体链路里,必须先完成“fragment 姿态解释”,再谈: + - 前进方向 + - 路径对齐 + - 角度调整 +- `_trackedRotation` 对真实物体必须优先跟踪“解释后的真实参考姿态”,不能回退成 `Transform` 的单位旋转 + +#### 虚拟物体 + +- 有明确资产坐标系 +- 当前依赖 `unit_cube.nwc` 资源 +- 资源必须满足: + - 几何中心在原点 + - 原点处 `BoundingBox.Center == (0,0,0)` +- 如果生产环境里虚拟物体固定偏移,先检查部署的 `unit_cube.nwc` 是否为最新资源,不要先怀疑代码 + +### 4.3 路径姿态链 + +#### Ground / Hoisting + +- 走平面姿态链 +- 已禁止偷偷退回旧 `yaw` 方案 +- 起点、逐帧、终点、通行空间,必须共享同一套尺寸语义 +- `Ground + 真实物体` 的业务跟踪点固定为**原始包围盒中心** +- 旋转后的实时 `BoundingBox.Center` 只允许用于: + - 诊断旋转后漂移 + - 计算当帧补偿 + - 校验补偿结果 +- 不允许把实时 `BoundingBox.Center` 直接当成: + - 起点目标跟踪点 + - 逐帧业务跟踪点 + - 碰撞记录主语义跟踪点 +- 如果 `Ground` 出现“起点正确但越走越偏”或“起点偏了但拐弯后偏差反而减小”,优先检查是否把实时包围盒中心误当成了业务跟踪点 + +#### Rail + +- 不能像平面路径那样在宿主空间随意补旋转 +- 必须并入 `canonical -> rail pose` 链 +- `Rail 0°` 基线必须稳定,不能被角度修正逻辑污染 +- `Rail` 真实物体不应再退回默认 `PositiveX / PositiveY` +- 三类路径都必须先复用同一个“对象姿态解释层”: + - `Ground / Hoisting` + - `forward = 路径方向` + - `up = 宿主 up` + - `Rail` + - `forward = rail 切向` + - `up = rail 法向 / preferred normal` +- 统一的是“对象参考姿态来源与解释方式”,不是把三类路径都强行改成同一个 `up` + +### 4.4 通行空间与物体姿态的关系 + +通行空间、起点落位、逐帧位置、终点诊断,必须尽量共用同一套尺寸/法线语义。 + +典型错误信号: + +- 通行空间正确,但物体陷入地面 +- 物体姿态正确,但通行空间轴搞反 +- `YUp` 下 `Y/Z` 表现互换 + +遇到这类问题,优先检查: + +- 是否一条链用了“原始高度” +- 另一条链用了“旋转后法线尺寸” +- `Rail` 真实物体尤其要检查: + - 起点/逐帧中心偏移是否仍在用原始 `objectHeight` + - 通行空间是否仍在用旧 `RailAssetConvention` + - 通行空间、路径偏移、最终姿态是否共享同一套“最终姿态尺寸语义” + +### 4.5 终端安装仿真 + +当前稳定链路是: + +1. 捕获终点箱体 +2. 分析端面(3点) +3. 选择安装点(当前已是 2 点确定安装面) +4. 生成辅助线 / 安装面 / 安装点 +5. 取起点并生成路径 + +当前 UI 上: + +- 终端安装仿真是独立区块 +- 不再夹在路径编辑中间 +- 安装方式选择只保留一处 + +--- + +## 5. 开发原则 + +### 5.1 彻底禁止 fallback + +这是当前项目的第一编码原则,优先级高于其他“先跑起来”的考虑。 + +不允许出现以下行为: + +- 新姿态链失败时,静默退回旧姿态链 +- 新变换链失败时,静默退回旧变换链 +- 正确姿势/正确位置/正确尺寸语义拿不到时,用“差不多”的旧值、缓存值、默认值顶上 +- 只打印一条 warning,然后继续使用错误语义把流程跑完 + +尤其禁止这类做法: + +- 偷偷退回旧 `yaw` +- 偷偷用硬编码 `Z-up` +- 偷偷在错误时给默认值掩盖问题 +- 偷偷在新链失败时自动掉回旧链 +- 读不到当前实际几何旋转时,回退到 `_trackedRotation` +- 读不到当前真实姿态时,回退到 `referenceRotation` +- 读不到当前显示姿态时,回退到 `ModelItem.Transform` + +正确做法只有两种: + +1. 在进入新链前把前置条件补齐 +2. 直接暴露失败并修根因 + +不允许把“旧链兜底”当成正式实现的一部分。 + +### 5.2 不向后兼容 + +项目只针对 Navisworks 2026。不要写旧版本兼容代码。 + +### 5.3 临时补丁不是正式实现 + +为定位问题临时加入的: + +- 强制刷新 +- 额外同步 +- 再调一次方法 +- UI 和稀泥补丁 + +如果最后证明它不是真正根因,修完后必须删掉,不能残留在正式代码里。 + +### 5.4 优先复用现有工具 + +尤其优先看: + +- `UnitsConverter` +- `GeometryHelper` +- `LogManager` +- `HostCoordinateAdapter` +- `Canonical*` 姿态工具 +- `ModelItemTransformHelper` +- `RailPathPoseHelper` + +不要在业务层手搓一套新的矩阵、坐标变换或尺寸投影公式。 + +### 5.5 测试优先于猜测 + +对几何/旋转/坐标问题,优先顺序应是: + +1. 看日志 +2. 日志不够就补日志 +3. 先补单元测试 +4. 再改代码 + +不要在没有锁住语义之前反复试错改实现。 + +--- + +## 6. 单位原则 + +所有路径计算、网格计算、包络尺寸、位移偏移,内部一律使用**模型单位**。 + +命名规则: + +- 米单位:变量名以 `InMeters` 结尾 +- 模型单位:变量名不加后缀 + +不要混用。 + +优先使用: + +- `UnitsConverter.GetMetersToUnitsConversionFactor()` +- `UnitsConverter.GetUnitsToMetersConversionFactor()` +- `UnitsConverter.ConvertToMeters(...)` +- `UnitsConverter.ConvertFromMeters(...)` + +--- + +## 7. 常见问题的排查入口 + +### 7.1 虚拟物体固定偏差 + +先查: + +1. 部署目录下的 `resources\\unit_cube.nwc` +2. 原点处 `BoundingBox.Center` 是否是 `(0,0,0)` +3. 再查起点/归位代码 + +### 7.2 真实物体旋转轴不对 + +先区分: + +- 目标姿态算错 +- 还是 Navisworks 应用姿态错 + +优先看: + +- `[动画姿态入口]` +- `[模型增量姿态]` + +### 7.3 吊装路径不显示 + +优先检查渲染链里是否有退化段/零长度段导致整条渲染失败。 + +### 7.4 “设为终点直接结束”后列表还是空 + +优先看 `UIStateManager` 队列消费是否吃掉了后续 UI 事件,不要先怀疑坐标系。 + +--- + +## 8. 资源与部署注意事项 + +### 8.1 虚拟物体资源 + +当前部署脚本会部署: + +- `resources\\unit_cube.nwc` +- `resources\\unit_cylinder.nwc` + +虚拟物体和参考杆问题,必须同时检查: + +- 仓库里的资源 +- `bin\\x64\\Release\\resources` +- 插件部署目录下的 `resources` + +### 8.2 deploy-plugin.bat 当前规则 + +- 走白名单部署 +- 不部署测试 DLL +- 不部署 Navisworks 自带 API DLL + +不要把它改回“复制所有 dll”。 + +--- + +## 9. 推荐阅读顺序 + +新会话接手本项目时,推荐顺序: + +1. 本文件 `AGENTS.md` +2. `doc/working/current-engineering-state.md` +3. `doc/design/2026/coordinate-system-canonical-space-design.md` +4. `doc/design/2026/NavisworksAPI使用方法.md` +5. 相关专项 skill: + - `.agents/skills/nw-api/SKILL.md` + - `.agents/skills/geometry-transform/SKILL.md` + +--- + +## 10. 当前对子代理和 skill 的约定 + +仓库中已经有几何/变换专项 skill: + +- `.agents/skills/geometry-transform/SKILL.md` + +它负责沉淀以下内容: + +- 坐标系变换 +- 物体姿态 +- 物体位移与归位 +- 虚拟物体资源定位 +- 通行空间几何 +- Navisworks 变换 API 使用规则 + +后续凡是几何/旋转/位移问题,优先沿这套 skill 与工具链继续维护,不要重新发明一套术语和流程。 + +--- + +## 11. 最后的硬约束 + +1. 不要混淆宿主坐标系、内部坐标系、资产坐标系 +2. 不要在宿主空间随意补旋转,先判断是否应并入现有姿态链 +3. 不要让虚拟物体和真实物体共享含糊的状态分支 +4. 不要让通行空间和真实物体使用两套不同的尺寸语义 +5. 不要把临时补丁留在正式实现里 +6. 不要绕过测试直接改几何核心逻辑 + +如果你要改动: + +- `PathAnimationManager` +- `VirtualObjectManager` +- `PathPointRenderPlugin` +- `AnimationControlViewModel` +- `HostCoordinateAdapter` +- `Canonical*PoseBuilder` +- `ModelItemTransformHelper` + +请默认这是高风险改动,先补测试,再动实现。 diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj new file mode 100644 index 0000000..6de6ee0 --- /dev/null +++ b/NavisworksTransport.UnitTests.csproj @@ -0,0 +1,105 @@ + + + + + Debug + x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5} + Library + Properties + NavisworksTransport.UnitTests + NavisworksTransport.UnitTests + v4.8 + 512 + true + + + true + bin\x64\Debug\ + DEBUG;TRACE + full + x64 + 7.3 + prompt + + + bin\x64\Release\ + TRACE + true + pdbonly + x64 + 7.3 + prompt + + + + ..\..\..\..\Program Files\Autodesk\Navisworks Manage 2026\Autodesk.Navisworks.Api.dll + True + + + packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + + + packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + + packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {1A0124F6-3DEB-4153-8760-F568AD9393EE} + TransportPlugin + + + + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 1020f6e..0edda3e 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -31,4 +31,5 @@ using System.Runtime.InteropServices; // // 主版本和次版本手动维护,Build和Revision在编译时自动更新 [assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("NavisworksTransport.UnitTests")] diff --git a/TransportPlugin.csproj b/TransportPlugin.csproj index 9fe4324..8912ebe 100644 --- a/TransportPlugin.csproj +++ b/TransportPlugin.csproj @@ -63,6 +63,7 @@ + packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll @@ -133,10 +134,13 @@ + + + @@ -146,6 +150,7 @@ + @@ -161,6 +166,7 @@ + @@ -280,6 +286,7 @@ + @@ -302,6 +309,7 @@ + @@ -329,6 +337,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -336,8 +372,11 @@ + + + @@ -468,13 +507,6 @@ - - - - PreserveNewest - TransportPlugin.name.txt - - @@ -484,16 +516,38 @@ PreserveNewest resources\default_config.toml + + + PreserveNewest + SQLite.Interop.dll + PreserveNewest resources\TransportPlugin.name.txt + + PreserveNewest + TransportRibbon.xaml + + + PreserveNewest + TransportRibbon_16.png + + + PreserveNewest + TransportRibbon_32.png + PreserveNewest resources\unit_cube.nwc + + + PreserveNewest + resources\unit_cylinder.nwc + - \ No newline at end of file + diff --git a/TransportPlugin.sln b/TransportPlugin.sln index 7b0594e..3f2571b 100644 --- a/TransportPlugin.sln +++ b/TransportPlugin.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.14.36203.30 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransportPlugin", "TransportPlugin.csproj", "{1A0124F6-3DEB-4153-8760-F568AD9393EE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NavisworksTransport.UnitTests", "NavisworksTransport.UnitTests.csproj", "{7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}" +EndProject Project("{54435603-DBB4-11D2-8724-00A0C9A8B90C}") = "TransportPlugin.Setup", "..\TransportPlugin.Setup\TransportPlugin.Setup.vdproj", "{E1955F72-A686-9398-1C6A-936493D9211F}" EndProject Global @@ -23,6 +25,14 @@ Global {1A0124F6-3DEB-4153-8760-F568AD9393EE}.Release|Any CPU.Build.0 = Release|Any CPU {1A0124F6-3DEB-4153-8760-F568AD9393EE}.Release|x64.ActiveCfg = Release|x64 {1A0124F6-3DEB-4153-8760-F568AD9393EE}.Release|x64.Build.0 = Release|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Debug|Any CPU.ActiveCfg = Debug|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Debug|Any CPU.Build.0 = Debug|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Debug|x64.ActiveCfg = Debug|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Debug|x64.Build.0 = Debug|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Release|Any CPU.ActiveCfg = Release|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Release|Any CPU.Build.0 = Release|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Release|x64.ActiveCfg = Release|x64 + {7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}.Release|x64.Build.0 = Release|x64 {E1955F72-A686-9398-1C6A-936493D9211F}.Debug|Any CPU.ActiveCfg = Debug {E1955F72-A686-9398-1C6A-936493D9211F}.Debug|x64.ActiveCfg = Debug {E1955F72-A686-9398-1C6A-936493D9211F}.Debug|x64.Build.0 = Debug diff --git a/UnitTests/CoordinateSystem/AssemblyEndFaceAnalyzerTests.cs b/UnitTests/CoordinateSystem/AssemblyEndFaceAnalyzerTests.cs new file mode 100644 index 0000000..401b846 --- /dev/null +++ b/UnitTests/CoordinateSystem/AssemblyEndFaceAnalyzerTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.GeometryAnalysis; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class AssemblyEndFaceAnalyzerTests + { + [TestMethod] + public void Analyze_RectangularFaceWithSideNoise_ShouldReturnFaceCenter() + { + var triangles = new List(); + triangles.AddRange(CreateRectangleFace(-2.0, 2.0, -1.0, 1.0, 10.0)); + triangles.AddRange(CreateRectangleFace(-2.0, 2.0, -1.0, 1.0, 8.0)); + triangles.Add(new AnalysisTriangle3( + new Vector3(-2.0f, -1.0f, 10.0f), + new Vector3(-2.0f, -1.0f, 9.0f), + new Vector3(-2.0f, 1.0f, 9.0f))); + + EndFaceAnalysisResult result = AssemblyEndFaceAnalyzer.Analyze( + triangles, + new Vector3(-1.5f, -0.7f, 10.0f), + new Vector3(1.4f, -0.5f, 10.0f), + new Vector3(1.1f, 0.8f, 10.0f)); + + Assert.IsTrue(result.IsReliable, result.DiagnosticMessage); + AssertPoint(result.Center, 0.0, 0.0, 10.0); + Assert.AreEqual(2, result.CandidateTriangleCount); + } + + [TestMethod] + public void Analyze_SquareRingFace_ShouldReturnRingCenter() + { + var triangles = new List(); + triangles.AddRange(CreateRingFace(4.0, 2.0, 5.0)); + + EndFaceAnalysisResult result = AssemblyEndFaceAnalyzer.Analyze( + triangles, + new Vector3(-3.5f, -3.5f, 5.0f), + new Vector3(3.5f, -3.5f, 5.0f), + new Vector3(3.5f, 3.5f, 5.0f)); + + Assert.IsTrue(result.IsReliable, result.DiagnosticMessage); + AssertPoint(result.Center, 0.0, 0.0, 5.0); + Assert.IsTrue(result.CandidateTriangleCount >= 8); + } + + [TestMethod] + public void Analyze_NearlyCollinearSeedPoints_ShouldFail() + { + var triangles = new List(); + triangles.AddRange(CreateRectangleFace(-1.0, 1.0, -1.0, 1.0, 0.0)); + + EndFaceAnalysisResult result = AssemblyEndFaceAnalyzer.Analyze( + triangles, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(1.0f, 0.0f, 0.0f), + new Vector3(2.0f, 0.0f, 0.0f)); + + Assert.IsFalse(result.IsReliable); + } + + [TestMethod] + public void OrientNormalTowardTarget_ShouldFlip_WhenNormalOpposesTargetDirection() + { + Vector3 orientedNormal = AssemblyEndFaceAnalyzer.OrientNormalTowardTarget( + new Vector3(-1f, 0f, 0f), + Vector3.Zero, + new Vector3(10f, 0f, 0f)); + + AssertPoint(orientedNormal, 1.0, 0.0, 0.0); + } + + [TestMethod] + public void OrientNormalTowardTarget_ShouldKeepDirection_WhenNormalAlreadyFacesTarget() + { + Vector3 orientedNormal = AssemblyEndFaceAnalyzer.OrientNormalTowardTarget( + new Vector3(0f, 1f, 0f), + new Vector3(1f, 2f, 3f), + new Vector3(1f, 5f, 3f)); + + AssertPoint(orientedNormal, 0.0, 1.0, 0.0); + } + + private static IEnumerable CreateRectangleFace(double minX, double maxX, double minY, double maxY, double z) + { + yield return new AnalysisTriangle3( + new Vector3((float)minX, (float)minY, (float)z), + new Vector3((float)maxX, (float)minY, (float)z), + new Vector3((float)maxX, (float)maxY, (float)z)); + yield return new AnalysisTriangle3( + new Vector3((float)minX, (float)minY, (float)z), + new Vector3((float)maxX, (float)maxY, (float)z), + new Vector3((float)minX, (float)maxY, (float)z)); + } + + private static IEnumerable CreateRingFace(double outerHalfSize, double innerHalfSize, double z) + { + if (innerHalfSize >= outerHalfSize) + { + throw new ArgumentOutOfRangeException(nameof(innerHalfSize)); + } + + foreach (AnalysisTriangle3 triangle in CreateRectangleFace(-outerHalfSize, outerHalfSize, innerHalfSize, outerHalfSize, z)) + { + yield return triangle; + } + + foreach (AnalysisTriangle3 triangle in CreateRectangleFace(-outerHalfSize, outerHalfSize, -outerHalfSize, -innerHalfSize, z)) + { + yield return triangle; + } + + foreach (AnalysisTriangle3 triangle in CreateRectangleFace(-outerHalfSize, -innerHalfSize, -innerHalfSize, innerHalfSize, z)) + { + yield return triangle; + } + + foreach (AnalysisTriangle3 triangle in CreateRectangleFace(innerHalfSize, outerHalfSize, -innerHalfSize, innerHalfSize, z)) + { + yield return triangle; + } + } + + private static void AssertPoint(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-5); + Assert.AreEqual(y, actual.Y, 1e-5); + Assert.AreEqual(z, actual.Z, 1e-5); + } + } +} diff --git a/UnitTests/CoordinateSystem/AssemblyInstallationReferenceBuilderTests.cs b/UnitTests/CoordinateSystem/AssemblyInstallationReferenceBuilderTests.cs new file mode 100644 index 0000000..a058cdf --- /dev/null +++ b/UnitTests/CoordinateSystem/AssemblyInstallationReferenceBuilderTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Numerics; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.GeometryAnalysis; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class AssemblyInstallationReferenceBuilderTests + { + [TestMethod] + public void Build_ShouldCreatePlaneParallelToOpticalAxisAndProjectedAnchor() + { + Vector3 axisBase = new Vector3(0f, 0f, 0f); + Vector3 axisDirection = Vector3.UnitX; + Vector3 pickPoint = new Vector3(2f, 3f, 4f); + + AssemblyInstallationReferenceResult result = + AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint); + + Assert.AreEqual(5f, result.OffsetDistance, 1e-4f); + Assert.AreEqual(2f, result.AnchorPoint.X, 1e-4f); + Assert.AreEqual(3f, result.AnchorPoint.Y, 1e-4f); + Assert.AreEqual(4f, result.AnchorPoint.Z, 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneNormal, result.OpticalAxisDirection), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneSpanDirection, result.OpticalAxisDirection), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneNormal, result.PlaneSpanDirection), 1e-4f); + } + + [TestMethod] + public void BuildFromTwoPoints_ShouldCreatePlaneParallelToOpticalAxisAndContainBothPoints() + { + Vector3 axisBase = new Vector3(0f, 0f, 0f); + Vector3 axisDirection = Vector3.UnitX; + Vector3 pickPoint1 = new Vector3(2f, 3f, 4f); + Vector3 pickPoint2 = new Vector3(6f, 3f, 6f); + + AssemblyInstallationReferenceResult result = + AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint1, pickPoint2); + + Assert.AreEqual(3f, result.OffsetDistance, 1e-4f); + Assert.AreEqual(4f, result.AnchorPoint.X, 1e-4f); + Assert.AreEqual(3f, result.AnchorPoint.Y, 1e-4f); + Assert.AreEqual(0f, result.AnchorPoint.Z, 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneNormal, result.OpticalAxisDirection), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneSpanDirection, result.OpticalAxisDirection), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(result.PlaneNormal, result.PlaneSpanDirection), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(pickPoint1 - result.AnchorPoint, result.PlaneNormal), 1e-4f); + Assert.AreEqual(0f, Vector3.Dot(pickPoint2 - result.AnchorPoint, result.PlaneNormal), 1e-4f); + } + + [TestMethod] + public void BuildFromTwoPoints_ShouldUseOpticalAxisProjectionAsInstallationCenterLine() + { + Vector3 axisBase = new Vector3(0f, 0f, 0f); + Vector3 axisDirection = Vector3.UnitX; + Vector3 pickPoint1 = new Vector3(2f, 3f, 4f); + Vector3 pickPoint2 = new Vector3(6f, 3f, 6f); + + AssemblyInstallationReferenceResult result = + AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint1, pickPoint2); + + Assert.AreEqual(0f, result.InstallLineBasePoint.X, 1e-4f); + Assert.AreEqual(3f, result.InstallLineBasePoint.Y, 1e-4f); + Assert.AreEqual(0f, result.InstallLineBasePoint.Z, 1e-4f); + Assert.AreEqual(4f, result.AnchorPoint.X, 1e-4f); + Assert.AreEqual(3f, result.AnchorPoint.Y, 1e-4f); + Assert.AreEqual(0f, result.AnchorPoint.Z, 1e-4f); + } + + [TestMethod] + public void BuildFromTwoPoints_WhenPlanePointsAreNearlyAxisAligned_ShouldThrow() + { + Vector3 axisBase = new Vector3(0f, 0f, 0f); + Vector3 axisDirection = Vector3.UnitX; + Vector3 pickPoint1 = new Vector3(2f, 3f, 4f); + Vector3 pickPoint2 = new Vector3(6f, 3.00001f, 4.00001f); + + InvalidOperationException ex = Assert.ThrowsException( + () => AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint1, pickPoint2)); + + StringAssert.Contains(ex.Message, "无法确定安装参考面"); + } + + [TestMethod] + public void Build_WhenPickPointTooCloseToOpticalAxis_ShouldThrow() + { + Vector3 axisBase = new Vector3(0f, 0f, 0f); + Vector3 axisDirection = Vector3.UnitX; + Vector3 pickPoint = new Vector3(2f, 0f, 0f); + + InvalidOperationException ex = Assert.ThrowsException( + () => AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint)); + + StringAssert.Contains(ex.Message, "距离光轴过小"); + } + } +} diff --git a/UnitTests/CoordinateSystem/AutoPathPlanningCoordinateSemanticsTests.cs b/UnitTests/CoordinateSystem/AutoPathPlanningCoordinateSemanticsTests.cs new file mode 100644 index 0000000..3b3cd3a --- /dev/null +++ b/UnitTests/CoordinateSystem/AutoPathPlanningCoordinateSemanticsTests.cs @@ -0,0 +1,405 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core; +using NavisworksTransport.PathPlanning; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; +using System.Reflection; +using System.Collections.Generic; +using Autodesk.Navisworks.Api; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class AutoPathPlanningCoordinateSemanticsTests + { + [TestMethod] + public void YUp_HostPlanarGridHelper_CreateHostPoint_ShouldApplyElevationOnHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + + Vector3 point = HostPlanarGridHelper.CreateHostPoint3(2.0, 3.0, 14.83, adapter); + + AssertPoint(point, 2.0, 14.83, 3.0); + } + + [TestMethod] + public void ZUp_HostPlanarGridHelper_CreateHostPoint_ShouldApplyElevationOnHostZ() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + + Vector3 point = HostPlanarGridHelper.CreateHostPoint3(2.0, 3.0, 6.25, adapter); + + AssertPoint(point, 2.0, 3.0, 6.25); + } + + [TestMethod] + public void YUp_HostPlanarGridHelper_GetAndSetElevation_ShouldUseHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var hostPoint = new Vector3(4.0f, 15.0f, 6.0f); + + double elevation = HostPlanarGridHelper.GetElevation3(hostPoint, adapter); + Vector3 adjusted = HostPlanarGridHelper.SetElevation3(hostPoint, 20.0, adapter); + + Assert.AreEqual(15.0, elevation, 1e-6); + AssertPoint(adjusted, 4.0, 20.0, 6.0); + } + + [TestMethod] + public void YUp_HostPlanarGridHelper_GetHorizontalCoords_ShouldProjectToHostXZPlane() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var hostPoint = new Vector3(8.0f, 14.83f, -5.35f); + + (double h1, double h2) = HostPlanarGridHelper.GetHorizontalCoords3(hostPoint, adapter); + + Assert.AreEqual(8.0, h1, 1e-6); + Assert.AreEqual(-5.35, h2, 1e-6); + } + + [TestMethod] + public void YUp_HostPlanarGridHelper_HorizontalRange_ShouldMatchHostXZPlane() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var hostMin = new Vector3(-10.0f, 2.0f, -30.0f); + var hostMax = new Vector3(40.0f, 12.0f, 5.0f); + + (double min1, double max1, double min2, double max2) = + HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + + Assert.AreEqual(-10.0, min1, 1e-6); + Assert.AreEqual(40.0, max1, 1e-6); + Assert.AreEqual(-30.0, min2, 1e-6); + Assert.AreEqual(5.0, max2, 1e-6); + } + + [TestMethod] + public void YUp_GridLikeWorldPointConstruction_ShouldWriteElevationToHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + double originH1 = -210.0; + double originH2 = -10.0; + double cellSize = 1.0; + + Vector3 worldPoint = HostPlanarGridHelper.CreateHostPoint3( + originH1 + 4 * cellSize, + originH2 + 6 * cellSize, + 14.83, + adapter); + + Assert.AreEqual(-206.0, worldPoint.X, 1e-6); + Assert.AreEqual(14.83, worldPoint.Y, 1e-6); + Assert.AreEqual(-4.0, worldPoint.Z, 1e-6); + } + + [TestMethod] + public void YUp_GridLikeWorldToGrid_ShouldReadHostXZAsPlanarAxes() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var origin = new Vector3(-210.0f, 14.83f, -10.0f); + var endPoint = new Vector3(-169.20f, 14.83f, -5.35f); + const double cellSize = 1.0; + + (double pointH1, double pointH2) = HostPlanarGridHelper.GetHorizontalCoords3(endPoint, adapter); + (double originH1, double originH2) = HostPlanarGridHelper.GetHorizontalCoords3(origin, adapter); + int gridX = (int)System.Math.Round((pointH1 - originH1) / cellSize); + int gridY = (int)System.Math.Round((pointH2 - originH2) / cellSize); + + Assert.AreEqual(41, gridX); + Assert.AreEqual(5, gridY); + } + + [TestMethod] + public void ZUp_GridMap_CellPlanarBounds_ShouldMatchNearestNodeRoundSemantics() + { + var bounds = GridMap.CalculateNearestNodePlanarBounds(2.0, 3.0, 1.0); + + Assert.AreEqual(1.5, bounds.minH1, 1e-9); + Assert.AreEqual(2.5, bounds.maxH1, 1e-9); + Assert.AreEqual(2.5, bounds.minH2, 1e-9); + Assert.AreEqual(3.5, bounds.maxH2, 1e-9); + } + + [TestMethod] + public void ZUp_GridMap_SegmentIntersection_ShouldDetectInteriorNotBoundaryTouch() + { + var bounds = GridMap.CalculateNearestNodePlanarBounds(2.0, 3.0, 1.0); + + bool crossesInterior = GridMap.TryGetSegmentRectangleInteriorIntersection( + 1.75, + 3.0, + 2.25, + 3.0, + bounds.minH1, + bounds.maxH1, + bounds.minH2, + bounds.maxH2, + 1.0, + out double enterT, + out double exitT); + + Assert.IsTrue(crossesInterior); + Assert.IsTrue(enterT >= 0.0 && enterT <= exitT && exitT <= 1.0); + + bool onlyTouchesBoundary = GridMap.TryGetSegmentRectangleInteriorIntersection( + 1.5, + 2.0, + 1.5, + 4.0, + bounds.minH1, + bounds.maxH1, + bounds.minH2, + bounds.maxH2, + 1.0, + out _, + out _); + + Assert.IsFalse(onlyTouchesBoundary, "贴着归属边界行走不应被当成穿过格子内部。"); + } + + [TestMethod] + public void YUp_GridMapGenerator_GetObstacleElevationRange_ShouldUseHostYInsteadOfWorldZ() + { + var generator = new GridMapGenerator(); + + var method = typeof(GridMapGenerator).GetMethod( + "GetObstacleElevationRange", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 GetObstacleElevationRange 私有方法。"); + + var elevationRange = ((double min, double max))method.Invoke(generator, new object[] + { + -200.0, 10.0, -30.0, + -198.0, 30.0, -10.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(10.0, elevationRange.min, 1e-6, "YUp 下障碍物最小高程应来自宿主 Y。"); + Assert.AreEqual(30.0, elevationRange.max, 1e-6, "YUp 下障碍物最大高程应来自宿主 Y。"); + } + + [TestMethod] + public void YUp_GridMapGenerator_CalculateBoundingBoxGridCoverage_ShouldProjectToHostXZPlane() + { + var generator = new GridMapGenerator(); + + var method = typeof(GridMapGenerator).GetMethod( + "CalculateGridCoverageFromWorldExtents", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(int), typeof(int), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 CalculateGridCoverageFromWorldExtents 私有方法。"); + + var coveredCells = (List<(int x, int y)>)method.Invoke(generator, new object[] + { + -200.0, 10.0, -30.0, + -198.0, 30.0, -10.0, + -210.0, 14.83, -80.0, + 200, 200, 1.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(63, coveredCells.Count, "YUp 下障碍物包围盒应按宿主 XZ 平面覆盖 3x21 个网格。"); + CollectionAssert.Contains(coveredCells, (10, 50)); + CollectionAssert.Contains(coveredCells, (12, 70)); + } + + [TestMethod] + public void YUp_GridMapGenerator_CalculateDoorOpeningCoverage_ShouldProjectToHostXZPlane() + { + var generator = new GridMapGenerator(); + + var method = typeof(GridMapGenerator).GetMethod( + "CalculateDoorOpeningCoverageFromWorldExtents", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(int), typeof(int), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 CalculateDoorOpeningCoverageFromWorldExtents 私有方法。"); + + var coveredCells = (List<(int x, int y)>)method.Invoke(generator, new object[] + { + 10.0, 14.0, 20.0, + 12.0, 16.0, 30.0, + 4.0, + 0.0, 0.0, 0.0, + 100, 100, 1.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(15, coveredCells.Count, "YUp 下门开口应按宿主 XZ 平面覆盖 3x5 个网格。"); + CollectionAssert.Contains(coveredCells, (10, 23)); + CollectionAssert.Contains(coveredCells, (12, 27)); + } + + [TestMethod] + public void YUp_PathPlanningManager_CalculateOptimalGridSize_ShouldUseHostXZHorizontalRange() + { + var method = typeof(PathPlanningManager).GetMethod( + "CalculateOptimalGridSizeFromBounds", + BindingFlags.Static | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 CalculateOptimalGridSizeFromBounds 私有方法。"); + + var gridSize = (double)method.Invoke(null, new object[] + { + -10.0, 100.0, -300.0, + 90.0, 120.0, 400.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(2.0, gridSize, 1e-6, "YUp 下网格大小应基于宿主 XZ 平面最大跨度 700 计算。"); + } + + [TestMethod] + public void YUp_ChannelHeightDetector_GetBoundsElevationRange_ShouldUseHostY() + { + var method = typeof(ChannelHeightDetector).GetMethod( + "GetBoundsElevationRange", + BindingFlags.Static | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 GetBoundsElevationRange 私有方法。"); + + var elevationRange = ((double min, double max))method.Invoke(null, new object[] + { + -10.0, 14.83, -30.0, + 20.0, 18.50, 40.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(14.83, elevationRange.min, 1e-6); + Assert.AreEqual(18.50, elevationRange.max, 1e-6); + } + + [TestMethod] + public void YUp_ChannelHeightDetector_CreateHeightProfileSample_ShouldUseHostXZPlaneAndHostYElevation() + { + var method = typeof(ChannelHeightDetector).GetMethod( + "CreateHeightProfileSamplePoint3", + BindingFlags.Static | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType), + typeof(double) + }, + null); + + Assert.IsNotNull(method, "未找到 CreateHeightProfileSamplePoint3 私有方法。"); + + var samplePoint = (Vector3)method.Invoke(null, new object[] + { + -10.0, 14.83, -30.0, + 20.0, 18.50, 40.0, + CoordinateSystemType.YUp, + 0.5 + }); + + Assert.AreEqual(5.0, samplePoint.X, 1e-6, "YUp 下采样点第一水平轴应沿宿主 X 插值。"); + Assert.AreEqual(14.83, samplePoint.Y, 1e-6, "YUp 下采样点高程应落在宿主底面 Y。"); + Assert.AreEqual(5.0, samplePoint.Z, 1e-6, "YUp 下采样点第二水平轴应沿宿主 Z 插值。"); + } + + [TestMethod] + public void YUp_ChannelHeightDetector_GetPickedSurfaceElevation_ShouldUseHostYInsteadOfWorldZ() + { + var method = typeof(ChannelHeightDetector).GetMethod( + "GetPickedSurfaceElevation", + BindingFlags.Static | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 GetPickedSurfaceElevation 私有方法。"); + + var elevation = (double)method.Invoke(null, new object[] + { + 11.0, 22.0, 33.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(22.0, elevation, 1e-6, "YUp 下拾取表面点的高程应来自宿主 Y。"); + } + + [TestMethod] + public void YUp_GridMapGenerator_ResolveBoundsMinElevation_ShouldUseHostYInsteadOfWorldZ() + { + var method = typeof(GridMapGenerator).GetMethod( + "ResolveBoundsMinElevation", + BindingFlags.Static | BindingFlags.NonPublic, + null, + new[] + { + typeof(double), typeof(double), typeof(double), + typeof(CoordinateSystemType) + }, + null); + + Assert.IsNotNull(method, "未找到 ResolveBoundsMinElevation 私有方法。"); + + var elevation = (double)method.Invoke(null, new object[] + { + 0.0, 12.5, -100.0, + CoordinateSystemType.YUp + }); + + Assert.AreEqual(12.5, elevation, 1e-6, "YUp 下边界最小高程应来自宿主 Y,而不是 bounds.Min.Z。"); + } + + private static void AssertPoint(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-6); + Assert.AreEqual(y, actual.Y, 1e-6); + Assert.AreEqual(z, actual.Z, 1e-6); + } + } +} diff --git a/UnitTests/CoordinateSystem/CanonicalPlanarPoseBuilderTests.cs b/UnitTests/CoordinateSystem/CanonicalPlanarPoseBuilderTests.cs new file mode 100644 index 0000000..d56e57d --- /dev/null +++ b/UnitTests/CoordinateSystem/CanonicalPlanarPoseBuilderTests.cs @@ -0,0 +1,188 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class CanonicalPlanarPoseBuilderTests + { + [TestMethod] + public void StraightForward_ZUpConvention_ShouldKeepLocalZAsWorldUp() + { + bool ok = CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp), + out Quaternion rotation); + + Assert.IsTrue(ok); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 1, 0); + AssertColumn(linear, 2, 0, 0, 1); + } + + [TestMethod] + public void StraightForward_YUpConvention_ShouldMapLocalYToWorldUp() + { + bool ok = CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp), + out Quaternion rotation); + + Assert.IsTrue(ok); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 0, 1); + AssertColumn(linear, 2, 0, -1, 0); + } + + [TestMethod] + public void ForwardWithVerticalComponent_ShouldProjectToHorizontalPlane() + { + bool ok = CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(10, 0, 3), + Vector3.UnitZ, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp), + out Quaternion rotation); + + Assert.IsTrue(ok); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + Assert.AreEqual(0.0, linear.M31, 1e-6); + Assert.AreEqual(0.0, linear.M32, 1e-6); + Assert.AreEqual(1.0, linear.M33, 1e-6); + } + + [TestMethod] + public void ZeroLocalEulerCorrection_ShouldMatchCurrentPlanarBaseline() + { + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + convention, + out Quaternion baselineRotation)); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + convention, + LocalEulerRotationCorrection.Zero, + out Quaternion correctedRotation)); + + AssertQuaternionEquivalent(baselineRotation, correctedRotation); + } + + [TestMethod] + public void LocalEulerCorrection_ShouldAlignCorrectedLocalForwardWithPlanarForward() + { + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + var correction = new LocalEulerRotationCorrection(0.0, 0.0, 90.0); + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(correction); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + convention, + correctionQuaternion, + out Quaternion rotation)); + + LocalAxisPoseBuilder.ApplyLocalPreRotation(convention, correctionQuaternion, out var correctedLocalForward, out var correctedLocalUp); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + + Vector3 mappedForward = TransformLocalVector(linear, correctedLocalForward); + Vector3 mappedUp = TransformLocalVector(linear, correctedLocalUp); + + AssertVector(mappedForward, 1, 0, 0); + AssertVector(mappedUp, 0, 0, 1); + } + + [TestMethod] + public void WorldCorrection_ShouldRotateBaselinePoseAroundHostYAxisInYUp() + { + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion worldCorrection = adapter.CreateCanonicalRotationCorrection( + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + new Vector3(5, 0, 0), + Vector3.UnitZ, + convention, + out Quaternion baselineRotation)); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForwardWithWorldCorrection( + new Vector3(5, 0, 0), + Vector3.UnitZ, + convention, + worldCorrection, + out Quaternion correctedRotation)); + + Matrix4x4 baseline = Matrix4x4.CreateFromQuaternion(baselineRotation); + + AssertColumn(baseline, 0, 1, 0, 0); + AssertColumn(baseline, 1, 0, 0, 1); + AssertColumn(baseline, 2, 0, -1, 0); + + Quaternion expectedRotation = Quaternion.Normalize(worldCorrection * baselineRotation); + AssertQuaternionEquivalent(expectedRotation, correctedRotation); + } + + private static void AssertColumn(Matrix4x4 matrix, int column, double x, double y, double z) + { + switch (column) + { + case 0: + Assert.AreEqual(x, matrix.M11, 1e-6); + Assert.AreEqual(y, matrix.M21, 1e-6); + Assert.AreEqual(z, matrix.M31, 1e-6); + break; + case 1: + Assert.AreEqual(x, matrix.M12, 1e-6); + Assert.AreEqual(y, matrix.M22, 1e-6); + Assert.AreEqual(z, matrix.M32, 1e-6); + break; + case 2: + Assert.AreEqual(x, matrix.M13, 1e-6); + Assert.AreEqual(y, matrix.M23, 1e-6); + Assert.AreEqual(z, matrix.M33, 1e-6); + break; + default: + Assert.Fail("Only first 3 columns are valid."); + break; + } + } + + private static void AssertVector(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-6); + Assert.AreEqual(y, actual.Y, 1e-6); + Assert.AreEqual(z, actual.Z, 1e-6); + } + + private static void AssertQuaternionEquivalent(Quaternion expected, Quaternion actual) + { + double dot = Math.Abs( + expected.X * actual.X + + expected.Y * actual.Y + + expected.Z * actual.Z + + expected.W * actual.W); + + Assert.AreEqual(1.0, dot, 1e-6); + } + + private static Vector3 TransformLocalVector(Matrix4x4 linear, Vector3 localVector) + { + Vector3 world = new Vector3( + linear.M11 * localVector.X + linear.M12 * localVector.Y + linear.M13 * localVector.Z, + linear.M21 * localVector.X + linear.M22 * localVector.Y + linear.M23 * localVector.Z, + linear.M31 * localVector.X + linear.M32 * localVector.Y + linear.M33 * localVector.Z); + return Vector3.Normalize(world); + } + } +} diff --git a/UnitTests/CoordinateSystem/CanonicalRailOffsetResolverTests.cs b/UnitTests/CoordinateSystem/CanonicalRailOffsetResolverTests.cs new file mode 100644 index 0000000..288ee90 --- /dev/null +++ b/UnitTests/CoordinateSystem/CanonicalRailOffsetResolverTests.cs @@ -0,0 +1,149 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core; +using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class CanonicalRailOffsetResolverTests + { + [TestMethod] + public void OverRail_ShouldOffsetTrackedCenterAlongNormalByHalfHeight() + { + PathRoute route = new PathRoute + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.OverRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine + }; + + RailLocalFrame frame = new RailLocalFrame(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ); + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + Vector3.Zero, + frame, + 4.0); + + AssertVector(trackedCenter, 0.0, 0.0, 2.0); + } + + [TestMethod] + public void UnderRail_ShouldOffsetTrackedCenterOppositeToNormalByHalfHeight() + { + PathRoute route = new PathRoute + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.UnderRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine + }; + + RailLocalFrame frame = new RailLocalFrame(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ); + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + Vector3.Zero, + frame, + 4.0); + + AssertVector(trackedCenter, 0.0, 0.0, -2.0); + } + + [TestMethod] + public void SlopedNormal_ShouldMoveTrackedCenterAlongRailNormalInsteadOfWorldUp() + { + PathRoute route = new PathRoute + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.OverRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine + }; + + Vector3 normal = Vector3.Normalize(new Vector3(0f, 1f, 1f)); + RailLocalFrame frame = new RailLocalFrame(Vector3.UnitX, Vector3.UnitY, normal); + + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + new Vector3(10f, 20f, 30f), + frame, + 4.0); + + Vector3 expected = new Vector3(10f, 20f, 30f) + normal * 2f; + AssertVector(trackedCenter, expected.X, expected.Y, expected.Z); + } + + [TestMethod] + public void AnchorSemantic_ShouldOnlyUseHalfHeightOffset_WhenReferencePointIsAlreadyAnchor() + { + PathRoute route = new PathRoute + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.OverRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine + }; + + RailLocalFrame frame = new RailLocalFrame(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ); + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + Vector3.Zero, + frame, + 4.0); + + AssertVector(trackedCenter, 0.0, 0.0, 2.0); + } + + [TestMethod] + public void RailNormalOffset_ShouldAddExtraDisplacementAlongRailNormal() + { + PathRoute route = new PathRoute + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.OverRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine, + RailNormalOffset = 1.5 + }; + + RailLocalFrame frame = new RailLocalFrame(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ); + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + Vector3.Zero, + frame, + 4.0); + + AssertVector(trackedCenter, 0.0, 0.0, 3.5); + } + + [TestMethod] + public void PreservedTrackedCenterOffset_ShouldBeMeasuredFromRailSemanticCenter() + { + Vector3 semanticTrackedCenter = new Vector3(10f, 20f, 30f); + Vector3 actualTrackedCenter = new Vector3(11.25f, 19.5f, 30.75f); + + Vector3 offset = RailPathPoseHelper.CalculatePreservedTrackedCenterOffset( + actualTrackedCenter, + semanticTrackedCenter); + + AssertVector(offset, 1.25, -0.5, 0.75); + } + + [TestMethod] + public void ResolvePreservedTrackedCenterPosition_ShouldReuseRailSemanticCenterPlusOffset() + { + Vector3 semanticTrackedCenter = new Vector3(3f, -2f, 8f); + Vector3 preservedOffset = new Vector3(0.2f, -0.4f, 0.6f); + + Vector3 preservedTrackedCenter = RailPathPoseHelper.ResolvePreservedTrackedCenterPosition( + semanticTrackedCenter, + preservedOffset); + + AssertVector(preservedTrackedCenter, 3.2, -2.4, 8.6); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-6); + Assert.AreEqual(y, actual.Y, 1e-6); + Assert.AreEqual(z, actual.Z, 1e-6); + } + } +} diff --git a/UnitTests/CoordinateSystem/CanonicalRailPoseBuilderTests.cs b/UnitTests/CoordinateSystem/CanonicalRailPoseBuilderTests.cs new file mode 100644 index 0000000..74b6405 --- /dev/null +++ b/UnitTests/CoordinateSystem/CanonicalRailPoseBuilderTests.cs @@ -0,0 +1,237 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class CanonicalRailPoseBuilderTests + { + [TestMethod] + public void StraightCanonicalPath_ZUpConvention_ShouldProduceIdentityLikeBasis() + { + bool ok = CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp), + out Quaternion rotation); + + Assert.IsTrue(ok); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 1, 0); + AssertColumn(linear, 2, 0, 0, 1); + } + + [TestMethod] + public void StraightCanonicalPath_YUpConvention_ShouldMapLocalYToCanonicalUp() + { + bool ok = CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp), + out Quaternion rotation); + + Assert.IsTrue(ok); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 0, 1); + AssertColumn(linear, 2, 0, -1, 0); + } + + [TestMethod] + public void SlopedPath_ShouldKeepForwardAlignedWithPathAndUpOrthogonalized() + { + bool ok = CanonicalRailPoseBuilder.TryCreateBasis( + new Vector3(0, 0, 0), + new Vector3(1, 1, 1), + new Vector3(2, 2, 2), + Vector3.UnitZ, + out Vector3 forward, + out Vector3 lateral, + out Vector3 up); + + Assert.IsTrue(ok); + Assert.AreEqual(1.0, forward.Length(), 1e-6); + Assert.AreEqual(1.0, lateral.Length(), 1e-6); + Assert.AreEqual(1.0, up.Length(), 1e-6); + Assert.AreEqual(0.0, Vector3.Dot(forward, up), 1e-6); + Assert.AreEqual(0.0, Vector3.Dot(forward, lateral), 1e-6); + Assert.AreEqual(0.0, Vector3.Dot(lateral, up), 1e-6); + } + + [TestMethod] + public void SlopedPath_ShouldCreateRailLocalFrameWithStableNormalCloseToCanonicalUp() + { + bool ok = CanonicalRailPoseBuilder.TryCreateLocalFrame( + new Vector3(0, 0, 0), + new Vector3(10, 0, 1), + new Vector3(20, 0, 2), + Vector3.UnitZ, + out RailLocalFrame frame); + + Assert.IsTrue(ok); + Assert.IsTrue(Vector3.Dot(frame.Forward, Vector3.UnitX) > 0.99f); + Assert.IsTrue(Vector3.Dot(frame.Normal, Vector3.UnitZ) > 0.99f); + Assert.AreEqual(0.0, Vector3.Dot(frame.Forward, frame.Normal), 1e-6); + Assert.AreEqual(0.0, Vector3.Dot(frame.Forward, frame.Lateral), 1e-6); + Assert.AreEqual(0.0, Vector3.Dot(frame.Lateral, frame.Normal), 1e-6); + } + + [TestMethod] + public void ZeroLocalUpRotation_ShouldMatchCurrentRailBaseline() + { + var convention = ModelAxisConvention.CreateRailAssetConvention(); + + Assert.IsTrue(CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + convention, + out Quaternion baselineRotation)); + + Assert.IsTrue(CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + convention, + 0.0, + out Quaternion correctedRotation)); + + AssertQuaternionEquivalent(baselineRotation, correctedRotation); + } + + [TestMethod] + public void LocalUpRotation_ShouldAlignCorrectedLocalForwardWithRailTangent() + { + var convention = ModelAxisConvention.CreateRailAssetConvention(); + + Assert.IsTrue(CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + convention, + 90.0, + out Quaternion rotation)); + + Quaternion localPreRotation = Quaternion.CreateFromAxisAngle( + Vector3.Normalize(convention.UpUnitVector), + (float)(90.0 * Math.PI / 180.0)); + + Vector3 correctedLocalForward = Vector3.Normalize(Vector3.Transform(convention.ForwardUnitVector, localPreRotation)); + Vector3 correctedLocalUp = Vector3.Normalize(Vector3.Transform(convention.UpUnitVector, localPreRotation)); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + + Vector3 mappedForward = TransformLocalVector(linear, correctedLocalForward); + Vector3 mappedUp = TransformLocalVector(linear, correctedLocalUp); + + AssertVector(mappedForward, 1, 0, 0); + AssertVector(mappedUp, 0, 0, 1); + } + + [TestMethod] + public void LocalEulerCorrection_ShouldAlignCorrectedLocalForwardWithRailTangent() + { + var convention = ModelAxisConvention.CreateRailAssetConvention(); + var correction = new LocalEulerRotationCorrection(0.0, 90.0, 0.0); + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(correction); + + Assert.IsTrue(CanonicalRailPoseBuilder.TryCreateQuaternion( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + convention, + null, + correctionQuaternion, + out Quaternion rotation)); + + LocalAxisPoseBuilder.ApplyLocalPreRotation(convention, correctionQuaternion, out var correctedLocalForward, out var correctedLocalUp); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + + Vector3 mappedForward = TransformLocalVector(linear, correctedLocalForward); + Vector3 mappedUp = TransformLocalVector(linear, correctedLocalUp); + + AssertVector(mappedForward, 1, 0, 0); + AssertVector(mappedUp, 0, 0, 1); + } + + [TestMethod] + public void PreferredNormal_ShouldOverrideCanonicalUpForRailFrame() + { + bool ok = CanonicalRailPoseBuilder.TryCreateLocalFrame( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0), + Vector3.UnitZ, + Vector3.UnitY, + out RailLocalFrame frame); + + Assert.IsTrue(ok); + AssertVector(frame.Forward, 1, 0, 0); + AssertVector(frame.Normal, 0, 1, 0); + AssertVector(frame.Lateral, 0, 0, -1); + } + + private static void AssertColumn(Matrix4x4 matrix, int column, double x, double y, double z) + { + switch (column) + { + case 0: + Assert.AreEqual(x, matrix.M11, 1e-6); + Assert.AreEqual(y, matrix.M21, 1e-6); + Assert.AreEqual(z, matrix.M31, 1e-6); + break; + case 1: + Assert.AreEqual(x, matrix.M12, 1e-6); + Assert.AreEqual(y, matrix.M22, 1e-6); + Assert.AreEqual(z, matrix.M32, 1e-6); + break; + case 2: + Assert.AreEqual(x, matrix.M13, 1e-6); + Assert.AreEqual(y, matrix.M23, 1e-6); + Assert.AreEqual(z, matrix.M33, 1e-6); + break; + default: + Assert.Fail("Only first 3 columns are valid."); + break; + } + } + + private static void AssertVector(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-6); + Assert.AreEqual(y, actual.Y, 1e-6); + Assert.AreEqual(z, actual.Z, 1e-6); + } + + private static void AssertQuaternionEquivalent(Quaternion expected, Quaternion actual) + { + double dot = Math.Abs( + expected.X * actual.X + + expected.Y * actual.Y + + expected.Z * actual.Z + + expected.W * actual.W); + + Assert.AreEqual(1.0, dot, 1e-6); + } + + private static Vector3 TransformLocalVector(Matrix4x4 linear, Vector3 localVector) + { + Vector3 world = new Vector3( + linear.M11 * localVector.X + linear.M12 * localVector.Y + linear.M13 * localVector.Z, + linear.M21 * localVector.X + linear.M22 * localVector.Y + linear.M23 * localVector.Z, + linear.M31 * localVector.X + linear.M32 * localVector.Y + linear.M33 * localVector.Z); + return Vector3.Normalize(world); + } + } +} diff --git a/UnitTests/CoordinateSystem/CanonicalTrackedPositionResolverTests.cs b/UnitTests/CoordinateSystem/CanonicalTrackedPositionResolverTests.cs new file mode 100644 index 0000000..674e192 --- /dev/null +++ b/UnitTests/CoordinateSystem/CanonicalTrackedPositionResolverTests.cs @@ -0,0 +1,65 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class CanonicalTrackedPositionResolverTests + { + [TestMethod] + public void GroundReference_ShouldOffsetHalfHeightAlongUp() + { + Vector3 result = CanonicalTrackedPositionResolver.ResolveGroundTrackedCenter( + new Vector3(10f, 20f, 30f), + Vector3.UnitZ, + 4.0); + + AssertVector(result, 10.0, 20.0, 32.0); + } + + [TestMethod] + public void TopSuspensionReference_ShouldOffsetNegativeHalfHeightAlongUp() + { + Vector3 result = CanonicalTrackedPositionResolver.ResolveTopSuspensionTrackedCenter( + new Vector3(10f, 20f, 30f), + Vector3.UnitZ, + 4.0); + + AssertVector(result, 10.0, 20.0, 28.0); + } + + [TestMethod] + public void ArbitraryNormalReference_ShouldOffsetAlongProvidedNormal() + { + Vector3 normal = Vector3.Normalize(new Vector3(0f, 1f, 1f)); + Vector3 result = CanonicalTrackedPositionResolver.ResolveCenterFromContactReference( + new Vector3(5f, 6f, 7f), + normal, + 4.0, + 0.0); + + Vector3 expected = new Vector3(5f, 6f, 7f) + normal * 2f; + AssertVector(result, expected.X, expected.Y, expected.Z); + } + + [TestMethod] + public void MidSurfaceFactor_ShouldProduceNoOffset() + { + Vector3 result = CanonicalTrackedPositionResolver.ResolveCenterFromContactReference( + new Vector3(1f, 2f, 3f), + Vector3.UnitZ, + 8.0, + 0.5); + + AssertVector(result, 1.0, 2.0, 3.0); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-6); + Assert.AreEqual(y, actual.Y, 1e-6); + Assert.AreEqual(z, actual.Z, 1e-6); + } + } +} diff --git a/UnitTests/CoordinateSystem/FragmentDefaultUpContextTests.cs b/UnitTests/CoordinateSystem/FragmentDefaultUpContextTests.cs new file mode 100644 index 0000000..17b135b --- /dev/null +++ b/UnitTests/CoordinateSystem/FragmentDefaultUpContextTests.cs @@ -0,0 +1,47 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class FragmentDefaultUpContextTests + { + [TestInitialize] + public void SetUp() + { + FragmentDefaultUpContext.ResetForTests(); + } + + [TestMethod] + public void GetOrCreateDocumentKey_ReusesFirstStableKey_WhenDocumentMetadataTemporarilyChanges() + { + const int runtimeDocumentId = 42; + + string initialKey = FragmentDefaultUpContext.GetOrCreateDocumentKey( + runtimeDocumentId, + @"C:\models\floor2.nwd", + "Floor2"); + + string transientKey = FragmentDefaultUpContext.GetOrCreateDocumentKey( + runtimeDocumentId, + string.Empty, + "无标题"); + + Assert.AreEqual(initialKey, transientKey); + Assert.AreEqual("file:C:\\models\\floor2.nwd", transientKey); + } + + [TestMethod] + public void GetOrCreateDocumentKey_UsesRuntimeScopedFallback_WhenFileNameIsUnavailable() + { + const int runtimeDocumentId = 7; + + string key = FragmentDefaultUpContext.GetOrCreateDocumentKey( + runtimeDocumentId, + string.Empty, + "无标题"); + + Assert.AreEqual("runtime:7|title:无标题", key); + } + } +} diff --git a/UnitTests/CoordinateSystem/FragmentRepresentativePoseHelperTests.cs b/UnitTests/CoordinateSystem/FragmentRepresentativePoseHelperTests.cs new file mode 100644 index 0000000..751eba4 --- /dev/null +++ b/UnitTests/CoordinateSystem/FragmentRepresentativePoseHelperTests.cs @@ -0,0 +1,172 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class FragmentRepresentativePoseHelperTests + { + [TestMethod] + public void ShouldAverageFragmentMatrices_AndIgnoreTranslation() + { + Quaternion expectedRotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (float)(Math.PI / 6.0))); + + double[] fragmentMatrix1 = CreateFragmentMatrix(expectedRotation, new Vector3(10.0f, 20.0f, 30.0f)); + double[] fragmentMatrix2 = CreateFragmentMatrix(expectedRotation, new Vector3(-3.0f, 8.0f, 1.0f)); + + bool ok = FragmentRepresentativePoseHelper.TryGetRepresentativeRotation( + new[] { fragmentMatrix1, fragmentMatrix2 }, + out Quaternion representativeRotation); + + Assert.IsTrue(ok); + AssertVector( + Vector3.Transform(Vector3.UnitX, representativeRotation), + Vector3.Transform(Vector3.UnitX, expectedRotation), + 1e-4); + AssertVector( + Vector3.Transform(Vector3.UnitY, representativeRotation), + Vector3.Transform(Vector3.UnitY, expectedRotation), + 1e-4); + AssertVector( + Vector3.Transform(Vector3.UnitZ, representativeRotation), + Vector3.Transform(Vector3.UnitZ, expectedRotation), + 1e-4); + } + + [TestMethod] + public void ShouldIgnoreQuaternionSignWhenAveragingFragments() + { + Quaternion expectedRotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)(Math.PI / 4.0))); + Quaternion oppositeSignRotation = new Quaternion( + -expectedRotation.X, + -expectedRotation.Y, + -expectedRotation.Z, + -expectedRotation.W); + + bool ok = FragmentRepresentativePoseHelper.TryGetRepresentativeRotation( + new[] { expectedRotation, oppositeSignRotation }, + out Quaternion representativeRotation); + + Assert.IsTrue(ok); + AssertVector( + Vector3.Transform(Vector3.UnitY, representativeRotation), + Vector3.Transform(Vector3.UnitY, expectedRotation), + 1e-4); + AssertVector( + Vector3.Transform(Vector3.UnitZ, representativeRotation), + Vector3.Transform(Vector3.UnitZ, expectedRotation), + 1e-4); + } + + [TestMethod] + public void ShouldFail_WhenNoFragmentRotationsAreAvailable() + { + bool ok = FragmentRepresentativePoseHelper.TryGetRepresentativeRotation( + Array.Empty(), + out Quaternion representativeRotation); + + Assert.IsFalse(ok); + Assert.AreEqual(Quaternion.Identity, representativeRotation); + } + + [TestMethod] + public void ShouldPreserveAxes_ForObservedYUpRealObjectFragmentBasis() + { + Vector3 axisX = new Vector3(1.0f, 0.0f, 0.0f); + Vector3 axisY = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 axisZ = new Vector3(0.0f, -1.0f, 0.0f); + + double[] fragmentMatrix = + { + axisX.X, axisX.Y, axisX.Z, 0.0, + axisY.X, axisY.Y, axisY.Z, 0.0, + axisZ.X, axisZ.Y, axisZ.Z, 0.0, + 0.0, 0.0, 0.0, 1.0 + }; + + bool ok = FragmentRepresentativePoseHelper.TryExtractRotationFromFragmentMatrix( + fragmentMatrix, + out Quaternion rotation); + + Assert.IsTrue(ok); + AssertVector(Vector3.Transform(Vector3.UnitX, rotation), axisX, 1e-4); + AssertVector(Vector3.Transform(Vector3.UnitY, rotation), axisY, 1e-4); + AssertVector(Vector3.Transform(Vector3.UnitZ, rotation), axisZ, 1e-4); + } + + [TestMethod] + public void RepresentativeFrame_ShouldExposeSameAxes_AsRepresentativeRotation() + { + Vector3 axisX = new Vector3(1.0f, 0.0f, 0.0f); + Vector3 axisY = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 axisZ = new Vector3(0.0f, -1.0f, 0.0f); + + double[] fragmentMatrix = + { + axisX.X, axisX.Y, axisX.Z, 0.0, + axisY.X, axisY.Y, axisY.Z, 0.0, + axisZ.X, axisZ.Y, axisZ.Z, 0.0, + 0.0, 0.0, 0.0, 1.0 + }; + + bool ok = FragmentRepresentativePoseHelper.TryGetRepresentativeFrame( + new[] { fragmentMatrix }, + out FragmentRepresentativePoseHelper.RepresentativeFrame frame); + + Assert.IsTrue(ok); + AssertVector(frame.AxisX, axisX, 1e-4); + AssertVector(frame.AxisY, axisY, 1e-4); + AssertVector(frame.AxisZ, axisZ, 1e-4); + AssertVector(Vector3.Transform(Vector3.UnitX, frame.Rotation), axisX, 1e-4); + AssertVector(Vector3.Transform(Vector3.UnitY, frame.Rotation), axisY, 1e-4); + AssertVector(Vector3.Transform(Vector3.UnitZ, frame.Rotation), axisZ, 1e-4); + } + + [TestMethod] + public void TryInterpretRepresentativeFrame_YUpHost_WithFragmentDefaultUpZ_ShouldMapZToHostY() + { + var raw = new FragmentRepresentativePoseHelper.RepresentativeFrame( + new Vector3(1.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, -1.0f), + new Vector3(0.0f, 1.0f, 0.0f), + Quaternion.Identity); + + bool ok = FragmentRepresentativePoseHelper.TryInterpretRepresentativeFrame( + raw, + "Z", + "Y", + out var interpreted); + + Assert.IsTrue(ok); + AssertVector(interpreted.AxisX, new Vector3(1.0f, 0.0f, 0.0f), 1e-4); + AssertVector(interpreted.AxisY, new Vector3(0.0f, 1.0f, 0.0f), 1e-4); + AssertVector(interpreted.AxisZ, new Vector3(0.0f, 0.0f, 1.0f), 1e-4); + } + + private static double[] CreateFragmentMatrix(Quaternion rotation, Vector3 translation) + { + Vector3 axisX = Vector3.Transform(Vector3.UnitX, rotation); + Vector3 axisY = Vector3.Transform(Vector3.UnitY, rotation); + Vector3 axisZ = Vector3.Transform(Vector3.UnitZ, rotation); + + return new[] + { + (double)axisX.X, (double)axisX.Y, (double)axisX.Z, 0.0, + (double)axisY.X, (double)axisY.Y, (double)axisY.Z, 0.0, + (double)axisZ.X, (double)axisZ.Y, (double)axisZ.Z, 0.0, + (double)translation.X, (double)translation.Y, (double)translation.Z, 1.0 + }; + } + + private static void AssertVector(Vector3 actual, Vector3 expected, double tolerance) + { + Assert.AreEqual(expected.X, actual.X, tolerance); + Assert.AreEqual(expected.Y, actual.Y, tolerance); + Assert.AreEqual(expected.Z, actual.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/GroundPassageSpaceOffsetTests.cs b/UnitTests/CoordinateSystem/GroundPassageSpaceOffsetTests.cs new file mode 100644 index 0000000..01ffc39 --- /dev/null +++ b/UnitTests/CoordinateSystem/GroundPassageSpaceOffsetTests.cs @@ -0,0 +1,17 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class GroundPassageSpaceOffsetTests + { + [TestMethod] + public void GroundPassageSpaceVerticalOffset_ShouldStayAtHalfHeight_WhenHeightAlreadyIncludesLift() + { + double verticalOffset = PathPointRenderPlugin.ResolveGroundPathObjectSpaceVerticalOffset(5.7); + + Assert.AreEqual(2.85, verticalOffset, 1e-9); + } + } +} diff --git a/UnitTests/CoordinateSystem/GroundPathObjectLiftOffsetTests.cs b/UnitTests/CoordinateSystem/GroundPathObjectLiftOffsetTests.cs new file mode 100644 index 0000000..04a2456 --- /dev/null +++ b/UnitTests/CoordinateSystem/GroundPathObjectLiftOffsetTests.cs @@ -0,0 +1,42 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core.Animation; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class GroundPathObjectLiftOffsetTests + { + [TestMethod] + public void YUp_GroundPathLift_ShouldOffsetAlongHostY() + { + Vector3 offset = PathAnimationManager.ResolveGroundPathObjectLiftOffsetVector(1.25, CoordinateSystemType.YUp); + + AssertVector(offset, 0.0, 1.25, 0.0); + } + + [TestMethod] + public void ZUp_GroundPathLift_ShouldOffsetAlongHostZ() + { + Vector3 offset = PathAnimationManager.ResolveGroundPathObjectLiftOffsetVector(1.25, CoordinateSystemType.ZUp); + + AssertVector(offset, 0.0, 0.0, 1.25); + } + + [TestMethod] + public void GroundPathLift_ShouldReturnZero_WhenLiftIsNotPositive() + { + Vector3 offset = PathAnimationManager.ResolveGroundPathObjectLiftOffsetVector(0.0, CoordinateSystemType.ZUp); + + AssertVector(offset, 0.0, 0.0, 0.0); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, actual.X, tolerance); + Assert.AreEqual(y, actual.Y, tolerance); + Assert.AreEqual(z, actual.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/HoistingCoordinateHelperTests.cs b/UnitTests/CoordinateSystem/HoistingCoordinateHelperTests.cs new file mode 100644 index 0000000..c660ac8 --- /dev/null +++ b/UnitTests/CoordinateSystem/HoistingCoordinateHelperTests.cs @@ -0,0 +1,146 @@ +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class HoistingCoordinateHelperTests + { + [TestMethod] + public void OffsetAlongUp_YUp_ShouldChangeHostYOnly() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var startPoint = new Vector3(1.0f, 2.0f, 3.0f); + + Vector3 liftedPoint = HoistingCoordinateHelper.OffsetAlongUp3(startPoint, 10.0, adapter); + + AssertPoint(liftedPoint, 1.0, 12.0, 3.0); + } + + [TestMethod] + public void ProjectToAerialElevation_YUp_ShouldKeepGroundHorizontalCoords() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var groundPoint = new Vector3(4.0f, 5.0f, 6.0f); + var aerialReferencePoint = new Vector3(1.0f, 12.0f, 3.0f); + + Vector3 projectedPoint = HoistingCoordinateHelper.ProjectToAerialElevation3(groundPoint, aerialReferencePoint, adapter); + + AssertPoint(projectedPoint, 4.0, 12.0, 6.0); + } + + [TestMethod] + public void RelativeHeight_YUp_ShouldUseHostYDifference() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var groundPoint = new Vector3(-10.0f, -30.0f, 8.0f); + var aerialPoint = new Vector3(-10.0f, 35.0f, 8.0f); + + double relativeHeight = HoistingCoordinateHelper.GetRelativeHeight3(groundPoint, aerialPoint, adapter); + + Assert.AreEqual(65.0, relativeHeight, 1e-9); + } + + [TestMethod] + public void SetElevation_YUp_ShouldRestoreClickedGroundElevationWithoutChangingHorizontalProjection() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var aerialPoint = new Vector3(-180.50f, 28.17f, 14.83f); + var clickedGroundPoint = new Vector3(-180.50f, -12.40f, 14.83f); + + Vector3 landingPoint = HoistingCoordinateHelper.SetElevation3( + aerialPoint, + HoistingCoordinateHelper.GetElevation(clickedGroundPoint, adapter), + adapter); + + AssertPoint(landingPoint, -180.50, -12.40, 14.83); + } + + [TestMethod] + public void CreateHorizontalTurnPoint_YUp_ShouldOperateInHostXZPlane() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var currentPoint = new Vector3(-168.70f, 28.17f, -30.01f); + var nextPoint = new Vector3(-180.50f, 28.17f, 14.83f); + + Vector3 turnPointKeepCurrentX = HoistingCoordinateHelper.CreateHorizontalTurnPoint3( + currentPoint, + nextPoint, + keepCurrentFirstHorizontalAxis: true, + adapter: adapter); + Vector3 turnPointKeepCurrentZ = HoistingCoordinateHelper.CreateHorizontalTurnPoint3( + currentPoint, + nextPoint, + keepCurrentFirstHorizontalAxis: false, + adapter: adapter); + + AssertPoint(turnPointKeepCurrentX, -168.70, 28.17, 14.83); + AssertPoint(turnPointKeepCurrentZ, -180.50, 28.17, -30.01); + } + + [TestMethod] + public void GetHorizontalDirection_YUp_ShouldProjectToHostXZPlane() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var fromPoint = new Vector3(-164.81f, 45.45f, 74.69f); + var toPoint = new Vector3(-176.90f, 45.45f, 40.48f); + + Vector3 horizontalDirection = HoistingCoordinateHelper.GetHorizontalDirection3(fromPoint, toPoint, adapter); + + Assert.AreEqual(0.0, horizontalDirection.Y, 1e-5); + Assert.AreEqual(1.0, horizontalDirection.Length(), 1e-5); + Assert.IsTrue(horizontalDirection.X < 0.0f); + Assert.IsTrue(horizontalDirection.Z < 0.0f); + } + + [TestMethod] + public void ExtendVerticalTransitionForObjectSpace_YUp_ShouldMoveLowerPointAlongHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var lowerPoint = new Vector3(-191.56f, 29.05f, -2.63f); + + Vector3 extendedPoint = HoistingCoordinateHelper.ExtendVerticalTransitionForObjectSpace3( + lowerPoint, + 4.0, + isLowerPoint: true, + adapter: adapter); + + AssertPoint(extendedPoint, -191.56, 25.05, -2.63); + } + + [TestMethod] + public void ExtendVerticalTransitionForObjectSpace_YUp_ShouldNotChangeHigherPoint() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var higherPoint = new Vector3(-191.56f, 45.45f, -2.63f); + + Vector3 unchangedPoint = HoistingCoordinateHelper.ExtendVerticalTransitionForObjectSpace3( + higherPoint, + 4.0, + isLowerPoint: false, + adapter: adapter); + + AssertPoint(unchangedPoint, -191.56, 45.45, -2.63); + } + + [TestMethod] + public void OffsetAlongUp_ZUp_ShouldChangeHostZOnly() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var startPoint = new Vector3(1.0f, 2.0f, 3.0f); + + Vector3 liftedPoint = HoistingCoordinateHelper.OffsetAlongUp3(startPoint, 10.0, adapter); + + AssertPoint(liftedPoint, 1.0, 2.0, 13.0); + } + + private static void AssertPoint(Vector3 actual, double x, double y, double z) + { + Assert.AreEqual(x, actual.X, 1e-5); + Assert.AreEqual(y, actual.Y, 1e-5); + Assert.AreEqual(z, actual.Z, 1e-5); + } + } +} diff --git a/UnitTests/CoordinateSystem/HoistingRealObjectPoseHelperTests.cs b/UnitTests/CoordinateSystem/HoistingRealObjectPoseHelperTests.cs new file mode 100644 index 0000000..9280f70 --- /dev/null +++ b/UnitTests/CoordinateSystem/HoistingRealObjectPoseHelperTests.cs @@ -0,0 +1,116 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class HoistingRealObjectPoseHelperTests + { + [TestMethod] + public void ShouldKeepActualUpAxis_WhenAligningHoistingForward() + { + Quaternion actualRotation = Quaternion.Normalize(new Quaternion(0.70710677f, 0.0f, 0.0f, 0.70710677f)); + Vector3 hostUp = Vector3.UnitY; + Vector3 targetForward = -Vector3.UnitX; + + bool ok = HoistingRealObjectPoseHelper.TryCreateRotationFromActualPose( + actualRotation, + targetForward, + hostUp, + out Quaternion resultRotation, + out LocalAxisDirection selectedDirection, + out Vector3 selectedAxisLocal, + out Vector3 projectedForward); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, selectedDirection); + AssertVector(selectedAxisLocal, 1.0, 0.0, 0.0); + AssertVector(projectedForward, 1.0, 0.0, 0.0); + + Vector3 transformedUp = Vector3.Normalize(Vector3.Transform(-Vector3.UnitZ, resultRotation)); + AssertVector(transformedUp, 0.0, 1.0, 0.0, 1e-4); + + Vector3 transformedForward = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, resultRotation)); + AssertVector(transformedForward, 1.0, 0.0, 0.0, 1e-4); + } + + [TestMethod] + public void ShouldChooseHorizontalAxisFromActualPose_ForHoisting() + { + Quaternion actualRotation = Quaternion.Normalize(new Quaternion(0.70710677f, 0.0f, 0.0f, 0.70710677f)); + Vector3 hostUp = Vector3.UnitY; + Vector3 targetForward = Vector3.UnitX; + + bool ok = HoistingRealObjectPoseHelper.TryCreateRotationFromActualPose( + actualRotation, + targetForward, + hostUp, + out Quaternion resultRotation, + out LocalAxisDirection selectedDirection, + out Vector3 selectedAxisLocal, + out Vector3 projectedForward); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, selectedDirection); + AssertVector(selectedAxisLocal, 1.0, 0.0, 0.0); + AssertVector(projectedForward, 1.0, 0.0, 0.0); + + Vector3 transformedUp = Vector3.Normalize(Vector3.Transform(-Vector3.UnitZ, resultRotation)); + AssertVector(transformedUp, 0.0, 1.0, 0.0, 1e-4); + + Vector3 transformedForward = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, resultRotation)); + AssertVector(transformedForward, 1.0, 0.0, 0.0, 1e-4); + } + + [TestMethod] + public void CreateRotationFromPlanarBasePose_ShouldReturnBaseRotation_WhenYawUnchanged() + { + Quaternion baseRotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.31f) * + Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.42f)); + + Quaternion resultRotation = HoistingRealObjectPoseHelper.CreateRotationFromPlanarBasePose( + baseRotation, + baseYawRadians: 1.25, + targetYawRadians: 1.25, + hostUp: Vector3.UnitY); + + AssertQuaternionEquivalent(resultRotation, baseRotation); + } + + [TestMethod] + public void CreateRotationFromPlanarBasePose_ShouldPreserveTiltAndApplyDeltaYaw() + { + Quaternion baseRotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.20f) * + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.35f) * + Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.40f)); + + Quaternion resultRotation = HoistingRealObjectPoseHelper.CreateRotationFromPlanarBasePose( + baseRotation, + baseYawRadians: 0.0, + targetYawRadians: System.Math.PI / 2.0, + hostUp: Vector3.UnitY); + + Quaternion expectedRotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)(System.Math.PI / 2.0)) * + baseRotation); + + AssertQuaternionEquivalent(resultRotation, expectedRotation); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, actual.X, tolerance); + Assert.AreEqual(y, actual.Y, tolerance); + Assert.AreEqual(z, actual.Z, tolerance); + } + + private static void AssertQuaternionEquivalent(Quaternion actual, Quaternion expected, double tolerance = 1e-6) + { + float dot = Quaternion.Dot(Quaternion.Normalize(actual), Quaternion.Normalize(expected)); + Assert.AreEqual(1.0, System.Math.Abs(dot), tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/HostCoordinateAdapterTests.cs b/UnitTests/CoordinateSystem/HostCoordinateAdapterTests.cs new file mode 100644 index 0000000..a5d7931 --- /dev/null +++ b/UnitTests/CoordinateSystem/HostCoordinateAdapterTests.cs @@ -0,0 +1,443 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; +using System; +using Autodesk.Navisworks.Api; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class HostCoordinateAdapterTests + { + [TestMethod] + public void ZUp_PointRoundTrip_ShouldRemainUnchanged() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var hostPoint = new Vector3(1.5f, -2.5f, 3.5f); + + Vector3 canonicalPoint = adapter.ToCanonicalPoint3(hostPoint); + Vector3 roundTripPoint = adapter.FromCanonicalPoint3(canonicalPoint); + + AssertPoint(roundTripPoint, 1.5, -2.5, 3.5); + } + + [TestMethod] + public void YUp_PointAndVectorRoundTrip_ShouldMatchExpectedMapping() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var hostPoint = new Vector3(1.0f, 2.0f, 3.0f); + var hostVector = new Vector3(4.0f, 5.0f, 6.0f); + + Vector3 canonicalPoint = adapter.ToCanonicalPoint3(hostPoint); + Vector3 canonicalVector = adapter.ToCanonicalVector3(hostVector); + + AssertPoint(canonicalPoint, 1.0, -3.0, 2.0); + AssertVector(canonicalVector, 4.0, -6.0, 5.0); + + Vector3 restoredPoint = adapter.FromCanonicalPoint3(canonicalPoint); + Vector3 restoredVector = adapter.FromCanonicalVector3(canonicalVector); + + AssertPoint(restoredPoint, 1.0, 2.0, 3.0); + AssertVector(restoredVector, 4.0, 5.0, 6.0); + } + + [TestMethod] + public void YUp_BoundsRoundTrip_ShouldPreserveBounds() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var hostBounds = new CanonicalBounds3(new Vector3(-2.0f, 1.0f, 3.0f), new Vector3(5.0f, 7.0f, 11.0f)); + + CanonicalBounds3 canonicalBounds = adapter.ToCanonicalBounds3(hostBounds); + CanonicalBounds3 restoredBounds = adapter.FromCanonicalBounds3(canonicalBounds); + + AssertPoint(restoredBounds.Min, -2.0, 1.0, 3.0); + AssertPoint(restoredBounds.Max, 5.0, 7.0, 11.0); + } + + [TestMethod] + public void YUp_CanonicalRotation_ShouldConvertToHostYUpBasis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + Quaternion canonicalRotation = convention.CreateQuaternion(new Vector3(1, 0, 0), Vector3.UnitZ); + + Matrix4x4 hostLinear = adapter.FromCanonicalLinearTransform(Matrix4x4.CreateFromQuaternion(canonicalRotation)); + + Assert.AreEqual(1.0, hostLinear.M11, 1e-6); + Assert.AreEqual(0.0, hostLinear.M21, 1e-6); + Assert.AreEqual(0.0, hostLinear.M31, 1e-6); + + Assert.AreEqual(0.0, hostLinear.M12, 1e-6); + Assert.AreEqual(0.0, hostLinear.M22, 1e-6); + Assert.AreEqual(-1.0, hostLinear.M32, 1e-6); + + Assert.AreEqual(0.0, hostLinear.M13, 1e-6); + Assert.AreEqual(1.0, hostLinear.M23, 1e-6); + Assert.AreEqual(0.0, hostLinear.M33, 1e-6); + } + + [TestMethod] + public void YUp_CanonicalRotation_WithPlanarForward_ShouldProduceExpectedHostAxes() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + + Vector3 hostForward = Vector3.Normalize(new Vector3(-0.997320f, 0.0f, 0.073164f)); + Vector3 canonicalForward = adapter.ToCanonicalVector3(hostForward); + + bool ok = CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + convention, + out Quaternion canonicalRotation); + + Assert.IsTrue(ok); + + Matrix4x4 canonicalLinear = Matrix4x4.CreateFromQuaternion(canonicalRotation); + Matrix4x4 hostLinear = adapter.FromCanonicalLinearTransform(canonicalLinear); + Quaternion hostQuaternion = Quaternion.CreateFromRotationMatrix(hostLinear); + Matrix4x4 reconstructedHostLinear = Matrix4x4.CreateFromQuaternion(hostQuaternion); + + Assert.AreEqual(hostForward.X, reconstructedHostLinear.M11, 1e-3); + Assert.AreEqual(hostForward.Y, reconstructedHostLinear.M21, 1e-3); + Assert.AreEqual(hostForward.Z, reconstructedHostLinear.M31, 1e-3); + + Assert.AreEqual(0.0, reconstructedHostLinear.M13, 1e-3); + Assert.AreEqual(1.0, reconstructedHostLinear.M23, 1e-3); + Assert.AreEqual(0.0, reconstructedHostLinear.M33, 1e-3); + } + + [TestMethod] + public void YUp_HostLinear_FromActualGroundPathPoints_ShouldMatchExpectedHostAxes() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + + Vector3 previousPoint = new Vector3(-181.82637032765f, 14.8333330154419f, 3.38710203888167f); + Vector3 currentPoint = new Vector3(-181.82637032765f, 14.8333330154419f, 3.38710203888167f); + Vector3 nextPoint = new Vector3(-202.828908853644f, 16.3772966612769f, 14.304117291825f); + + Vector3 canonicalPrevious = adapter.ToCanonicalPoint3(previousPoint); + Vector3 canonicalCurrent = adapter.ToCanonicalPoint3(currentPoint); + Vector3 canonicalNext = adapter.ToCanonicalPoint3(nextPoint); + + Vector3 canonicalForward = canonicalNext - canonicalPrevious; + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + convention, + out Quaternion canonicalRotation)); + + Matrix4x4 canonicalLinear = Matrix4x4.CreateFromQuaternion(canonicalRotation); + Matrix4x4 hostLinear = adapter.FromCanonicalLinearTransform(canonicalLinear); + + Assert.AreEqual(-0.8873, hostLinear.M11, 1e-3); + Assert.AreEqual(0.0000, hostLinear.M21, 1e-3); + Assert.AreEqual(0.4612, hostLinear.M31, 1e-3); + + Assert.AreEqual(0.4612, hostLinear.M12, 1e-3); + Assert.AreEqual(0.0000, hostLinear.M22, 1e-3); + Assert.AreEqual(0.8873, hostLinear.M32, 1e-3); + + Assert.AreEqual(0.0000, hostLinear.M13, 1e-3); + Assert.AreEqual(1.0000, hostLinear.M23, 1e-3); + Assert.AreEqual(0.0000, hostLinear.M33, 1e-3); + } + + [TestMethod] + public void YUp_HostRotationCorrection_ShouldMapHostYAxisToCanonicalUp() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var correction = new LocalEulerRotationCorrection(0.0, 90.0, 0.0); + + Quaternion canonicalCorrection = adapter.CreateCanonicalRotationCorrection(correction); + Vector3 rotatedForward = Vector3.Transform(Vector3.UnitX, canonicalCorrection); + + Assert.AreEqual(0.0, rotatedForward.X, 1e-6); + Assert.AreEqual(1.0, rotatedForward.Y, 1e-6); + Assert.AreEqual(0.0, rotatedForward.Z, 1e-6); + } + + [TestMethod] + public void YUp_HostRotationCorrection_ShouldKeepHostYAxisInvariantInHostSpace() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + Vector3 rotatedUp = Vector3.Transform(Vector3.UnitY, hostCorrection); + AssertVector(rotatedUp, 0.0, 1.0, 0.0); + } + + [TestMethod] + public void YUp_HostRotationCorrection_X90_ShouldRotateHostZIntoHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(90.0, 0.0, 0.0)); + + Vector3 rotatedHostZ = Vector3.Transform(Vector3.UnitZ, hostCorrection); + AssertVector(rotatedHostZ, 0.0, -1.0, 0.0); + } + + [TestMethod] + public void YUp_HostRotationCorrection_Z90_ShouldRotateHostXIntoNegativeHostY() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + Vector3 rotatedHostX = Vector3.Transform(Vector3.UnitX, hostCorrection); + AssertVector(rotatedHostX, 0.0, 1.0, 0.0); + } + + [TestMethod] + public void ComposeHostQuaternion_ShouldApplyHostCorrectionAfterBaseline() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var baseline = Quaternion.Identity; + + Quaternion composed = adapter.ComposeHostQuaternion( + baseline, + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + Assert.AreEqual(0.0, linear.M11, 1e-6); + Assert.AreEqual(0.0, linear.M21, 1e-6); + Assert.AreEqual(-1.0, linear.M31, 1e-6); + + Assert.AreEqual(0.0, linear.M12, 1e-6); + Assert.AreEqual(1.0, linear.M22, 1e-6); + Assert.AreEqual(0.0, linear.M32, 1e-6); + } + + [TestMethod] + public void ZUp_HostRotationCorrection_ShouldKeepHostZAxisInvariantInHostSpace() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + Vector3 rotatedUp = Vector3.Transform(Vector3.UnitZ, hostCorrection); + AssertVector(rotatedUp, 0.0, 0.0, 1.0); + } + + [TestMethod] + public void ZUp_ComposeHostQuaternion_ShouldApplyHostZCorrectionAfterBaseline() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var baseline = Quaternion.Identity; + + Quaternion composed = adapter.ComposeHostQuaternion( + baseline, + new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + Assert.AreEqual(0.0, linear.M11, 1e-6); + Assert.AreEqual(1.0, linear.M21, 1e-6); + Assert.AreEqual(0.0, linear.M31, 1e-6); + + Assert.AreEqual(-1.0, linear.M12, 1e-6); + Assert.AreEqual(0.0, linear.M22, 1e-6); + Assert.AreEqual(0.0, linear.M32, 1e-6); + + Assert.AreEqual(0.0, linear.M13, 1e-6); + Assert.AreEqual(0.0, linear.M23, 1e-6); + Assert.AreEqual(1.0, linear.M33, 1e-6); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForGroundBaseline_ShouldRotateAxesAroundHostXAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.8987f, 0.0000f, -0.4386f); + Vector3 baselineY = new Vector3(0.0000f, 1.0000f, 0.0000f); + Vector3 baselineZ = new Vector3(0.4386f, 0.0000f, -0.8987f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(90.0, 0.0, 0.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForGroundBaseline_ShouldRotateAxesAroundHostYAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.8987f, 0.0000f, -0.4386f); + Vector3 baselineY = new Vector3(0.0000f, 1.0000f, 0.0000f); + Vector3 baselineZ = new Vector3(0.4386f, 0.0000f, -0.8987f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(0.0, 90.0, 0.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForGroundBaseline_ShouldRotateAxesAroundHostZAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.8987f, 0.0000f, -0.4386f); + Vector3 baselineY = new Vector3(0.0000f, 1.0000f, 0.0000f); + Vector3 baselineZ = new Vector3(0.4386f, 0.0000f, -0.8987f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(0.0, 0.0, 90.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForRailBaseline_ShouldRotateAxesAroundHostXAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.9900f, 0.1403f, -0.0163f); + Vector3 baselineY = new Vector3(0.1403f, 0.9901f, 0.0000f); + Vector3 baselineZ = new Vector3(0.0161f, -0.0023f, -0.9999f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(90.0, 0.0, 0.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForRailBaseline_ShouldRotateAxesAroundHostYAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.9900f, 0.1403f, -0.0163f); + Vector3 baselineY = new Vector3(0.1403f, 0.9901f, 0.0000f); + Vector3 baselineZ = new Vector3(0.0161f, -0.0023f, -0.9999f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(0.0, 90.0, 0.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void YUp_ComposeHostQuaternion_ForRailBaseline_ShouldRotateAxesAroundHostZAxis() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 baselineX = new Vector3(-0.9900f, 0.1403f, -0.0163f); + Vector3 baselineY = new Vector3(0.1403f, 0.9901f, 0.0000f); + Vector3 baselineZ = new Vector3(0.0161f, -0.0023f, -0.9999f); + Quaternion baseline = CreateQuaternionFromAxes(baselineX, baselineY, baselineZ); + + var correction = new LocalEulerRotationCorrection(0.0, 0.0, 90.0); + Quaternion composed = adapter.ComposeHostQuaternion(baseline, correction); + Quaternion hostCorrection = adapter.CreateHostRotationCorrection(correction); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(composed); + + AssertAxis(linear, 1, Vector3.Transform(baselineX, hostCorrection)); + AssertAxis(linear, 2, Vector3.Transform(baselineY, hostCorrection)); + AssertAxis(linear, 3, Vector3.Transform(baselineZ, hostCorrection)); + } + + [TestMethod] + public void RemapHostSemanticCorrectionToLocalAxes_ShouldPermuteAnglesFromSemanticAxes() + { + LocalEulerRotationCorrection mapped = HostCoordinateAdapter.RemapHostSemanticCorrectionToLocalAxes( + new LocalEulerRotationCorrection(10.0, 20.0, 30.0), + LocalAxisDirection.PositiveY, + LocalAxisDirection.PositiveZ, + LocalAxisDirection.PositiveX); + + Assert.AreEqual(30.0, mapped.XDegrees, 1e-9); + Assert.AreEqual(10.0, mapped.YDegrees, 1e-9); + Assert.AreEqual(20.0, mapped.ZDegrees, 1e-9); + } + + [TestMethod] + public void RemapHostSemanticCorrectionToLocalAxes_ShouldApplySignForNegativeMappedAxes() + { + LocalEulerRotationCorrection mapped = HostCoordinateAdapter.RemapHostSemanticCorrectionToLocalAxes( + new LocalEulerRotationCorrection(10.0, 20.0, 30.0), + LocalAxisDirection.NegativeY, + LocalAxisDirection.PositiveZ, + LocalAxisDirection.NegativeX); + + Assert.AreEqual(-30.0, mapped.XDegrees, 1e-9); + Assert.AreEqual(-10.0, mapped.YDegrees, 1e-9); + Assert.AreEqual(20.0, mapped.ZDegrees, 1e-9); + } + + private static void AssertPoint(Vector3 point, double x, double y, double z) + { + Assert.AreEqual(x, point.X, 1e-9); + Assert.AreEqual(y, point.Y, 1e-9); + Assert.AreEqual(z, point.Z, 1e-9); + } + + private static void AssertVector(Vector3 vector, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, vector.X, tolerance); + Assert.AreEqual(y, vector.Y, tolerance); + Assert.AreEqual(z, vector.Z, tolerance); + } + + private static Quaternion CreateQuaternionFromAxes(Vector3 xAxis, Vector3 yAxis, Vector3 zAxis) + { + Matrix4x4 basis = new Matrix4x4( + xAxis.X, yAxis.X, zAxis.X, 0f, + xAxis.Y, yAxis.Y, zAxis.Y, 0f, + xAxis.Z, yAxis.Z, zAxis.Z, 0f, + 0f, 0f, 0f, 1f); + return Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(basis)); + } + + private static void AssertAxis(Matrix4x4 linear, int axisIndex, Vector3 expectedAxis, double tolerance = 1e-4) + { + Vector3 normalizedExpected = Vector3.Normalize(expectedAxis); + switch (axisIndex) + { + case 1: + Assert.AreEqual(normalizedExpected.X, linear.M11, tolerance); + Assert.AreEqual(normalizedExpected.Y, linear.M21, tolerance); + Assert.AreEqual(normalizedExpected.Z, linear.M31, tolerance); + break; + case 2: + Assert.AreEqual(normalizedExpected.X, linear.M12, tolerance); + Assert.AreEqual(normalizedExpected.Y, linear.M22, tolerance); + Assert.AreEqual(normalizedExpected.Z, linear.M32, tolerance); + break; + case 3: + Assert.AreEqual(normalizedExpected.X, linear.M13, tolerance); + Assert.AreEqual(normalizedExpected.Y, linear.M23, tolerance); + Assert.AreEqual(normalizedExpected.Z, linear.M33, tolerance); + break; + default: + Assert.Fail($"未知轴索引: {axisIndex}"); + break; + } + } + } +} diff --git a/UnitTests/CoordinateSystem/ModelAxisConventionTests.cs b/UnitTests/CoordinateSystem/ModelAxisConventionTests.cs new file mode 100644 index 0000000..8526f21 --- /dev/null +++ b/UnitTests/CoordinateSystem/ModelAxisConventionTests.cs @@ -0,0 +1,121 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class ModelAxisConventionTests + { + [TestMethod] + public void DefaultForYUp_ShouldUseXForwardAndYUp() + { + ModelAxisConvention convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, convention.UpAxis); + } + + [TestMethod] + public void DefaultForZUp_ShouldUseXForwardAndZUp() + { + ModelAxisConvention convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, convention.UpAxis); + } + + [TestMethod] + public void YUpConvention_CreateLinearTransform_ShouldMapLocalYToWorldUp() + { + var convention = new ModelAxisConvention(LocalAxisDirection.PositiveX, LocalAxisDirection.PositiveY); + Matrix4x4 linear = convention.CreateLinearTransform3(new Vector3(1, 0, 0), new Vector3(0, 0, 1)); + + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 0, 1); + AssertColumn(linear, 2, 0, -1, 0); + } + + [TestMethod] + public void ZUpConvention_CreateLinearTransform_ShouldMapLocalZToWorldUp() + { + var convention = new ModelAxisConvention(LocalAxisDirection.PositiveX, LocalAxisDirection.PositiveZ); + Matrix4x4 linear = convention.CreateLinearTransform3(new Vector3(1, 0, 0), new Vector3(0, 0, 1)); + + AssertColumn(linear, 0, 1, 0, 0); + AssertColumn(linear, 1, 0, 1, 0); + AssertColumn(linear, 2, 0, 0, 1); + } + + [TestMethod] + public void YUpConvention_CreateRotation_ShouldAlignLocalAxesToRequestedWorldAxes() + { + var convention = new ModelAxisConvention(LocalAxisDirection.PositiveX, LocalAxisDirection.PositiveY); + Quaternion rotation = convention.CreateQuaternion(new Vector3(0, 1, 0), new Vector3(0, 0, 1)); + Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation); + + AssertColumn(linear, 0, 0, 1, 0); + AssertColumn(linear, 1, 0, 0, 1); + AssertColumn(linear, 2, 1, 0, 0); + } + + [TestMethod] + public void ReferenceRodAssetConvention_ShouldUseXForwardAndZUp() + { + ModelAxisConvention convention = ModelAxisConvention.CreateReferenceRodAssetConvention(); + + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, convention.UpAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, convention.SideAxis); + } + + [TestMethod] + public void VirtualObjectAssetConvention_CreateScaleVector_ShouldMapLengthWidthHeightToLocalAxes() + { + ModelAxisConvention convention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); + + var scale = convention.CreateScaleVector3(12.0, 5.0, 7.0); + + Assert.AreEqual(12.0, scale.X, 1e-6); + Assert.AreEqual(5.0, scale.Y, 1e-6); + Assert.AreEqual(7.0, scale.Z, 1e-6); + } + + [TestMethod] + public void YUpConvention_CreateScaleVector_ShouldMapUpSizeToLocalY() + { + ModelAxisConvention convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + + var scale = convention.CreateScaleVector3(12.0, 5.0, 7.0); + + Assert.AreEqual(12.0, scale.X, 1e-6); + Assert.AreEqual(7.0, scale.Y, 1e-6); + Assert.AreEqual(5.0, scale.Z, 1e-6); + } + + private static void AssertColumn(Matrix4x4 matrix, int column, double x, double y, double z) + { + switch (column) + { + case 0: + Assert.AreEqual(x, matrix.M11, 1e-6); + Assert.AreEqual(y, matrix.M21, 1e-6); + Assert.AreEqual(z, matrix.M31, 1e-6); + break; + case 1: + Assert.AreEqual(x, matrix.M12, 1e-6); + Assert.AreEqual(y, matrix.M22, 1e-6); + Assert.AreEqual(z, matrix.M32, 1e-6); + break; + case 2: + Assert.AreEqual(x, matrix.M13, 1e-6); + Assert.AreEqual(y, matrix.M23, 1e-6); + Assert.AreEqual(z, matrix.M33, 1e-6); + break; + default: + Assert.Fail("Only first 3 columns are valid."); + break; + } + } + } +} diff --git a/UnitTests/CoordinateSystem/ObjectSpaceOrientationHelperTests.cs b/UnitTests/CoordinateSystem/ObjectSpaceOrientationHelperTests.cs new file mode 100644 index 0000000..f60f624 --- /dev/null +++ b/UnitTests/CoordinateSystem/ObjectSpaceOrientationHelperTests.cs @@ -0,0 +1,56 @@ +using System.Numerics; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class ObjectSpaceOrientationHelperTests + { + [TestMethod] + public void CalculateAxes_ShouldHonorProvidedUpReference() + { + var segmentDirection = new Vector3(10f, 0f, 0f); + var upReference = new Vector3(0f, 1f, 0f); + + var (_, up) = ObjectSpaceOrientationHelper.CalculateAxes(segmentDirection, upReference); + + Assert.AreEqual(0.0, up.X, 1e-6); + Assert.AreEqual(1.0, System.Math.Abs(up.Y), 1e-6); + Assert.AreEqual(0.0, up.Z, 1e-6); + } + + [TestMethod] + public void CalculateAxes_ForVerticalSegment_ShouldUseHorizontalDirectionWithProvidedUpReference() + { + var segmentDirection = new Vector3(0f, 10f, 0f); + var upReference = new Vector3(0f, 1f, 0f); + var horizontalDirection = new Vector3(1f, 0f, 0f); + + var (right, up) = ObjectSpaceOrientationHelper.CalculateAxes(segmentDirection, upReference, horizontalDirection); + + Assert.AreEqual(0.0, right.X, 1e-6); + Assert.AreEqual(0.0, right.Y, 1e-6); + Assert.AreEqual(1.0, right.Z, 1e-6); + Assert.AreEqual(1.0, up.X, 1e-6); + Assert.AreEqual(0.0, up.Y, 1e-6); + Assert.AreEqual(0.0, up.Z, 1e-6); + } + + [TestMethod] + public void TryCalculateAxes_ShouldReturnFalse_ForZeroLengthSegment() + { + var segmentDirection = Vector3.Zero; + var upReference = new Vector3(0f, 1f, 0f); + + bool ok = ObjectSpaceOrientationHelper.TryCalculateAxes( + segmentDirection, + upReference, + out var axes); + + Assert.IsFalse(ok); + Assert.AreEqual(Vector3.Zero, axes.right); + Assert.AreEqual(Vector3.Zero, axes.up); + } + } +} diff --git a/UnitTests/CoordinateSystem/ObjectStartPlacementRequestTests.cs b/UnitTests/CoordinateSystem/ObjectStartPlacementRequestTests.cs new file mode 100644 index 0000000..8ec72ff --- /dev/null +++ b/UnitTests/CoordinateSystem/ObjectStartPlacementRequestTests.cs @@ -0,0 +1,44 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class ObjectStartPlacementRequestTests + { + [TestMethod] + public void TranslationOnly_ShouldPreserveInitialPoseAndClearRotationCorrection() + { + var request = ObjectStartPlacementRequest.TranslationOnly; + + Assert.AreEqual(ObjectStartPlacementMode.PreserveInitialPose, request.PlacementMode); + Assert.IsTrue(request.PreserveInitialPose); + Assert.AreEqual(LocalEulerRotationCorrection.Zero, request.RotationCorrection); + Assert.AreEqual(0.0, request.VerticalLiftInMeters, 1e-9); + } + + [TestMethod] + public void RotationCorrectionRequest_ShouldKeepCorrectionAndAlignToPathPose() + { + var correction = new LocalEulerRotationCorrection(15.0, 30.0, 45.0); + + var request = ObjectStartPlacementRequest.CreateRotationCorrection(correction, 0.35); + + Assert.AreEqual(ObjectStartPlacementMode.AlignToPathPose, request.PlacementMode); + Assert.IsFalse(request.PreserveInitialPose); + Assert.AreEqual(correction, request.RotationCorrection); + Assert.AreEqual(0.35, request.VerticalLiftInMeters, 1e-9); + } + + [TestMethod] + public void TranslationOnlyRequest_ShouldKeepVerticalLift() + { + var request = ObjectStartPlacementRequest.CreateTranslationOnly(0.28); + + Assert.AreEqual(ObjectStartPlacementMode.PreserveInitialPose, request.PlacementMode); + Assert.IsTrue(request.PreserveInitialPose); + Assert.AreEqual(LocalEulerRotationCorrection.Zero, request.RotationCorrection); + Assert.AreEqual(0.28, request.VerticalLiftInMeters, 1e-9); + } + } +} diff --git a/UnitTests/CoordinateSystem/PathPointVisualizationTests.cs b/UnitTests/CoordinateSystem/PathPointVisualizationTests.cs new file mode 100644 index 0000000..e05657f --- /dev/null +++ b/UnitTests/CoordinateSystem/PathPointVisualizationTests.cs @@ -0,0 +1,32 @@ +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class PathPointVisualizationTests + { + [TestMethod] + public void ResolveVisualizationRadius_WhenVisualizationDiameterIsUnset_ShouldUseDefaultRadius() + { + var point = new PathPoint(new Point3D(1.0, 2.0, 3.0), "测试点", PathPointType.WayPoint); + + double radius = point.ResolveVisualizationRadius(0.25); + + Assert.AreEqual(0.25, radius, 1e-9); + } + + [TestMethod] + public void ResolveVisualizationRadius_WhenVisualizationDiameterIsSpecified_ShouldUseHalfDiameter() + { + var point = new PathPoint(new Point3D(1.0, 2.0, 3.0), "测试点", PathPointType.WayPoint) + { + VisualizationDiameter = 0.10 + }; + + double radius = point.ResolveVisualizationRadius(0.25); + + Assert.AreEqual(0.05, radius, 1e-9); + } + } +} diff --git a/UnitTests/CoordinateSystem/PathTargetFrameResolverTests.cs b/UnitTests/CoordinateSystem/PathTargetFrameResolverTests.cs new file mode 100644 index 0000000..8fe7ea9 --- /dev/null +++ b/UnitTests/CoordinateSystem/PathTargetFrameResolverTests.cs @@ -0,0 +1,123 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Collections.Generic; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class PathTargetFrameResolverTests + { + [TestMethod] + public void YUp_PlanarFrame_ShouldUseHostYAsUp() + { + bool ok = PathTargetFrameResolver.TryCreatePlanarHostFrame( + new Vector3(2.0f, 0.0f, 1.0f), + CoordinateSystemType.YUp, + out PathTargetFrame frame); + + Assert.IsTrue(ok); + AssertVector(frame.Up, 0.0, 1.0, 0.0); + Assert.AreEqual(0.0, Vector3.Dot(frame.Forward, frame.Up), 1e-6); + Assert.AreEqual(1.0, frame.Side.Length(), 1e-6); + } + + [TestMethod] + public void ZUp_PlanarFrame_ShouldUseHostZAsUp() + { + bool ok = PathTargetFrameResolver.TryCreatePlanarHostFrame( + new Vector3(2.0f, 1.0f, 0.0f), + CoordinateSystemType.ZUp, + out PathTargetFrame frame); + + Assert.IsTrue(ok); + AssertVector(frame.Up, 0.0, 0.0, 1.0); + Assert.AreEqual(0.0, Vector3.Dot(frame.Forward, frame.Up), 1e-6); + Assert.AreEqual(1.0, frame.Side.Length(), 1e-6); + } + + [TestMethod] + public void Hoisting_StartForward_ShouldUseHorizontalSegmentInsteadOfInitialLift() + { + var pathPoints = new List + { + new Vector3(-105.992f, -28.615f, 77.043f), + new Vector3(-105.992f, 16.230f, 77.043f), + new Vector3(-229.870f, 16.230f, -32.982f) + }; + + bool ok = PathTargetFrameResolver.TryResolvePlanarStartHostForward( + NavisworksTransport.PathType.Hoisting, + pathPoints, + out Vector3 hostForward); + + Assert.IsTrue(ok); + AssertVector(Vector3.Normalize(hostForward), -0.7477, 0.0, -0.6640, 1e-4); + } + + [TestMethod] + public void Hoisting_StartFrame_ShouldUseHorizontalSegmentAndKeepHostUp() + { + var pathPoints = new List + { + new Vector3(-105.992f, -28.615f, 77.043f), + new Vector3(-105.992f, 16.230f, 77.043f), + new Vector3(-229.870f, 16.230f, -32.982f) + }; + + bool ok = PathTargetFrameResolver.TryCreatePlanarStartHostFrame( + NavisworksTransport.PathType.Hoisting, + pathPoints, + CoordinateSystemType.YUp, + out PathTargetFrame frame); + + Assert.IsTrue(ok); + AssertVector(frame.Up, 0.0, 1.0, 0.0); + Assert.AreEqual(0.0, Vector3.Dot(frame.Forward, frame.Up), 1e-6); + } + + [TestMethod] + public void Hoisting_StartForward_ShouldUseEditedRealPathHorizontalSegment_FromDebugLog() + { + var pathPoints = new List + { + new Vector3(-197.436f, 14.833f, 29.413f), + new Vector3(-197.436f, 31.240f, 29.413f), + new Vector3(-213.650f, 31.240f, 29.413f) + }; + + bool ok = PathTargetFrameResolver.TryResolvePlanarStartHostForward( + NavisworksTransport.PathType.Hoisting, + pathPoints, + out Vector3 hostForward); + + Assert.IsTrue(ok); + AssertVector(Vector3.Normalize(hostForward), -1.0, 0.0, 0.0, 1e-6); + } + + [TestMethod] + public void YUp_PlanarYaw_ShouldUseHostXZPlane_NotHostXY() + { + bool ok1 = PathTargetFrameResolver.TryResolvePlanarHostYaw( + new Vector3(1.0f, 0.0f, 0.0f), + CoordinateSystemType.YUp, + out double yaw1); + bool ok2 = PathTargetFrameResolver.TryResolvePlanarHostYaw( + new Vector3(0.0f, 0.0f, 1.0f), + CoordinateSystemType.YUp, + out double yaw2); + + Assert.IsTrue(ok1); + Assert.IsTrue(ok2); + Assert.AreEqual(0.0, yaw1, 1e-6); + Assert.AreEqual(-System.Math.PI / 2.0, yaw2, 1e-6); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, actual.X, tolerance); + Assert.AreEqual(y, actual.Y, tolerance); + Assert.AreEqual(z, actual.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/ProjectReferenceFrameTests.cs b/UnitTests/CoordinateSystem/ProjectReferenceFrameTests.cs new file mode 100644 index 0000000..755de5f --- /dev/null +++ b/UnitTests/CoordinateSystem/ProjectReferenceFrameTests.cs @@ -0,0 +1,38 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class ProjectReferenceFrameTests + { + [TestMethod] + public void DefaultForYUp_ShouldUseCanonicalUpAndYUpModelConvention() + { + ProjectReferenceFrame frame = ProjectReferenceFrame.CreateDefault(CoordinateSystemType.YUp); + + Assert.AreEqual(0.0, frame.SphereCenterInCanonical3.X, 1e-9); + Assert.AreEqual(0.0, frame.SphereCenterInCanonical3.Y, 1e-9); + Assert.AreEqual(0.0, frame.SphereCenterInCanonical3.Z, 1e-9); + Assert.AreEqual(0.0, frame.ProjectUpInCanonical3.X, 1e-9); + Assert.AreEqual(0.0, frame.ProjectUpInCanonical3.Y, 1e-9); + Assert.AreEqual(1.0, frame.ProjectUpInCanonical3.Z, 1e-9); + Assert.AreEqual(LocalAxisDirection.PositiveX, frame.DefaultModelAxisConvention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, frame.DefaultModelAxisConvention.UpAxis); + } + + [TestMethod] + public void Constructor_ShouldNormalizeProjectUp() + { + var frame = new ProjectReferenceFrame( + new Vector3(1, 2, 3), + new Vector3(0, 0, 10), + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp)); + + Assert.AreEqual(0.0, frame.ProjectUpInCanonical3.X, 1e-9); + Assert.AreEqual(0.0, frame.ProjectUpInCanonical3.Y, 1e-9); + Assert.AreEqual(1.0, frame.ProjectUpInCanonical3.Z, 1e-9); + } + } +} diff --git a/UnitTests/CoordinateSystem/RailAssemblyWorkflowContextTests.cs b/UnitTests/CoordinateSystem/RailAssemblyWorkflowContextTests.cs new file mode 100644 index 0000000..aeb2308 --- /dev/null +++ b/UnitTests/CoordinateSystem/RailAssemblyWorkflowContextTests.cs @@ -0,0 +1,118 @@ +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.UI.WPF.ViewModels; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RailAssemblyWorkflowContextTests + { + [TestMethod] + public void ResetEndFaceAnalysis_ClearsAnalysisStateAndSeedPoints() + { + var context = CreateContext(); + context.HasEndFaceAnalysis = true; + context.EndFaceCenterPoint = new Point3D(1.0, 2.0, 3.0); + context.EndFaceNormal = new Vector3(1f, 0f, 0f); + context.EndFaceSeedPoints.Add(new Point3D(4.0, 5.0, 6.0)); + + context.ResetEndFaceAnalysis(); + + Assert.IsFalse(context.HasEndFaceAnalysis); + Assert.IsNull(context.EndFaceCenterPoint); + Assert.AreEqual(default(Vector3), context.EndFaceNormal); + Assert.AreEqual(0, context.EndFaceSeedPoints.Count); + } + + [TestMethod] + public void ResetInstallationReference_RestoresDefaultOffsetAndClearsReferenceState() + { + var context = CreateContext(); + context.HasInstallationReference = true; + context.InstallationPickPoint = new Point3D(1.0, 2.0, 3.0); + context.InstallationBaseAnchorPoint = new Point3D(4.0, 5.0, 6.0); + context.InstallationAnchorPoint = new Point3D(7.0, 8.0, 9.0); + context.InstallationPlaneNormal = new Vector3(0f, 1f, 0f); + context.InstallationPlaneSpanDirection = new Vector3(1f, 0f, 0f); + context.InstallationOffsetDistanceInMeters = 2.5; + context.InstallationReferenceRouteId = "route-1"; + context.InstallationSeedPoints.Add(new Point3D(9.0, 8.0, 7.0)); + context.AnchorVerticalOffsetInMeters = 3.5; + + context.ResetInstallationReference(); + + Assert.IsFalse(context.HasInstallationReference); + Assert.IsNull(context.InstallationPickPoint); + Assert.IsNull(context.InstallationBaseAnchorPoint); + Assert.IsNull(context.InstallationAnchorPoint); + Assert.AreEqual(default(Vector3), context.InstallationPlaneNormal); + Assert.AreEqual(default(Vector3), context.InstallationPlaneSpanDirection); + Assert.AreEqual(0.0, context.InstallationOffsetDistanceInMeters, 1e-9); + Assert.IsNull(context.InstallationReferenceRouteId); + Assert.AreEqual(0, context.InstallationSeedPoints.Count); + Assert.AreEqual(0.25, context.AnchorVerticalOffsetInMeters, 1e-9); + } + + [TestMethod] + public void ResetSession_ClearsTransientStateButPreservesConfiguration() + { + var context = CreateContext(); + context.WorkflowMode = RailAssemblyWorkflowMode.EditSelectedRail; + context.TerminalObjectName = "箱体A"; + context.TerminalObjectInfo = "info"; + context.ReferenceRodLengthInMeters = 12.0; + context.ReferenceRodDiameterInMeters = 0.4; + context.SphereCenterX = 10.0; + context.SphereCenterY = 11.0; + context.SphereCenterZ = 12.0; + context.MountMode = RailMountMode.OverRail; + context.HasTerminalObject = true; + context.IsSelectingStartPoint = true; + context.IsSelectingEndFacePoints = true; + context.IsSelectingInstallationPoint = true; + context.StartPoint = new Point3D(1.0, 2.0, 3.0); + context.StartPointText = "(1,2,3)"; + context.HasEndFaceAnalysis = true; + context.EndFaceCenterPoint = new Point3D(4.0, 5.0, 6.0); + context.HasInstallationReference = true; + context.InstallationReferenceRouteId = "route-2"; + context.AnchorVerticalOffsetInMeters = 1.5; + context.EndFaceSeedPoints.Add(new Point3D(1.0, 0.0, 0.0)); + context.InstallationSeedPoints.Add(new Point3D(0.0, 1.0, 0.0)); + + context.ResetSession(); + + Assert.AreEqual(RailAssemblyWorkflowMode.None, context.WorkflowMode); + Assert.IsFalse(context.HasTerminalObject); + Assert.IsFalse(context.IsSelectingStartPoint); + Assert.IsFalse(context.IsSelectingEndFacePoints); + Assert.IsFalse(context.IsSelectingInstallationPoint); + Assert.IsNull(context.TerminalObject); + Assert.IsNotNull(context.StartPoint); + Assert.AreEqual("未选择", context.TerminalObjectName); + Assert.AreEqual("请选择终点处已安装箱体", context.TerminalObjectInfo); + Assert.AreEqual("未选择", context.StartPointText); + Assert.IsFalse(context.HasEndFaceAnalysis); + Assert.IsFalse(context.HasInstallationReference); + Assert.AreEqual(0.25, context.AnchorVerticalOffsetInMeters, 1e-9); + Assert.AreEqual(12.0, context.ReferenceRodLengthInMeters, 1e-9); + Assert.AreEqual(0.4, context.ReferenceRodDiameterInMeters, 1e-9); + Assert.AreEqual(10.0, context.SphereCenterX, 1e-9); + Assert.AreEqual(11.0, context.SphereCenterY, 1e-9); + Assert.AreEqual(12.0, context.SphereCenterZ, 1e-9); + Assert.AreEqual(RailMountMode.OverRail, context.MountMode); + } + + private static RailAssemblyWorkflowContext CreateContext() + { + return new RailAssemblyWorkflowContext( + defaultReferenceRodLengthInMeters: 20.0, + defaultReferenceRodDiameterInMeters: 0.3, + defaultAnchorVerticalOffsetInMeters: 0.25, + defaultSphereCenterX: 0.0, + defaultSphereCenterY: 0.0, + defaultSphereCenterZ: 0.0); + } + } +} diff --git a/UnitTests/CoordinateSystem/RailPathPoseHelperTests.cs b/UnitTests/CoordinateSystem/RailPathPoseHelperTests.cs new file mode 100644 index 0000000..cc93a59 --- /dev/null +++ b/UnitTests/CoordinateSystem/RailPathPoseHelperTests.cs @@ -0,0 +1,36 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RailPathPoseHelperTests + { + [TestMethod] + public void NormalizePreferredNormalToHostUpHemisphere_ShouldFlip_WhenPreferredNormalOpposesHostUp() + { + Vector3 hostPreferredNormal = new Vector3(-0.1222f, -0.9676f, 0.2211f); + Vector3 normalized = RailPathPoseHelper.NormalizePreferredNormalToHostUpHemisphere( + hostPreferredNormal, + Vector3.UnitY); + + Assert.IsTrue(Vector3.Dot(normalized, Vector3.UnitY) > 0f); + Assert.AreEqual(0.1222 / 1.0008249, normalized.X, 1e-4); + Assert.AreEqual(0.9676 / 1.0008249, normalized.Y, 1e-4); + Assert.AreEqual(-0.2211 / 1.0008249, normalized.Z, 1e-4); + } + + [TestMethod] + public void NormalizePreferredNormalToHostUpHemisphere_ShouldKeepDirection_WhenPreferredNormalAlreadyMatchesHostUp() + { + Vector3 hostPreferredNormal = new Vector3(0.139f, 0.954f, 0.266f); + Vector3 normalized = RailPathPoseHelper.NormalizePreferredNormalToHostUpHemisphere( + hostPreferredNormal, + Vector3.UnitY); + + Assert.IsTrue(Vector3.Dot(normalized, Vector3.UnitY) > 0f); + Assert.IsTrue(Vector3.Dot(Vector3.Normalize(hostPreferredNormal), normalized) > 0.9999f); + } + } +} diff --git a/UnitTests/CoordinateSystem/RailPreservedPoseTests.cs b/UnitTests/CoordinateSystem/RailPreservedPoseTests.cs new file mode 100644 index 0000000..05e0d81 --- /dev/null +++ b/UnitTests/CoordinateSystem/RailPreservedPoseTests.cs @@ -0,0 +1,290 @@ +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core.Animation; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RailPreservedPoseTests + { + [TestMethod] + public void ResolvePreservedPoseRotation_ShouldPreferActualGeometryRotationForRealObjects() + { + Rotation3D fallbackRotation = new Rotation3D(0.0, 0.0, 0.0, 1.0); + Rotation3D actualGeometryRotation = new Rotation3D(0.0, 0.70710678, 0.0, 0.70710678); + + Rotation3D resolved = PathAnimationManager.ResolvePreservedPoseRotation( + preferActualGeometryRotation: true, + hasActualGeometryRotation: true, + actualGeometryRotation: actualGeometryRotation, + fallbackRotation: fallbackRotation); + + Assert.AreEqual(actualGeometryRotation.A, resolved.A, 1e-9); + Assert.AreEqual(actualGeometryRotation.B, resolved.B, 1e-9); + Assert.AreEqual(actualGeometryRotation.C, resolved.C, 1e-9); + Assert.AreEqual(actualGeometryRotation.D, resolved.D, 1e-9); + } + + [TestMethod] + public void ResolvePreservedPoseRotation_ShouldFallbackWhenActualGeometryRotationUnavailable() + { + Rotation3D fallbackRotation = new Rotation3D(0.0, 0.0, 0.38268343, 0.92387953); + Rotation3D actualGeometryRotation = new Rotation3D(0.0, 0.70710678, 0.0, 0.70710678); + + Rotation3D resolved = PathAnimationManager.ResolvePreservedPoseRotation( + preferActualGeometryRotation: true, + hasActualGeometryRotation: false, + actualGeometryRotation: actualGeometryRotation, + fallbackRotation: fallbackRotation); + + Assert.AreEqual(fallbackRotation.A, resolved.A, 1e-9); + Assert.AreEqual(fallbackRotation.B, resolved.B, 1e-9); + Assert.AreEqual(fallbackRotation.C, resolved.C, 1e-9); + Assert.AreEqual(fallbackRotation.D, resolved.D, 1e-9); + } + + [TestMethod] + public void ShouldPreservePathRotationForFrames_ShouldEnableHoistingWhenTranslationModeHasLockedRotation() + { + bool shouldPreserve = PathAnimationManager.ShouldPreservePathRotationForFrames( + PathType.Hoisting, + ObjectStartPlacementMode.PreserveInitialPose, + hasPreservedRotation: true); + + Assert.IsTrue(shouldPreserve); + } + + [TestMethod] + public void ShouldPreservePathRotationForFrames_ShouldEnableRailWhenTranslationModeHasLockedRotation() + { + bool shouldPreserve = PathAnimationManager.ShouldPreservePathRotationForFrames( + PathType.Rail, + ObjectStartPlacementMode.PreserveInitialPose, + hasPreservedRotation: true); + + Assert.IsTrue(shouldPreserve); + } + + [TestMethod] + public void ShouldPreservePathRotationForFrames_ShouldDisableGroundEvenInTranslationMode() + { + bool shouldPreserve = PathAnimationManager.ShouldPreservePathRotationForFrames( + PathType.Ground, + ObjectStartPlacementMode.PreserveInitialPose, + hasPreservedRotation: true); + + Assert.IsFalse(shouldPreserve); + } + + [TestMethod] + public void ShouldPreservePathRotationForFrames_ShouldDisableWhenLockedRotationMissing() + { + bool shouldPreserve = PathAnimationManager.ShouldPreservePathRotationForFrames( + PathType.Hoisting, + ObjectStartPlacementMode.PreserveInitialPose, + hasPreservedRotation: false); + + Assert.IsFalse(shouldPreserve); + } + + [TestMethod] + public void ShouldAllowFragmentPlanarFallback_ShouldDisableHoisting() + { + Assert.IsFalse(PathAnimationManager.ShouldAllowFragmentPlanarFallback(PathType.Hoisting)); + Assert.IsFalse(PathAnimationManager.ShouldAllowFragmentPlanarFallback(PathType.Ground)); + Assert.IsTrue(PathAnimationManager.ShouldAllowFragmentPlanarFallback(PathType.Rail)); + } + + [TestMethod] + public void ShouldUseReferenceBasedRealObjectPlanarPose_ShouldDisableGroundAndHoisting() + { + Assert.IsFalse(PathAnimationManager.ShouldUseReferenceBasedRealObjectPlanarPose(PathType.Ground)); + Assert.IsFalse(PathAnimationManager.ShouldUseReferenceBasedRealObjectPlanarPose(PathType.Hoisting)); + Assert.IsTrue(PathAnimationManager.ShouldUseReferenceBasedRealObjectPlanarPose(PathType.Rail)); + } + + [TestMethod] + public void TryResolveDisplayedPlanarYawFromRotation_ShouldResolveYUpFromDisplayedXAxis() + { + Rotation3D rotation = new Rotation3D( + 0.0, + System.Math.Sin(-System.Math.PI / 4.0), + 0.0, + System.Math.Cos(-System.Math.PI / 4.0)); + + bool ok = PathAnimationManager.TryResolveDisplayedPlanarYawFromRotation( + rotation, + CoordinateSystemType.YUp, + out double yawRadians); + + Assert.IsTrue(ok); + Assert.AreEqual(-System.Math.PI / 2.0, yawRadians, 1e-6); + } + + [TestMethod] + public void ShouldUsePlanarRealObjectPureIncrementFrames_ShouldEnableOnlyForPlanarRealObjects() + { + Assert.IsTrue(PathAnimationManager.ShouldUsePlanarRealObjectPureIncrementFrames(PathType.Ground, true)); + Assert.IsTrue(PathAnimationManager.ShouldUsePlanarRealObjectPureIncrementFrames(PathType.Hoisting, true)); + Assert.IsFalse(PathAnimationManager.ShouldUsePlanarRealObjectPureIncrementFrames(PathType.Ground, false)); + Assert.IsFalse(PathAnimationManager.ShouldUsePlanarRealObjectPureIncrementFrames(PathType.Rail, true)); + } + + [TestMethod] + public void ResolveAnimationFramePlaybackYawRadians_ShouldPreferPlanarHostYawWithHostCorrection() + { + var frame = new AnimationFrame + { + YawRadians = 0.25, + PlanarHostYawRadians = 0.75, + HasCustomRotation = true + }; + + double resolved = PathAnimationManager.ResolveAnimationFramePlaybackYawRadians( + frame, + new LocalEulerRotationCorrection(10.0, 20.0, 30.0), + CoordinateSystemType.YUp); + + Assert.AreEqual(0.75 + 20.0 * System.Math.PI / 180.0, resolved, 1e-9); + } + + [TestMethod] + public void ApplyRecordedPlanarRealObjectFramePose_ShouldPreserveFullRotation_ForGroundCollisionLogCase() + { + var frame = new AnimationFrame(); + double yawRadians = 115.85 * System.Math.PI / 180.0; + + var hostLinear = new Matrix4x4( + -0.4360f, 0.0000f, -0.8999f, 0.0f, + -0.6364f, 0.7071f, 0.3083f, 0.0f, + 0.6364f, 0.7071f, -0.3083f, 0.0f, + 0.0f, 0.0f, 0.0f, 1.0f); + Quaternion hostQuaternion = Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(hostLinear)); + var rotation = new Rotation3D(hostQuaternion.X, hostQuaternion.Y, hostQuaternion.Z, hostQuaternion.W); + + PathAnimationManager.ApplyRecordedPlanarRealObjectFramePose(frame, yawRadians, rotation); + + Assert.IsTrue(frame.PlanarHostYawRadians.HasValue); + Assert.AreEqual(yawRadians, frame.PlanarHostYawRadians.Value, 1e-9); + Assert.IsTrue(frame.HasCustomRotation); + + Matrix3 linear = new Transform3D(frame.Rotation).Linear; + Assert.AreEqual(-0.4360, linear.Get(0, 0), 5e-4); + Assert.AreEqual(-0.6364, linear.Get(1, 0), 5e-4); + Assert.AreEqual(0.6364, linear.Get(2, 0), 5e-4); + Assert.AreEqual(0.0000, linear.Get(0, 1), 5e-4); + Assert.AreEqual(0.7071, linear.Get(1, 1), 5e-4); + Assert.AreEqual(0.7071, linear.Get(2, 1), 5e-4); + Assert.AreEqual(-0.8999, linear.Get(0, 2), 5e-4); + Assert.AreEqual(0.3083, linear.Get(1, 2), 5e-4); + Assert.AreEqual(-0.3083, linear.Get(2, 2), 5e-4); + } + + [TestMethod] + public void TryResolveGroundHostPlanarYawFromFramePoints_ShouldResolveYUpHostYaw() + { + bool ok = PathAnimationManager.TryResolveGroundHostPlanarYawFromFramePoints( + new Point3D(0, 0, 0), + new Point3D(1, 0, 0), + new Point3D(1, 0, -1), + CoordinateSystemType.YUp, + out double yawRadians); + + Assert.IsTrue(ok); + Assert.AreEqual(-System.Math.PI / 4.0, yawRadians, 1e-6); + } + + [TestMethod] + public void ResolvePlanarHostUpCorrectionRadians_ShouldUseYDegreesForYUp() + { + double radians = PathAnimationManager.ResolvePlanarHostUpCorrectionRadians( + new LocalEulerRotationCorrection(10.0, 20.0, 30.0), + CoordinateSystemType.YUp); + + Assert.AreEqual(20.0 * System.Math.PI / 180.0, radians, 1e-9); + } + + [TestMethod] + public void ResolvePlanarHostUpCorrectionRadians_ShouldUseZDegreesForZUp() + { + double radians = PathAnimationManager.ResolvePlanarHostUpCorrectionRadians( + new LocalEulerRotationCorrection(10.0, 20.0, 30.0), + CoordinateSystemType.ZUp); + + Assert.AreEqual(30.0 * System.Math.PI / 180.0, radians, 1e-9); + } + + [TestMethod] + public void ResolveCurrentRotationBaseline_ShouldPreferActualGeometryForHoisting() + { + Rotation3D trackedRotation = new Rotation3D(0.0, 0.0, 0.0, 1.0); + Rotation3D actualGeometryRotation = new Rotation3D(0.0, 0.70710678, 0.0, 0.70710678); + + Rotation3D resolved = PathAnimationManager.ResolveCurrentRotationBaseline( + PathType.Hoisting, + isRealObjectMode: true, + hasTrackedRotation: true, + trackedRotation: trackedRotation, + hasReferenceRotation: false, + referenceRotation: Rotation3D.Identity, + transformRotation: Rotation3D.Identity, + hasActualGeometryRotation: true, + actualGeometryRotation: actualGeometryRotation); + + Assert.AreEqual(actualGeometryRotation.A, resolved.A, 1e-9); + Assert.AreEqual(actualGeometryRotation.B, resolved.B, 1e-9); + Assert.AreEqual(actualGeometryRotation.C, resolved.C, 1e-9); + Assert.AreEqual(actualGeometryRotation.D, resolved.D, 1e-9); + } + + [TestMethod] + public void ResolveCurrentRotationBaseline_ShouldKeepTrackedRotationForGround() + { + Rotation3D trackedRotation = new Rotation3D(0.0, 0.0, 0.38268343, 0.92387953); + Rotation3D actualGeometryRotation = new Rotation3D(0.0, 0.70710678, 0.0, 0.70710678); + + Rotation3D resolved = PathAnimationManager.ResolveCurrentRotationBaseline( + PathType.Ground, + isRealObjectMode: true, + hasTrackedRotation: true, + trackedRotation: trackedRotation, + hasReferenceRotation: false, + referenceRotation: Rotation3D.Identity, + transformRotation: Rotation3D.Identity, + hasActualGeometryRotation: true, + actualGeometryRotation: actualGeometryRotation); + + Assert.AreEqual(trackedRotation.A, resolved.A, 1e-9); + Assert.AreEqual(trackedRotation.B, resolved.B, 1e-9); + Assert.AreEqual(trackedRotation.C, resolved.C, 1e-9); + Assert.AreEqual(trackedRotation.D, resolved.D, 1e-9); + } + + [TestMethod] + public void ShouldUseRealObjectOverrideRotation_ShouldEnableForRealObjects() + { + Assert.IsTrue(PathAnimationManager.ShouldUseRealObjectOverrideRotation(true)); + Assert.IsFalse(PathAnimationManager.ShouldUseRealObjectOverrideRotation(false)); + } + + [TestMethod] + public void ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition_ShouldEnableOnlyForGroundRealObjects() + { + Assert.IsTrue(PathAnimationManager.ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + PathType.Ground, + isRealObjectMode: true)); + Assert.IsFalse(PathAnimationManager.ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + PathType.Ground, + isRealObjectMode: false)); + Assert.IsFalse(PathAnimationManager.ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + PathType.Hoisting, + isRealObjectMode: true)); + Assert.IsFalse(PathAnimationManager.ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + PathType.Rail, + isRealObjectMode: true)); + } + + } +} diff --git a/UnitTests/CoordinateSystem/RealObjectPlanarPoseSolverTests.cs b/UnitTests/CoordinateSystem/RealObjectPlanarPoseSolverTests.cs new file mode 100644 index 0000000..ebf77c7 --- /dev/null +++ b/UnitTests/CoordinateSystem/RealObjectPlanarPoseSolverTests.cs @@ -0,0 +1,162 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RealObjectPlanarPoseSolverTests + { + [TestMethod] + public void ShouldChooseClosestCandidateAxis_FromRotatedReferencePose() + { + Quaternion referenceRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (float)(Math.PI / 6.0)); + Vector3 desiredForward = new Vector3(1.0f, 0.2f, 0.1f); + Vector3 desiredUp = Vector3.UnitZ; + + bool ok = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + desiredForward, + desiredUp, + out RealObjectPlanarPoseSolution solution); + + Assert.IsTrue(ok); + AssertVector(solution.SelectedReferenceAxisLocal, 1.0, 0.0, 0.0); + + Vector3 transformedSelectedAxis = Vector3.Normalize(Vector3.Transform(solution.SelectedReferenceAxisLocal, solution.BaselineRotation)); + AssertVector(transformedSelectedAxis, desiredForward.X / desiredForward.Length(), desiredForward.Y / desiredForward.Length(), desiredForward.Z / desiredForward.Length(), 1e-4); + } + + [TestMethod] + public void ShouldPreferNegativeAxisWhenItMatchesForwardBest() + { + bool ok = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + Quaternion.Identity, + new Vector3(-0.1f, -1.0f, 0.05f), + Vector3.UnitZ, + out RealObjectPlanarPoseSolution solution); + + Assert.IsTrue(ok); + AssertVector(solution.SelectedReferenceAxisLocal, 0.0, -1.0, 0.0); + } + + [TestMethod] + public void ShouldRespectDesiredUpPlane_WhenDesiredForwardHasVerticalComponent() + { + Vector3 desiredForward = Vector3.Normalize(new Vector3(1.0f, 1.0f, 1.0f)); + + bool ok = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + Quaternion.Identity, + desiredForward, + Vector3.UnitY, + out RealObjectPlanarPoseSolution solution); + + Assert.IsTrue(ok); + Vector3 transformedSelectedAxis = Vector3.Normalize(Vector3.Transform(solution.SelectedReferenceAxisLocal, solution.BaselineRotation)); + AssertVector(transformedSelectedAxis, desiredForward.X, desiredForward.Y, desiredForward.Z, 1e-4); + } + + [TestMethod] + public void ShouldPreserveReferenceOrthogonalityByApplyingSingleRotationDelta() + { + Quaternion referenceRotation = Quaternion.Normalize( + Quaternion.CreateFromYawPitchRoll( + (float)(Math.PI / 7.0), + (float)(Math.PI / 9.0), + (float)(Math.PI / 11.0))); + + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.3f, 0.8f, 0.52f)); + + bool ok = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + desiredForward, + Vector3.UnitZ, + out RealObjectPlanarPoseSolution solution); + + Assert.IsTrue(ok); + + Vector3 transformedX = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, solution.BaselineRotation)); + Vector3 transformedY = Vector3.Normalize(Vector3.Transform(Vector3.UnitY, solution.BaselineRotation)); + Vector3 transformedZ = Vector3.Normalize(Vector3.Transform(Vector3.UnitZ, solution.BaselineRotation)); + + Assert.AreEqual(1.0, transformedX.Length(), 1e-4); + Assert.AreEqual(1.0, transformedY.Length(), 1e-4); + Assert.AreEqual(1.0, transformedZ.Length(), 1e-4); + Assert.AreEqual(0.0, Vector3.Dot(transformedX, transformedY), 1e-4); + Assert.AreEqual(0.0, Vector3.Dot(transformedY, transformedZ), 1e-4); + Assert.AreEqual(0.0, Vector3.Dot(transformedZ, transformedX), 1e-4); + } + + [TestMethod] + public void FixedReferenceAxis_ShouldKeepAxisFamilyAcrossTurn() + { + Quaternion referenceRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)Math.PI); + Vector3 desiredForwardStart = Vector3.Normalize(new Vector3(-0.95f, 0.0f, 0.31f)); + Vector3 desiredForwardTurn = Vector3.Normalize(new Vector3(0.22f, 0.0f, 0.97f)); + Vector3 desiredUp = Vector3.UnitY; + + bool startOk = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + desiredForwardStart, + desiredUp, + out RealObjectPlanarPoseSolution startSolution); + + Assert.IsTrue(startOk); + + bool turnOk = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + desiredForwardTurn, + desiredUp, + startSolution.SelectedReferenceAxisDirection, + out RealObjectPlanarPoseSolution turnSolution); + + Assert.IsTrue(turnOk); + Assert.AreEqual(startSolution.SelectedReferenceAxisDirection, turnSolution.SelectedReferenceAxisDirection); + + Vector3 transformedSelectedAxis = Vector3.Normalize( + Vector3.Transform(turnSolution.SelectedReferenceAxisLocal, turnSolution.BaselineRotation)); + AssertVector( + transformedSelectedAxis, + desiredForwardTurn.X, + desiredForwardTurn.Y, + desiredForwardTurn.Z, + 1e-4); + } + + [TestMethod] + public void FixedPositiveX_ShouldNotSwitchToZ_WhenPathLeansTowardZ() + { + Quaternion referenceRotation = Quaternion.Identity; + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.690f, 0.0f, 0.724f)); + Vector3 desiredUp = Vector3.UnitY; + + bool ok = RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + desiredForward, + desiredUp, + LocalAxisDirection.PositiveX, + out RealObjectPlanarPoseSolution solution); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, solution.SelectedReferenceAxisDirection); + AssertVector(solution.SelectedReferenceAxisLocal, 1.0, 0.0, 0.0); + + Vector3 transformedSelectedAxis = Vector3.Normalize( + Vector3.Transform(solution.SelectedReferenceAxisLocal, solution.BaselineRotation)); + AssertVector( + transformedSelectedAxis, + desiredForward.X, + desiredForward.Y, + desiredForward.Z, + 1e-4); + } + + private static void AssertVector(Vector3 vector, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, vector.X, tolerance); + Assert.AreEqual(y, vector.Y, tolerance); + Assert.AreEqual(z, vector.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/RealObjectProjectedExtentResolverTests.cs b/UnitTests/CoordinateSystem/RealObjectProjectedExtentResolverTests.cs new file mode 100644 index 0000000..595a088 --- /dev/null +++ b/UnitTests/CoordinateSystem/RealObjectProjectedExtentResolverTests.cs @@ -0,0 +1,145 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RealObjectProjectedExtentResolverTests + { + [TestMethod] + public void Ground_DualAxisCorrection_ShouldProjectAgainstFinalPose() + { + var convention = new ModelAxisConvention(LocalAxisDirection.PositiveX, LocalAxisDirection.PositiveY); + Vector3 targetForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Vector3 targetUp = Vector3.UnitY; + + Quaternion baseline = Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(new Matrix4x4( + -0.8987f, 0.0000f, 0.4386f, 0f, + 0.0000f, 1.0000f, 0.0000f, 0f, + -0.4386f, 0.0000f,-0.8987f, 0f, + 0f, 0f, 0f, 1f))); + + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion final = adapter.ComposeHostQuaternion( + baseline, + new LocalEulerRotationCorrection(90.0, 0.0, 90.0)); + + var extents = RealObjectProjectedExtentResolver.CalculateProjectedSemanticExtents( + convention, + forwardSize: 6.0, + sideSize: 2.0, + upSize: 4.0, + baseline, + final, + targetForward, + targetUp); + + Vector3 localSize = convention.CreateScaleVector3(6.0, 2.0, 4.0); + Vector3 rotatedLocalX = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, final)); + Vector3 rotatedLocalY = Vector3.Normalize(Vector3.Transform(Vector3.UnitY, final)); + Vector3 rotatedLocalZ = Vector3.Normalize(Vector3.Transform(Vector3.UnitZ, final)); + Vector3 targetSide = Vector3.Normalize(Vector3.Cross(targetForward, targetUp)); + + double expectedForward = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetForward); + double expectedSide = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetSide); + double expectedUp = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetUp); + + Assert.AreEqual(expectedForward, extents.forwardExtent, 1e-5); + Assert.AreEqual(expectedSide, extents.sideExtent, 1e-5); + Assert.AreEqual(expectedUp, extents.upExtent, 1e-5); + Assert.AreNotEqual(4.0, extents.upExtent, 1e-3, "双轴旋转后,Ground 的法线尺寸不应停留在旧高度。"); + } + + [TestMethod] + public void Ground_DisplayedHostAxesProjection_ShouldKeepXAxisLengthAndSwapXZAsAxesRotate() + { + Vector3 targetForward = Vector3.UnitX; + Vector3 targetUp = Vector3.UnitY; + + var noCorrection = RealObjectProjectedExtentResolver.CalculateProjectedDisplayedExtents( + displayedAxisXSize: 6.0, + displayedAxisYSize: 2.0, + displayedAxisZSize: 4.0, + displayedHostAxisX: Vector3.UnitX, + displayedHostAxisY: Vector3.UnitZ, + displayedHostAxisZ: -Vector3.UnitY, + targetForward, + targetUp); + + Assert.AreEqual(6.0, noCorrection.forwardExtent, 1e-5); + Assert.AreEqual(2.0, noCorrection.sideExtent, 1e-5); + Assert.AreEqual(4.0, noCorrection.upExtent, 1e-5); + + var xRotated = RealObjectProjectedExtentResolver.CalculateProjectedDisplayedExtents( + displayedAxisXSize: 6.0, + displayedAxisYSize: 2.0, + displayedAxisZSize: 4.0, + displayedHostAxisX: Vector3.UnitX, + displayedHostAxisY: Vector3.UnitY, + displayedHostAxisZ: Vector3.UnitZ, + targetForward, + targetUp); + + Assert.AreEqual(6.0, xRotated.forwardExtent, 1e-5); + Assert.AreEqual(4.0, xRotated.sideExtent, 1e-5); + Assert.AreEqual(2.0, xRotated.upExtent, 1e-5); + + var zRotated = RealObjectProjectedExtentResolver.CalculateProjectedDisplayedExtents( + displayedAxisXSize: 6.0, + displayedAxisYSize: 2.0, + displayedAxisZSize: 4.0, + displayedHostAxisX: -Vector3.UnitZ, + displayedHostAxisY: Vector3.UnitX, + displayedHostAxisZ: -Vector3.UnitY, + targetForward, + targetUp); + + Assert.AreEqual(2.0, zRotated.forwardExtent, 1e-5); + Assert.AreEqual(6.0, zRotated.sideExtent, 1e-5); + Assert.AreEqual(4.0, zRotated.upExtent, 1e-5); + } + + [TestMethod] + public void Ground_DisplayedHostAxesProjection_ShouldIgnoreVerticalComponentInForward() + { + Vector3 targetUp = Vector3.UnitY; + Vector3 planarForward = Vector3.Normalize(new Vector3(1f, 0f, 1f)); + Vector3 slopedForward = Vector3.Normalize(new Vector3(1f, 2f, 1f)); + + var planar = RealObjectProjectedExtentResolver.CalculateProjectedDisplayedExtents( + displayedAxisXSize: 6.0, + displayedAxisYSize: 2.0, + displayedAxisZSize: 4.0, + displayedHostAxisX: Vector3.UnitX, + displayedHostAxisY: Vector3.UnitZ, + displayedHostAxisZ: -Vector3.UnitY, + planarForward, + targetUp); + + var sloped = RealObjectProjectedExtentResolver.CalculateProjectedDisplayedExtents( + displayedAxisXSize: 6.0, + displayedAxisYSize: 2.0, + displayedAxisZSize: 4.0, + displayedHostAxisX: Vector3.UnitX, + displayedHostAxisY: Vector3.UnitZ, + displayedHostAxisZ: -Vector3.UnitY, + slopedForward, + targetUp); + + Assert.AreNotEqual(planar.forwardExtent, sloped.forwardExtent, 1e-3, "原始带竖直分量的路径方向会污染 Ground 尺寸投影。"); + } + + private static double ProjectExtent( + Vector3 localSize, + Vector3 rotatedLocalX, + Vector3 rotatedLocalY, + Vector3 rotatedLocalZ, + Vector3 targetAxis) + { + return System.Math.Abs(Vector3.Dot(rotatedLocalX, targetAxis)) * localSize.X + + System.Math.Abs(Vector3.Dot(rotatedLocalY, targetAxis)) * localSize.Y + + System.Math.Abs(Vector3.Dot(rotatedLocalZ, targetAxis)) * localSize.Z; + } + } +} diff --git a/UnitTests/CoordinateSystem/RealObjectRailAxisConventionResolverTests.cs b/UnitTests/CoordinateSystem/RealObjectRailAxisConventionResolverTests.cs new file mode 100644 index 0000000..5d2ac9a --- /dev/null +++ b/UnitTests/CoordinateSystem/RealObjectRailAxisConventionResolverTests.cs @@ -0,0 +1,166 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RealObjectRailAxisConventionResolverTests + { + [TestMethod] + public void YUp_InterpretedReferencePose_ShouldChoosePositiveXAndPositiveYForRail() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, -1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Vector3 desiredUp = Vector3.UnitY; + + bool ok = RealObjectRailAxisConventionResolver.TryResolve( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveY, + desiredForward, + CoordinateSystemType.YUp, + out ModelAxisConvention convention, + out LocalAxisDirection selectedForwardAxis, + out Vector3 selectedForwardWorldAxis, + out LocalAxisDirection selectedUpAxis, + out Vector3 selectedUpWorldAxis); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, selectedForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, convention.UpAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, selectedUpAxis); + AssertVector(selectedForwardWorldAxis, -1.0, 0.0, 0.0); + AssertVector(selectedUpWorldAxis, 0.0, 1.0, 0.0); + } + + [TestMethod] + public void ZUp_InterpretedReferencePose_ShouldChoosePositiveXAndPositiveZForRail() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.9f, 0.3f, 0.0f)); + Vector3 desiredUp = Vector3.UnitZ; + + bool ok = RealObjectRailAxisConventionResolver.TryResolve( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveZ, + desiredForward, + CoordinateSystemType.ZUp, + out ModelAxisConvention convention, + out LocalAxisDirection selectedForwardAxis, + out _, + out LocalAxisDirection selectedUpAxis, + out Vector3 selectedUpWorldAxis); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, selectedForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, convention.UpAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, selectedUpAxis); + AssertVector(selectedUpWorldAxis, 0.0, 0.0, 1.0); + } + + [TestMethod] + public void YUp_ShouldNotSelectYAxisFamilyAsForwardCandidate() + { + Vector3 referenceAxisX = new Vector3(1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(0.0f, 1.0f, 0.01f)); + Vector3 desiredUp = Vector3.UnitY; + + bool ok = RealObjectRailAxisConventionResolver.TryResolve( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveY, + desiredForward, + CoordinateSystemType.YUp, + out _, + out LocalAxisDirection selectedForwardAxis, + out _, + out _, + out _); + + Assert.IsTrue(ok); + Assert.AreNotEqual(LocalAxisDirection.PositiveY, selectedForwardAxis); + Assert.AreNotEqual(LocalAxisDirection.NegativeY, selectedForwardAxis); + } + + [TestMethod] + public void YUp_WhenRemainingZAxisBestMatchesDesiredUp_ShouldChooseZAsUp() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 desiredForward = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 desiredUp = new Vector3(0.0f, 1.0f, 0.0f); + + bool ok = RealObjectRailAxisConventionResolver.TryResolve( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveZ, + desiredForward, + CoordinateSystemType.YUp, + out ModelAxisConvention convention, + out LocalAxisDirection selectedForwardAxis, + out Vector3 selectedForwardWorldAxis, + out LocalAxisDirection selectedUpAxis, + out Vector3 selectedUpWorldAxis); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, selectedForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, selectedUpAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, convention.UpAxis); + AssertVector(selectedForwardWorldAxis, -1.0, 0.0, 0.0); + AssertVector(selectedUpWorldAxis, 0.0, 1.0, 0.0); + } + + [TestMethod] + public void YUp_ShouldUseMappedHostUpLocalAxis_AndSelectForwardFromRemainingAxes() + { + Vector3 referenceAxisX = new Vector3(0.0f, 0.0f, 1.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(0.99f, -0.14f, -0.02f)); + + bool ok = RealObjectRailAxisConventionResolver.TryResolve( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveZ, + desiredForward, + CoordinateSystemType.YUp, + out ModelAxisConvention convention, + out LocalAxisDirection selectedForwardAxis, + out Vector3 selectedForwardWorldAxis, + out LocalAxisDirection selectedUpAxis, + out Vector3 selectedUpWorldAxis); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveZ, selectedUpAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, convention.UpAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, selectedForwardAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, convention.ForwardAxis); + AssertVector(selectedUpWorldAxis, -1.0, 0.0, 0.0); + AssertVector(selectedForwardWorldAxis, 0.0, -1.0, 0.0); + } + + private static void AssertVector(Vector3 actual, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, actual.X, tolerance); + Assert.AreEqual(y, actual.Y, tolerance); + Assert.AreEqual(z, actual.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/RealObjectRailExtentResolverTests.cs b/UnitTests/CoordinateSystem/RealObjectRailExtentResolverTests.cs new file mode 100644 index 0000000..35f6dc7 --- /dev/null +++ b/UnitTests/CoordinateSystem/RealObjectRailExtentResolverTests.cs @@ -0,0 +1,145 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RealObjectRailExtentResolverTests + { + [TestMethod] + public void YUp_ZeroCorrection_ShouldKeepResolvedRailUpExtent() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, -1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Quaternion baseline = Quaternion.Identity; + + bool ok = RealObjectRailExtentResolver.TryResolveProjectedSemanticExtents( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveY, + desiredForward, + CoordinateSystemType.YUp, + 6.0, + 2.0, + 4.0, + baseline, + baseline, + out ModelAxisConvention convention, + out var extents); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, convention.UpAxis); + Assert.AreEqual(6.0, extents.forwardExtent, 1e-6); + Assert.AreEqual(2.0, extents.sideExtent, 1e-6); + Assert.AreEqual(4.0, extents.upExtent, 1e-6); + } + + [TestMethod] + public void YUp_HostY90_ShouldKeepResolvedRailUpExtentAndSwapForwardSide() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, -1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Quaternion baseline = Quaternion.Identity; + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion final = adapter.ComposeHostQuaternion( + baseline, + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + bool ok = RealObjectRailExtentResolver.TryResolveProjectedSemanticExtents( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveY, + desiredForward, + CoordinateSystemType.YUp, + 6.0, + 2.0, + 4.0, + baseline, + final, + out _, + out var extents); + + Assert.IsTrue(ok); + Assert.AreEqual(2.0, extents.forwardExtent, 1e-6); + Assert.AreEqual(6.0, extents.sideExtent, 1e-6); + Assert.AreEqual(4.0, extents.upExtent, 1e-6); + } + + [TestMethod] + public void YUp_DualAxisCorrection_WithRailBaseline_ShouldProjectAgainstFinalRailPose() + { + Vector3 referenceAxisX = new Vector3(-1.0f, 0.0f, 0.0f); + Vector3 referenceAxisY = new Vector3(0.0f, 1.0f, 0.0f); + Vector3 referenceAxisZ = new Vector3(0.0f, 0.0f, -1.0f); + Vector3 desiredForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + + Quaternion baseline = Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(new Matrix4x4( + 0.9900f, -0.1403f, -0.0161f, 0f, + 0.1403f, 0.9901f, -0.0023f, 0f, + 0.0163f, 0.0000f, 0.9999f, 0f, + 0f, 0f, 0f, 1f))); + + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Quaternion final = adapter.ComposeHostQuaternion( + baseline, + new LocalEulerRotationCorrection(0.0, 90.0, 90.0)); + + bool ok = RealObjectRailExtentResolver.TryResolveProjectedSemanticExtents( + referenceAxisX, + referenceAxisY, + referenceAxisZ, + LocalAxisDirection.PositiveY, + desiredForward, + CoordinateSystemType.YUp, + 6.0, + 2.0, + 4.0, + baseline, + final, + out ModelAxisConvention convention, + out var extents); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, convention.ForwardAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, convention.UpAxis); + + Vector3 localSize = convention.CreateScaleVector3(6.0, 2.0, 4.0); + Vector3 rotatedLocalX = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, final)); + Vector3 rotatedLocalY = Vector3.Normalize(Vector3.Transform(Vector3.UnitY, final)); + Vector3 rotatedLocalZ = Vector3.Normalize(Vector3.Transform(Vector3.UnitZ, final)); + Vector3 targetForward = Vector3.Normalize(Vector3.Transform(convention.ForwardUnitVector, baseline)); + Vector3 targetUp = Vector3.Normalize(Vector3.Transform(convention.UpUnitVector, baseline)); + Vector3 targetSide = Vector3.Normalize(Vector3.Cross(targetForward, targetUp)); + + double expectedForward = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetForward); + double expectedSide = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetSide); + double expectedUp = ProjectExtent(localSize, rotatedLocalX, rotatedLocalY, rotatedLocalZ, targetUp); + + Assert.AreEqual(expectedForward, extents.forwardExtent, 1e-5); + Assert.AreEqual(expectedSide, extents.sideExtent, 1e-5); + Assert.AreEqual(expectedUp, extents.upExtent, 1e-5); + Assert.AreNotEqual(4.0, extents.upExtent, 1e-3, "双轴旋转后,法向尺寸不应仍停留在单轴结果。"); + } + + private static double ProjectExtent( + Vector3 localSize, + Vector3 rotatedLocalX, + Vector3 rotatedLocalY, + Vector3 rotatedLocalZ, + Vector3 targetAxis) + { + return Math.Abs(Vector3.Dot(rotatedLocalX, targetAxis)) * localSize.X + + Math.Abs(Vector3.Dot(rotatedLocalY, targetAxis)) * localSize.Y + + Math.Abs(Vector3.Dot(rotatedLocalZ, targetAxis)) * localSize.Z; + } + } +} diff --git a/UnitTests/CoordinateSystem/RealObjectReferencePoseResolverTests.cs b/UnitTests/CoordinateSystem/RealObjectReferencePoseResolverTests.cs new file mode 100644 index 0000000..0faa079 --- /dev/null +++ b/UnitTests/CoordinateSystem/RealObjectReferencePoseResolverTests.cs @@ -0,0 +1,160 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Collections.Generic; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RealObjectReferencePoseResolverTests + { + [TestMethod] + public void YUp_HostWithFragmentDefaultZ_ShouldInterpretRepresentativeFrameToHostSemantics() + { + var fragmentMatrices = new List + { + CreateMatrix( + axisX: new Vector3(1.0f, 0.0f, 0.0f), + axisY: new Vector3(0.0f, 1.0f, 0.0f), + axisZ: new Vector3(0.0f, 0.0f, 1.0f)) + }; + + bool ok = RealObjectReferencePoseResolver.TryResolveFromFragmentMatrices( + fragmentMatrices, + fragmentDefaultUpAxis: "Z", + hostUpAxis: "Y", + out RealObjectReferencePose pose); + + Assert.IsTrue(ok); + AssertVector(pose.RawAxisX, 1.0, 0.0, 0.0); + AssertVector(pose.RawAxisY, 0.0, 1.0, 0.0); + AssertVector(pose.RawAxisZ, 0.0, 0.0, 1.0); + AssertVector(pose.AxisX, 1.0, 0.0, 0.0); + AssertVector(pose.AxisY, 0.0, 0.0, 1.0); + AssertVector(pose.AxisZ, 0.0, -1.0, 0.0); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostWorldXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, pose.HostWorldYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostWorldZAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostSemanticXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostSemanticYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, pose.HostSemanticZAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostUpLocalAxis); + } + + [TestMethod] + public void YUp_WorldAxisMapping_ShouldFollowRawRepresentativeFrame() + { + var fragmentMatrices = new List + { + CreateMatrix( + axisX: new Vector3(0.0f, 0.0f, 1.0f), + axisY: new Vector3(1.0f, 0.0f, 0.0f), + axisZ: new Vector3(0.0f, 1.0f, 0.0f)) + }; + + bool ok = RealObjectReferencePoseResolver.TryResolveFromFragmentMatrices( + fragmentMatrices, + fragmentDefaultUpAxis: "Z", + hostUpAxis: "Y", + out RealObjectReferencePose pose); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveY, pose.HostWorldXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostWorldYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostWorldZAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostSemanticXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostSemanticYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, pose.HostSemanticZAxisLocalAxis); + } + + [TestMethod] + public void DetectDefaultUpAxis_ShouldReturnZ_WhenRepresentativeZMatchesHostUp() + { + var fragmentMatrices = new List + { + CreateMatrix( + axisX: new Vector3(1.0f, 0.0f, 0.0f), + axisY: new Vector3(0.0f, 0.0f, -1.0f), + axisZ: new Vector3(0.0f, 1.0f, 0.0f)) + }; + + bool ok = RealObjectReferencePoseResolver.TryDetectDefaultUpAxis( + fragmentMatrices, + "Y", + out string detectedAxis, + out float yAlignment, + out float zAlignment); + + Assert.IsTrue(ok); + Assert.AreEqual("Z", detectedAxis); + Assert.AreEqual(0.0, yAlignment, 1e-6); + Assert.AreEqual(1.0, zAlignment, 1e-6); + } + + [TestMethod] + public void YUp_WorldAxisMapping_ShouldAcceptSlightlyRotatedRawFragmentFrame() + { + var fragmentMatrices = new List + { + CreateMatrix( + axisX: Vector3.Normalize(new Vector3(0.9980f, 0.0638f, 0.0f)), + axisY: new Vector3(0.0f, 0.0f, -1.0f), + axisZ: Vector3.Normalize(new Vector3(-0.0638f, 0.9980f, 0.0f))) + }; + + bool ok = RealObjectReferencePoseResolver.TryResolveFromFragmentMatrices( + fragmentMatrices, + fragmentDefaultUpAxis: "Y", + hostUpAxis: "Y", + out RealObjectReferencePose pose); + + Assert.IsTrue(ok); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostWorldXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostWorldYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.NegativeY, pose.HostWorldZAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveX, pose.HostSemanticXAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, pose.HostSemanticYAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveZ, pose.HostSemanticZAxisLocalAxis); + Assert.AreEqual(LocalAxisDirection.PositiveY, pose.HostUpLocalAxis); + } + + [TestMethod] + public void YUp_WorldAxisMapping_ShouldRejectAmbiguousRawFragmentFrame() + { + var fragmentMatrices = new List + { + CreateMatrix( + axisX: Vector3.Normalize(new Vector3(0.80f, 0.60f, 0.0f)), + axisY: new Vector3(0.0f, 0.0f, -1.0f), + axisZ: Vector3.Normalize(new Vector3(-0.60f, 0.80f, 0.0f))) + }; + + bool ok = RealObjectReferencePoseResolver.TryResolveFromFragmentMatrices( + fragmentMatrices, + fragmentDefaultUpAxis: "Y", + hostUpAxis: "Y", + out RealObjectReferencePose pose); + + Assert.IsFalse(ok); + Assert.IsNull(pose); + } + + private static double[] CreateMatrix(Vector3 axisX, Vector3 axisY, Vector3 axisZ) + { + return new double[] + { + axisX.X, axisX.Y, axisX.Z, 0.0, + axisY.X, axisY.Y, axisY.Z, 0.0, + axisZ.X, axisZ.Y, axisZ.Z, 0.0, + 0.0, 0.0, 0.0, 1.0 + }; + } + + private static void AssertVector(Vector3 actual, double x, double y, double z, double tolerance = 1e-6) + { + Assert.AreEqual(x, actual.X, tolerance); + Assert.AreEqual(y, actual.Y, tolerance); + Assert.AreEqual(z, actual.Z, tolerance); + } + } +} diff --git a/UnitTests/CoordinateSystem/RotatedObjectExtentHelperTests.cs b/UnitTests/CoordinateSystem/RotatedObjectExtentHelperTests.cs new file mode 100644 index 0000000..ca93b9b --- /dev/null +++ b/UnitTests/CoordinateSystem/RotatedObjectExtentHelperTests.cs @@ -0,0 +1,322 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class RotatedObjectExtentHelperTests + { + [TestMethod] + public void YUp_HostY90_ForRealObject_ShouldKeepUpExtentUnchanged() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + Quaternion correction = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + var result = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + convention, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + correctionQuaternion: correction); + + Assert.AreEqual(4.0, result.forwardExtent, 1e-6); + Assert.AreEqual(6.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void YUp_HostZ90_ForRealObject_ShouldPromoteForwardSizeToUpExtent() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + Quaternion correction = adapter.CreateHostRotationCorrection( + new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + var result = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + convention, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + correctionQuaternion: correction); + + Assert.AreEqual(2.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(6.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void ZUp_HostY90_ShouldPromoteForwardSizeToUpExtent() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + Quaternion correction = adapter.CreateCanonicalRotationCorrection( + new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + var result = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + convention, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + correctionQuaternion: correction); + + Assert.AreEqual(2.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(6.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void ZUp_HostZ90_ShouldKeepUpExtentUnchanged() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp); + Quaternion correction = adapter.CreateCanonicalRotationCorrection( + new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + var result = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + convention, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + correctionQuaternion: correction); + + Assert.AreEqual(4.0, result.forwardExtent, 1e-6); + Assert.AreEqual(6.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostY90_ShouldSwapForwardAndSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 90.0, 0.0)); + + Assert.AreEqual(4.0, result.forwardExtent, 1e-6); + Assert.AreEqual(6.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostY45_ShouldBlendForwardAndSide_AndKeepUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 45.0, 0.0)); + + double expectedPlanar = 5.0 * Math.Sqrt(2.0); + Assert.AreEqual(expectedPlanar, result.forwardExtent, 1e-6); + Assert.AreEqual(expectedPlanar, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostY135_ShouldBlendForwardAndSide_AndKeepUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 135.0, 0.0)); + + double expectedPlanar = 5.0 * Math.Sqrt(2.0); + Assert.AreEqual(expectedPlanar, result.forwardExtent, 1e-6); + Assert.AreEqual(expectedPlanar, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostY180_ShouldKeepSemanticExtents() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 180.0, 0.0)); + + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostY270_ShouldSwapForwardAndSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 270.0, 0.0)); + + Assert.AreEqual(4.0, result.forwardExtent, 1e-6); + Assert.AreEqual(6.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostX90_ShouldKeepForward_AndSwapSideWithUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(90.0, 0.0, 0.0)); + + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(2.0, result.sideExtent, 1e-6); + Assert.AreEqual(4.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostX45_ShouldKeepForward_AndBlendSideWithUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(45.0, 0.0, 0.0)); + + double expectedBlend = 3.0 * Math.Sqrt(2.0); + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.sideExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostX135_ShouldKeepForward_AndBlendSideWithUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(135.0, 0.0, 0.0)); + + double expectedBlend = 3.0 * Math.Sqrt(2.0); + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.sideExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostX180_ShouldKeepSemanticExtents() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(180.0, 0.0, 0.0)); + + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostX270_ShouldKeepForward_AndSwapSideWithUp() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(270.0, 0.0, 0.0)); + + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(2.0, result.sideExtent, 1e-6); + Assert.AreEqual(4.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostZ90_ShouldPromoteUpToForward_AndKeepSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 0.0, 90.0)); + + Assert.AreEqual(2.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(6.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostZ45_ShouldBlendForwardWithUp_AndKeepSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 0.0, 45.0)); + + double expectedBlend = 4.0 * Math.Sqrt(2.0); + Assert.AreEqual(expectedBlend, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostZ135_ShouldBlendForwardWithUp_AndKeepSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 0.0, 135.0)); + + double expectedBlend = 4.0 * Math.Sqrt(2.0); + Assert.AreEqual(expectedBlend, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(expectedBlend, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostZ180_ShouldKeepSemanticExtents() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 0.0, 180.0)); + + Assert.AreEqual(6.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(2.0, result.upExtent, 1e-6); + } + + [TestMethod] + public void Ground_YUp_HostZ270_ShouldPromoteUpToForward_AndKeepSide() + { + var result = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + CoordinateSystemType.YUp, + forwardSize: 6.0, + sideSize: 4.0, + upSize: 2.0, + hostCorrection: new LocalEulerRotationCorrection(0.0, 0.0, 270.0)); + + Assert.AreEqual(2.0, result.forwardExtent, 1e-6); + Assert.AreEqual(4.0, result.sideExtent, 1e-6); + Assert.AreEqual(6.0, result.upExtent, 1e-6); + } + } +} diff --git a/UnitTests/CoordinateSystem/ViewpointHelperTests.cs b/UnitTests/CoordinateSystem/ViewpointHelperTests.cs new file mode 100644 index 0000000..168e72a --- /dev/null +++ b/UnitTests/CoordinateSystem/ViewpointHelperTests.cs @@ -0,0 +1,85 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils; +using System.Numerics; +using NavisworksTransport.Core.Config; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class ViewpointHelperTests + { + [TestMethod] + public void ResolvePathViewpointProfile_ShouldReturnExpectedDefaults() + { + var config = ConfigManager.Instance.Current.PathEditing; + var ground = ViewpointHelper.ResolvePathViewpointProfile(ViewpointHelper.ViewpointStrategy.PathGroundSelection); + var hoisting = ViewpointHelper.ResolvePathViewpointProfile(ViewpointHelper.ViewpointStrategy.PathHoistingSelection); + var rail = ViewpointHelper.ResolvePathViewpointProfile(ViewpointHelper.ViewpointStrategy.PathRailSelection); + + Assert.AreEqual(12.0, ground.CameraDistanceMeters, 1e-9); + Assert.AreEqual(90.0, ground.ElevationDegrees, 1e-9); + + Assert.AreEqual(config.HoistingViewDistanceMeters, hoisting.CameraDistanceMeters, 1e-9); + Assert.AreEqual(config.HoistingViewElevationDegrees, hoisting.ElevationDegrees, 1e-9); + Assert.AreEqual(ViewpointHelper.PathCameraHorizontalMode.Side, hoisting.HorizontalMode); + + Assert.AreEqual(config.RailViewDistanceMeters, rail.CameraDistanceMeters, 1e-9); + Assert.AreEqual(config.RailViewElevationDegrees, rail.ElevationDegrees, 1e-9); + Assert.AreEqual(ViewpointHelper.PathCameraHorizontalMode.Side, rail.HorizontalMode); + } + + [TestMethod] + public void ResolveCameraOffsetDirection_Hoisting_ShouldLookFromSideAndSlightlyAbove() + { + Vector3 hostUp = Vector3.UnitZ; + Vector3 forward = Vector3.UnitX; + var profile = ViewpointHelper.ResolvePathViewpointProfile(ViewpointHelper.ViewpointStrategy.PathHoistingSelection); + + Vector3 offset = ViewpointHelper.ResolveCameraOffsetDirection(profile, hostUp, forward); + + Assert.AreEqual(0.0f, offset.X, 1e-5f); + Assert.IsTrue(offset.Y > 0.0f); + Assert.IsTrue(offset.Z > 0.0f); + } + + [TestMethod] + public void ResolveCameraOffsetDirection_Rail_ShouldLookFromSideAndSlightlyAbove() + { + Vector3 hostUp = Vector3.UnitZ; + Vector3 forward = Vector3.UnitX; + var profile = ViewpointHelper.ResolvePathViewpointProfile(ViewpointHelper.ViewpointStrategy.PathRailSelection); + + Vector3 offset = ViewpointHelper.ResolveCameraOffsetDirection(profile, hostUp, forward); + + Assert.AreEqual(0.0f, offset.X, 1e-5f); + Assert.IsTrue(offset.Y > 0.0f); + Assert.IsTrue(offset.Z > 0.0f); + } + + [TestMethod] + public void ResolveHorizontalPathForward_ShouldFallbackWhenPathIsVertical() + { + Vector3 hostUp = Vector3.UnitZ; + Vector3 rawVertical = Vector3.UnitZ; + Vector3 fallback = Vector3.UnitY; + + Vector3 resolved = ViewpointHelper.ResolveHorizontalPathForward(rawVertical, hostUp, fallback); + + Assert.AreEqual(0.0f, resolved.Z, 1e-5f); + Assert.AreEqual(1.0f, resolved.Y, 1e-5f); + } + + [TestMethod] + public void ResolveFocusViewpointProfile_ShouldCentralizeNamedFocusStrategies() + { + var modelFocus = ViewpointHelper.ResolveFocusViewpointProfile(ViewpointHelper.ViewpointStrategy.ModelFocus); + var collision = ViewpointHelper.ResolveFocusViewpointProfile(ViewpointHelper.ViewpointStrategy.CollisionCloseUp); + + Assert.AreEqual(60.0, modelFocus.ViewAngleDegrees, 1e-9); + Assert.AreEqual(0.25, modelFocus.TargetViewRatio, 1e-9); + + Assert.AreEqual(60.0, collision.ViewAngleDegrees, 1e-9); + Assert.AreEqual(0.25, collision.TargetViewRatio, 1e-9); + } + } +} diff --git a/UnitTests/CoordinateSystem/VirtualGroundPoseCharacterizationTests.cs b/UnitTests/CoordinateSystem/VirtualGroundPoseCharacterizationTests.cs new file mode 100644 index 0000000..f94d79a --- /dev/null +++ b/UnitTests/CoordinateSystem/VirtualGroundPoseCharacterizationTests.cs @@ -0,0 +1,120 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils.CoordinateSystem; +using System; +using System.Numerics; +using Autodesk.Navisworks.Api; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class VirtualGroundPoseCharacterizationTests + { + [TestMethod] + public void YUp_GroundVirtualPose_WithYUpVirtualAsset_ShouldUseHostYAsUp() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var convention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + + Vector3 hostForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Vector3 canonicalForward = adapter.ToCanonicalVector3(hostForward); + + bool ok = CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + convention, + out Quaternion canonicalRotation); + + Assert.IsTrue(ok); + + Matrix4x4 hostLinear = adapter.FromCanonicalLinearTransform( + Matrix4x4.CreateFromQuaternion(canonicalRotation)); + + AssertColumn(hostLinear, 0, -0.8987, 0.0000, 0.4386); + AssertColumn(hostLinear, 1, 0.0000, 1.0000, 0.0000); + AssertColumn(hostLinear, 2, -0.4386, 0.0000, -0.8987); + } + + [TestMethod] + public void YUp_GroundVirtualPose_WithYUpVirtualAsset_ShouldMatchHostYUpDefaultPlanarConvention() + { + var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + Vector3 hostForward = Vector3.Normalize(new Vector3(-0.8987f, 0.0f, 0.4386f)); + Vector3 canonicalForward = adapter.ToCanonicalVector3(hostForward); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp), + out Quaternion assetRotation)); + + Assert.IsTrue(CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp), + out Quaternion hostDefaultRotation)); + + Matrix4x4 assetHostLinear = adapter.FromCanonicalLinearTransform( + Matrix4x4.CreateFromQuaternion(assetRotation)); + Matrix4x4 hostDefaultLinear = adapter.FromCanonicalLinearTransform( + Matrix4x4.CreateFromQuaternion(hostDefaultRotation)); + + Assert.AreEqual(1.0, assetHostLinear.M22, 1e-3, "YUp 虚拟物体资源应把 local Y 对到宿主 up。"); + Assert.AreEqual(hostDefaultLinear.M11, assetHostLinear.M11, 1e-3); + Assert.AreEqual(hostDefaultLinear.M21, assetHostLinear.M21, 1e-3); + Assert.AreEqual(hostDefaultLinear.M31, assetHostLinear.M31, 1e-3); + Assert.AreEqual(hostDefaultLinear.M12, assetHostLinear.M12, 1e-3); + Assert.AreEqual(hostDefaultLinear.M22, assetHostLinear.M22, 1e-3); + Assert.AreEqual(hostDefaultLinear.M32, assetHostLinear.M32, 1e-3); + Assert.AreEqual(hostDefaultLinear.M13, assetHostLinear.M13, 1e-3); + Assert.AreEqual(hostDefaultLinear.M23, assetHostLinear.M23, 1e-3); + Assert.AreEqual(hostDefaultLinear.M33, assetHostLinear.M33, 1e-3); + } + + [TestMethod] + public void VirtualObjectReferencePose_ShouldMatchSelectedVirtualAssetConvention() + { + var yUpAdapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + var zUpAdapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + var yUpConvention = ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.YUp); + var zUpConvention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); + + Matrix4x4 yUpLinear = Matrix4x4.CreateFromQuaternion( + yUpConvention.CreateQuaternion(new Vector3(1, 0, 0), yUpAdapter.HostUpVector3)); + Matrix4x4 zUpLinear = Matrix4x4.CreateFromQuaternion( + zUpConvention.CreateQuaternion(new Vector3(1, 0, 0), zUpAdapter.HostUpVector3)); + + AssertColumn(yUpLinear, 0, 1.0, 0.0, 0.0); + AssertColumn(yUpLinear, 1, 0.0, 1.0, 0.0); + AssertColumn(yUpLinear, 2, 0.0, 0.0, 1.0); + + AssertColumn(zUpLinear, 0, 1.0, 0.0, 0.0); + AssertColumn(zUpLinear, 1, 0.0, 1.0, 0.0); + AssertColumn(zUpLinear, 2, 0.0, 0.0, 1.0); + } + + private static void AssertColumn(Matrix4x4 matrix, int column, double x, double y, double z) + { + switch (column) + { + case 0: + Assert.AreEqual(x, matrix.M11, 1e-3); + Assert.AreEqual(y, matrix.M21, 1e-3); + Assert.AreEqual(z, matrix.M31, 1e-3); + break; + case 1: + Assert.AreEqual(x, matrix.M12, 1e-3); + Assert.AreEqual(y, matrix.M22, 1e-3); + Assert.AreEqual(z, matrix.M32, 1e-3); + break; + case 2: + Assert.AreEqual(x, matrix.M13, 1e-3); + Assert.AreEqual(y, matrix.M23, 1e-3); + Assert.AreEqual(z, matrix.M33, 1e-3); + break; + default: + Assert.Fail("Only first 3 columns are valid."); + break; + } + } + } +} diff --git a/UnitTests/CoordinateSystem/VirtualObjectModelTransformTests.cs b/UnitTests/CoordinateSystem/VirtualObjectModelTransformTests.cs new file mode 100644 index 0000000..e571818 --- /dev/null +++ b/UnitTests/CoordinateSystem/VirtualObjectModelTransformTests.cs @@ -0,0 +1,100 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Core; + +namespace NavisworksTransport.UnitTests.CoordinateSystem +{ + [TestClass] + public class VirtualObjectModelTransformTests + { + [TestMethod] + public void BuildModelTransformPreservingScale_ShouldKeepExistingScaleAndReplaceRotationTranslation() + { + const double scaleX = 4.9213; + const double scaleY = 3.2808; + const double scaleZ = 6.5617; + var targetRotation = new QuaternionData(0.0, 0.7071068, 0.0, 0.7071068); + + VirtualObjectManager.ModelTransformComponents result = VirtualObjectManager.CreateModelTransformPreservingScale( + scaleX, + scaleY, + scaleZ, + -177.129, + 18.114, + -2.793, + targetRotation.ToRotation3D()); + + Assert.AreEqual(scaleX, result.ScaleX, 1e-4); + Assert.AreEqual(scaleY, result.ScaleY, 1e-4); + Assert.AreEqual(scaleZ, result.ScaleZ, 1e-4); + AssertRotationEquivalent(targetRotation, result.Rotation); + Assert.AreEqual(-177.129, result.TranslationX, 1e-6); + Assert.AreEqual(18.114, result.TranslationY, 1e-6); + Assert.AreEqual(-2.793, result.TranslationZ, 1e-6); + } + + [TestMethod] + public void BuildModelTransformPreservingScale_ResetPose_ShouldOnlyClearRotationAndTranslation() + { + VirtualObjectManager.ModelTransformComponents result = VirtualObjectManager.CreateModelTransformPreservingScale( + 12.0, + 5.0, + 7.0, + 0.0, + 0.0, + 0.0, + QuaternionData.Identity.ToRotation3D()); + + Assert.AreEqual(12.0, result.ScaleX, 1e-6); + Assert.AreEqual(5.0, result.ScaleY, 1e-6); + Assert.AreEqual(7.0, result.ScaleZ, 1e-6); + AssertRotationEquivalent(QuaternionData.Identity, result.Rotation); + Assert.AreEqual(0.0, result.TranslationX, 1e-6); + Assert.AreEqual(0.0, result.TranslationY, 1e-6); + Assert.AreEqual(0.0, result.TranslationZ, 1e-6); + } + + private static void AssertRotationEquivalent(QuaternionData expected, Autodesk.Navisworks.Api.Rotation3D actual) + { + bool same = + NearlyEqual(expected.X, actual.A) && + NearlyEqual(expected.Y, actual.B) && + NearlyEqual(expected.Z, actual.C) && + NearlyEqual(expected.W, actual.D); + bool negatedSame = + NearlyEqual(expected.X, -actual.A) && + NearlyEqual(expected.Y, -actual.B) && + NearlyEqual(expected.Z, -actual.C) && + NearlyEqual(expected.W, -actual.D); + + Assert.IsTrue(same || negatedSame, "Rotation3D quaternion should match target rotation up to sign."); + } + + private static bool NearlyEqual(double left, double right) + { + return System.Math.Abs(left - right) < 1e-6; + } + + private struct QuaternionData + { + public QuaternionData(double x, double y, double z, double w) + { + X = x; + Y = y; + Z = z; + W = w; + } + + public double X { get; } + public double Y { get; } + public double Z { get; } + public double W { get; } + + public Autodesk.Navisworks.Api.Rotation3D ToRotation3D() + { + return new Autodesk.Navisworks.Api.Rotation3D(X, Y, Z, W); + } + + public static QuaternionData Identity => new QuaternionData(0, 0, 0, 1); + } + } +} diff --git a/UnitTests/Core/PathHelperTests.cs b/UnitTests/Core/PathHelperTests.cs new file mode 100644 index 0000000..9949ea5 --- /dev/null +++ b/UnitTests/Core/PathHelperTests.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils; + +namespace NavisworksTransport.UnitTests.Core +{ + [TestClass] + public class PathHelperTests + { + [TestMethod] + public void BuildDuplicatedPathName_ShouldRebuildTrailingTimestamp() + { + var now = new DateTime(2026, 3, 28, 9, 45, 12); + + var duplicatedName = PathHelper.BuildDuplicatedPathName("人工_0328_081530", now); + + Assert.AreEqual("副本_人工_0328_094512", duplicatedName); + } + + [TestMethod] + public void BuildDuplicatedPathName_ShouldKeepPlainNameWithoutTimestamp() + { + var duplicatedName = PathHelper.BuildDuplicatedPathName("我的路径"); + + Assert.AreEqual("副本_我的路径", duplicatedName); + } + + [TestMethod] + public void GenerateCollisionReportFileName_ShouldUseUnifiedChinesePrefixAndTimestamp() + { + var now = new DateTime(2026, 4, 9, 23, 55, 1); + + var fileName = PathHelper.GenerateCollisionReportFileName("人工_0409_235000", "html", now); + + Assert.AreEqual("碰撞检测报告_人工_0409_235000_20260409_235501.html", fileName); + } + } +} diff --git a/UnitTests/Core/PathPersistenceTests.cs b/UnitTests/Core/PathPersistenceTests.cs new file mode 100644 index 0000000..5830002 --- /dev/null +++ b/UnitTests/Core/PathPersistenceTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NavisworksTransport.UnitTests.Core +{ + [TestClass] + public class PathPersistenceTests + { + [TestMethod] + public void PathDataManager_ShouldImport_RailPreferredNormal_FromJson() + { + var tempDir = Path.Combine(Path.GetTempPath(), "NavisworksTransportTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var filePath = Path.Combine(tempDir, "route.json"); + + try + { + File.WriteAllText(filePath, +@"{ + ""PathPlanningData"": { + ""version"": ""1.0"", + ""generator"": ""test"", + ""timestamp"": ""2026-03-25T00:00:00"", + ""ProjectInfo"": { + ""name"": ""test"", + ""description"": ""test"", + ""units"": ""meters"", + ""coordinateSystem"": ""Global"" + }, + ""Routes"": [ + { + ""id"": ""route-1"", + ""name"": ""rail-test"", + ""description"": """", + ""pathType"": ""Rail"", + ""railMountMode"": ""OverRail"", + ""railPathDefinitionMode"": ""RailCenterLine"", + ""railNormalOffset"": 0.25, + ""railPreferredNormal"": { ""x"": -0.2, ""y"": 0.5, ""z"": 0.8 }, + ""totalLength"": 10.0, + ""objectLimits"": { ""maxLength"": 0, ""maxWidth"": 0, ""maxHeight"": 0, ""safetyMargin"": 0 }, + ""gridSize"": 1.0, + ""liftHeight"": 0.0, + ""created"": ""2026-03-25T00:00:00"", + ""points"": [ + { ""id"": ""p1"", ""name"": ""start"", ""type"": ""StartPoint"", ""index"": 0, ""x"": 0, ""y"": 0, ""z"": 0, ""created"": ""2026-03-25T00:00:00"" }, + { ""id"": ""p2"", ""name"": ""end"", ""type"": ""EndPoint"", ""index"": 1, ""x"": 10, ""y"": 0, ""z"": 0, ""created"": ""2026-03-25T00:00:00"" } + ] + } + ] + } +}"); + + var manager = new PathDataManager(); + var importedRoutes = manager.ImportFromJson(filePath); + Assert.AreEqual(1, importedRoutes.Count); + Assert.IsNotNull(importedRoutes[0].RailPreferredNormal); + Assert.AreEqual(0.25, importedRoutes[0].RailNormalOffset, 1e-6); + } + finally + { + SafeDelete(tempDir); + } + } + + [TestMethod] + public void PathDataManager_ShouldImport_RailPreferredNormal_FromXml() + { + var tempDir = Path.Combine(Path.GetTempPath(), "NavisworksTransportTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var filePath = Path.Combine(tempDir, "route.xml"); + + try + { + File.WriteAllText(filePath, +@" + + + + + + + + + + +"); + + var manager = new PathDataManager(); + var importedRoutes = manager.ImportFromXml(filePath); + Assert.AreEqual(1, importedRoutes.Count); + Assert.IsNotNull(importedRoutes[0].RailPreferredNormal); + Assert.AreEqual(0.25, importedRoutes[0].RailNormalOffset, 1e-6); + } + finally + { + SafeDelete(tempDir); + } + } + + private static void SafeDelete(string directory) + { + if (!Directory.Exists(directory)) + { + return; + } + + try + { + Directory.Delete(directory, true); + } + catch + { + } + } + } +} diff --git a/UnitTests/Core/PathPlanningManagerHoistingCompletionTests.cs b/UnitTests/Core/PathPlanningManagerHoistingCompletionTests.cs new file mode 100644 index 0000000..537f574 --- /dev/null +++ b/UnitTests/Core/PathPlanningManagerHoistingCompletionTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NavisworksTransport.UnitTests.Core +{ + [TestClass] + public class PathPlanningManagerHoistingCompletionTests + { + [TestMethod] + public void ReuseLastHoistingPointAsDescendPoint_ShouldPreserveGeometryAndAvoidDuplicateSegment_FromRealLogCase() + { + var originalLastAerialPointPosition = new Point3D(-132.36, -16.89, -6.95); + var pathPoints = new List + { + new PathPoint(new Point3D(-140.00, -30.01, -6.95), "起吊点", PathPointType.StartPoint) { Index = 0, Direction = HoistingPointDirection.Vertical }, + new PathPoint(new Point3D(-140.00, -16.89, -6.95), "提升点", PathPointType.WayPoint) { Index = 1, Direction = HoistingPointDirection.Vertical }, + new PathPoint(new Point3D(-150.00, -16.89, -6.95), "路径点7", PathPointType.WayPoint) { Index = 2, Direction = HoistingPointDirection.Longitudinal }, + new PathPoint(originalLastAerialPointPosition, "路径点8", PathPointType.WayPoint) { Index = 3, Direction = HoistingPointDirection.Longitudinal } + }; + + var finalGroundPoint = new Point3D(-132.36, -30.01, -6.95); + var originalLastPoint = pathPoints[3]; + + PathPoint descendPoint = PathPlanningManager.ReuseLastHoistingPointAsDescendPoint(pathPoints); + var endPoint = new PathPoint(finalGroundPoint, "落地点", PathPointType.EndPoint) + { + Index = pathPoints.Count, + Direction = HoistingPointDirection.Vertical + }; + pathPoints.Add(endPoint); + + Assert.AreEqual(5, pathPoints.Count); + Assert.AreSame(pathPoints[3], descendPoint); + Assert.AreSame(originalLastPoint, descendPoint); + Assert.AreEqual("下降点", pathPoints[3].Name); + Assert.AreEqual(PathPointType.WayPoint, pathPoints[3].Type); + Assert.AreEqual(HoistingPointDirection.Vertical, pathPoints[3].Direction); + Assert.AreSame(originalLastAerialPointPosition, pathPoints[3].Position); + + Assert.AreEqual("落地点", pathPoints[4].Name); + Assert.AreEqual(PathPointType.EndPoint, pathPoints[4].Type); + Assert.AreEqual(HoistingPointDirection.Vertical, pathPoints[4].Direction); + Assert.AreSame(finalGroundPoint, pathPoints[4].Position); + } + } +} diff --git a/UnitTests/Core/PathRouteCloneTests.cs b/UnitTests/Core/PathRouteCloneTests.cs new file mode 100644 index 0000000..a1e5c75 --- /dev/null +++ b/UnitTests/Core/PathRouteCloneTests.cs @@ -0,0 +1,71 @@ +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NavisworksTransport.UnitTests.Core +{ + [TestClass] + public class PathRouteCloneTests + { + [TestMethod] + public void Clone_ShouldPreserveRailSpecificPropertiesAndGenerateNewIds() + { + var route = new PathRoute("原路径") + { + PathType = PathType.Rail, + RailMountMode = RailMountMode.OverRail, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine, + RailNormalOffset = 12.5, + RailPreferredNormal = new Point3D(0, 1, 0) + }; + + route.Points.Add(new PathPoint(new Point3D(1, 2, 3), "起点", PathPointType.StartPoint)); + route.Points.Add(new PathPoint(new Point3D(4, 5, 6), "终点", PathPointType.EndPoint)); + + var clonedRoute = route.Clone(); + + Assert.AreEqual(PathType.Rail, clonedRoute.PathType); + Assert.AreEqual(RailMountMode.OverRail, clonedRoute.RailMountMode); + Assert.AreEqual(RailPathDefinitionMode.RailCenterLine, clonedRoute.RailPathDefinitionMode); + Assert.AreEqual(12.5, clonedRoute.RailNormalOffset, 1e-6); + Assert.IsNotNull(clonedRoute.RailPreferredNormal); + Assert.AreNotEqual(route.Id, clonedRoute.Id); + Assert.AreEqual(2, clonedRoute.Points.Count); + Assert.AreNotEqual(route.Points[0].Id, clonedRoute.Points[0].Id); + Assert.AreEqual(route.Points[0].Name, clonedRoute.Points[0].Name); + Assert.AreEqual(route.Points[0].Type, clonedRoute.Points[0].Type); + } + + [TestMethod] + public void Clone_ShouldCopyGroundEdgesAndRemapPointReferences() + { + var route = new PathRoute("地面路径") + { + PathType = PathType.Ground, + TurnRadius = 3.0, + IsCurved = true + }; + + var startPoint = new PathPoint(new Point3D(0, 0, 0), "起点", PathPointType.StartPoint); + var endPoint = new PathPoint(new Point3D(10, 0, 0), "终点", PathPointType.EndPoint); + route.Points.Add(startPoint); + route.Points.Add(endPoint); + route.Edges.Add(new PathEdge + { + StartPointId = startPoint.Id, + EndPointId = endPoint.Id, + SegmentType = PathSegmentType.Straight, + PhysicalLength = 10.0 + }); + + var clonedRoute = route.Clone(); + + Assert.AreEqual(PathType.Ground, clonedRoute.PathType); + Assert.AreEqual(1, clonedRoute.Edges.Count); + Assert.AreEqual(3.0, clonedRoute.TurnRadius, 1e-6); + Assert.IsTrue(clonedRoute.IsCurved); + Assert.AreEqual(clonedRoute.Points[0].Id, clonedRoute.Edges[0].StartPointId); + Assert.AreEqual(clonedRoute.Points[1].Id, clonedRoute.Edges[0].EndPointId); + Assert.AreNotEqual(route.Edges[0].Id, clonedRoute.Edges[0].Id); + } + } +} diff --git a/UnitTests/Core/SelectionClipBoxLockStateTests.cs b/UnitTests/Core/SelectionClipBoxLockStateTests.cs new file mode 100644 index 0000000..c4a5a23 --- /dev/null +++ b/UnitTests/Core/SelectionClipBoxLockStateTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils; + +namespace NavisworksTransport.UnitTests.Core +{ + [TestClass] + public class SelectionClipBoxLockStateTests + { + [TestMethod] + public void ShouldRefreshOnSelectionChanged_ReturnsFalse_WhenSelectionClipBoxIsLocked() + { + var state = new SelectionClipBoxLockState(); + state.LockSelection(new object[] { new object(), new object() }); + + bool shouldRefresh = state.ShouldRefreshOnSelectionChanged(isSelectionClipBoxMode: true); + + Assert.IsFalse(shouldRefresh); + Assert.AreEqual(2, state.LockedSelectionCount); + } + + [TestMethod] + public void ShouldRefreshOnSelectionChanged_ReturnsTrue_WhenSelectionClipBoxModeHasNoLockedSelection() + { + var state = new SelectionClipBoxLockState(); + + bool shouldRefresh = state.ShouldRefreshOnSelectionChanged(isSelectionClipBoxMode: true); + + Assert.IsTrue(shouldRefresh); + Assert.AreEqual(0, state.LockedSelectionCount); + } + + [TestMethod] + public void Clear_UnlocksSelectionClipBoxState() + { + var state = new SelectionClipBoxLockState(); + state.LockSelection(new List { new object() }); + + state.Clear(); + + Assert.IsFalse(state.IsLocked); + Assert.AreEqual(0, state.LockedSelectionCount); + } + } +} diff --git a/UnitTests/Integration/AutoPathGridGenerationAutomationTests.cs b/UnitTests/Integration/AutoPathGridGenerationAutomationTests.cs new file mode 100644 index 0000000..d306237 --- /dev/null +++ b/UnitTests/Integration/AutoPathGridGenerationAutomationTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + [TestClass] + [TestCategory("NavisworksIntegration")] + [TestCategory("NavisworksIntegration.Ground")] + public class AutoPathGridGenerationAutomationTests + { + private const string BaselineRouteName = "测试基准"; + private const double BaselineObjectLengthInMeters = 0.4; + private const double BaselineObjectWidthInMeters = 0.4; + private const double BaselineSafetyMarginInMeters = 0.05; + private const double BaselineGridSizeInMeters = 0.3; + + [TestMethod] + [Timeout(240000)] + public async Task GroundAutoPath_BaselineRoute_CreatesPath() + { + using (var client = new NavisworksTestAutomationClient()) + { + await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false); + + JObject response = await client.RunAutoPathAsync( + "Ground", + BaselineRouteName, + BaselineObjectLengthInMeters, + BaselineObjectWidthInMeters, + BaselineSafetyMarginInMeters, + BaselineGridSizeInMeters).ConfigureAwait(false); + Assert.IsTrue(response.Value("ok"), "测试 HTTP 接口返回失败: " + (string)response["error"]); + + JObject data = (JObject)response["data"]; + Assert.IsNotNull(data, "缺少测试结果 data"); + + JObject sourceRoute = (JObject)data["sourceRoute"]; + Assert.IsNotNull(sourceRoute, "缺少 sourceRoute"); + Assert.AreEqual(BaselineRouteName, (string)sourceRoute["name"], "自动路径测试必须使用精确命名的测试基准路径作为起终点来源"); + Assert.AreEqual("Ground", (string)sourceRoute["pathType"], "源路径类型不匹配"); + + JObject generatedRoute = (JObject)data["generatedRoute"]; + Assert.IsNotNull(generatedRoute, "缺少 generatedRoute"); + Assert.AreEqual("Ground", (string)generatedRoute["pathType"], "生成路径类型不匹配"); + Assert.IsTrue((int)generatedRoute["pointCount"] >= 2, "生成路径至少应包含起点和终点"); + Assert.IsTrue((double)generatedRoute["length"] > 0, "生成路径长度必须大于 0"); + + JObject planningParameters = (JObject)data["planningParameters"]; + Assert.IsNotNull(planningParameters, "缺少 planningParameters"); + + Assert.AreEqual(BaselineObjectLengthInMeters, (double)planningParameters["objectLengthInMeters"], 1e-9, "物体长度参数不匹配"); + Assert.AreEqual(BaselineObjectWidthInMeters, (double)planningParameters["objectWidthInMeters"], 1e-9, "物体宽度参数不匹配"); + Assert.AreEqual(BaselineSafetyMarginInMeters, (double)planningParameters["safetyMarginInMeters"], 1e-9, "安全间隙参数不匹配"); + Assert.AreEqual(BaselineGridSizeInMeters, (double)planningParameters["gridSizeInMeters"], 1e-9, "网格大小参数不匹配"); + + JObject segmentValidation = (JObject)data["segmentValidation"]; + Assert.IsNotNull(segmentValidation, "缺少 segmentValidation"); + Assert.AreEqual(0, (int)segmentValidation["blockedSampleCount"], "生成路径存在穿过不可通行网格的采样点"); + Assert.AreEqual(0, (int)segmentValidation["blockedCellInteriorIntersectionCount"], "生成路径线段穿过了不可通行网格的实际归属区域"); + Assert.AreEqual(0, (int)segmentValidation["invalidSampleCount"], "生成路径存在落在网格外的采样点"); + } + } + } +} diff --git a/UnitTests/Integration/NavisworksTestAutomationClient.cs b/UnitTests/Integration/NavisworksTestAutomationClient.cs new file mode 100644 index 0000000..ed9494b --- /dev/null +++ b/UnitTests/Integration/NavisworksTestAutomationClient.cs @@ -0,0 +1,116 @@ +using System; +using System.Globalization; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + internal sealed class NavisworksTestAutomationClient : IDisposable + { + private readonly HttpClient _httpClient; + + public NavisworksTestAutomationClient() + { + _httpClient = new HttpClient + { + BaseAddress = new Uri("http://127.0.0.1:18777"), + Timeout = TimeSpan.FromSeconds(20) + }; + } + + public async Task EnsureServiceReadyAsync(TimeSpan timeout) + { + DateTime deadlineUtc = DateTime.UtcNow.Add(timeout); + Exception lastError = null; + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + JObject pingResponse = await GetJsonAsync("/api/test/ping").ConfigureAwait(false); + if (pingResponse.Value("ok") == true) + { + return; + } + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + + Assert.Fail( + "Navisworks 测试服务未就绪。请先用 start-navisworks.bat 启动 Navisworks,并确保插件面板已加载。最后错误: {0}", + lastError?.Message ?? "unknown"); + } + + public async Task RunVirtualCollisionTestAsync(string pathType, int timeoutSeconds) + { + string requestUri = string.Format( + "/api/test/run-virtual-collision-test?pathType={0}&timeoutSeconds={1}", + Uri.EscapeDataString(pathType), + timeoutSeconds); + + return await PostJsonAsync(requestUri).ConfigureAwait(false); + } + + public async Task AnalyzeAutoPathGridAsync(string pathType) + { + string requestUri = string.Format( + "/api/test/analyze-auto-path-grid?pathType={0}", + Uri.EscapeDataString(pathType)); + + return await PostJsonAsync(requestUri).ConfigureAwait(false); + } + + public async Task RunAutoPathAsync( + string pathType, + string routeName, + double objectLengthInMeters, + double objectWidthInMeters, + double safetyMarginInMeters, + double gridSizeInMeters) + { + string requestUri = string.Format( + CultureInfo.InvariantCulture, + "/api/test/run-auto-path?pathType={0}&routeName={1}&objectLengthInMeters={2}&objectWidthInMeters={3}&safetyMarginInMeters={4}&gridSizeInMeters={5}", + Uri.EscapeDataString(pathType), + Uri.EscapeDataString(routeName), + objectLengthInMeters, + objectWidthInMeters, + safetyMarginInMeters, + gridSizeInMeters); + + return await PostJsonAsync(requestUri).ConfigureAwait(false); + } + + private async Task GetJsonAsync(string requestUri) + { + using (HttpResponseMessage response = await _httpClient.GetAsync(requestUri).ConfigureAwait(false)) + { + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return JObject.Parse(content); + } + } + + private async Task PostJsonAsync(string requestUri) + { + using (HttpResponseMessage response = await _httpClient.PostAsync(requestUri, new StringContent(string.Empty)).ConfigureAwait(false)) + { + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return JObject.Parse(content); + } + } + + public void Dispose() + { + _httpClient.Dispose(); + } + } +} diff --git a/UnitTests/Integration/VirtualCollisionAutomationTests.cs b/UnitTests/Integration/VirtualCollisionAutomationTests.cs new file mode 100644 index 0000000..1791734 --- /dev/null +++ b/UnitTests/Integration/VirtualCollisionAutomationTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + [TestClass] + [TestCategory("NavisworksIntegration")] + public class VirtualCollisionAutomationTests + { + private const int DefaultCollisionTimeoutSeconds = 240; + + [TestMethod] + [Timeout(360000)] + public async Task GroundVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Ground").ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task HoistingVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Hoisting").ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task RailVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Rail").ConfigureAwait(false); + } + + private static async Task AssertVirtualCollisionTestAsync(string pathType) + { + using (var client = new NavisworksTestAutomationClient()) + { + await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false); + + JObject response = await client.RunVirtualCollisionTestAsync(pathType, DefaultCollisionTimeoutSeconds).ConfigureAwait(false); + Assert.IsTrue(response.Value("ok"), "测试 HTTP 接口返回失败: " + (string)response["error"]); + + JObject data = (JObject)response["data"]; + Assert.IsNotNull(data, "缺少测试结果 data"); + + Assert.AreEqual(pathType, (string)data["requestedPathType"], "请求的路径类型不匹配"); + + JObject route = (JObject)data["route"]; + Assert.IsNotNull(route, "缺少 route"); + Assert.AreEqual(pathType, (string)route["pathType"], "返回的路径类型不匹配"); + Assert.IsFalse(string.IsNullOrWhiteSpace((string)route["name"]), "路径名为空"); + + JObject animatedObject = (JObject)data["animatedObject"]; + Assert.IsNotNull(animatedObject, "缺少 animatedObject"); + Assert.AreEqual("VirtualObject", (string)animatedObject["mode"], "当前集成测试应使用虚拟物体"); + + JObject animation = (JObject)data["animation"]; + Assert.IsNotNull(animation, "缺少 animation"); + Assert.AreEqual("Finished", (string)animation["currentState"], "动画未完成"); + Assert.IsTrue((int)animation["totalFrames"] > 0, "动画总帧数应大于 0"); + Assert.IsTrue(animation["detectionRecordId"] != null && (int)animation["detectionRecordId"] > 0, "检测记录 ID 无效"); + + Assert.IsNotNull(data["report"], "缺少碰撞报告"); + } + } + } +} diff --git a/UnitTests/Properties/AssemblyInfo.cs b/UnitTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..06b09da --- /dev/null +++ b/UnitTests/Properties/AssemblyInfo.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("NavisworksTransport.UnitTests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("NavisworksTransport.UnitTests")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: ComVisible(false)] +[assembly: Guid("8f8a2fd7-c7e5-4185-95a4-740a2bf6130f")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/UnitTests/Utils/BoundingBoxGeometryUtilsTests.cs b/UnitTests/Utils/BoundingBoxGeometryUtilsTests.cs new file mode 100644 index 0000000..a0bc7c2 --- /dev/null +++ b/UnitTests/Utils/BoundingBoxGeometryUtilsTests.cs @@ -0,0 +1,400 @@ +using System; +using Autodesk.Navisworks.Api; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NavisworksTransport.Utils; + +namespace NavisworksTransport.UnitTests.Utils +{ + /// + /// BoundingBoxGeometryUtils 的单元测试 + /// 重点验证旋转后包围盒中心偏移计算的数学正确性 + /// 使用大尺寸和夸张比例,确保结果清晰可见 + /// + [TestClass] + public class BoundingBoxGeometryUtilsTests + { + #region CalculateRotatedBoundingBoxCenterOffset Tests - 大尺寸非对称包围盒 + + /// + /// X方向超长的长方体(类似长沙发/长桌)绕Z轴旋转90度 + /// 尺寸: 10000 x 100 x 50 + /// 预期: X方向大偏移 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_LongX_Rotate90AroundZ_LargeXOffset() + { + // X方向从 -5000 到 +5000(总长10000) + // Y方向从 -50 到 +50(总长100) + // Z方向从 -25 到 +25(总长50) + var bounds = new BoundingBox3D( + new Point3D(-5000, -50, -25), + new Point3D(5000, 50, 25)); + + // 原中心: (0, 0, 0) + // 8角点: (±5000, ±50, ±25) + + // 绕Z轴旋转90度 (x'=-y, y'=x): + // (5000, 50) -> (-50, 5000) + // (5000, -50) -> (50, 5000) + // (-5000, 50) -> (-50, -5000) + // (-5000, -50) -> (50, -5000) + // + // 新X范围: [-50, 50](来自原Y范围) + // 新Y范围: [-5000, 5000](来自原X范围) + // 新中心: (0, 0, 0) + // 偏移: (0, 0, 0) - 等等,这不应该是0 + + // 啊不对,让我重新算: + // 原中心是 (0, 0, 0) + // 旋转后各角点分布在 [-50,50]x[-5000,5000]x[-25,25] + // 新AABB中心仍然是 (0, 0, 0) + // 所以偏移应该是 0? + + // 等等,问题在于原中心是 (0,0,0),所以新中心也是 (0,0,0) + // 我需要测试非对称的,即原中心不在原点的情况 + + // 重新设计:让包围盒中心不在原点 + // Min=(-1000, -50, -25), Max=(9000, 50, 25) + // 中心: (4000, 0, 0) + bounds = new BoundingBox3D( + new Point3D(-1000, -50, -25), + new Point3D(9000, 50, 25)); + + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI / 2); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (4000, 0, 0) + // 8角点相对中心: (-5000,±50,±25), (5000,±50,±25) + // + // 旋转90度后: + // (-5000, 50) -> (-50, -5000) + // (-5000, -50) -> (50, -5000) + // (5000, 50) -> (-50, 5000) + // (5000, -50) -> (50, 5000) + // + // 新X范围: [-50, 50] + // 新Y范围: [-5000, 5000] + // 新中心: (0, 0, 0) + // 偏移: (0-4000, 0-0, 0-0) = (-4000, 0, 0) + + Console.WriteLine($"LongX-Rotate90: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(-4000, offset.X, 1, "X方向大偏移应为-4000"); + Assert.AreEqual(0, offset.Y, 1, "Y偏移应为0"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// Y方向超长的长方体绕X轴旋转90度 + /// 尺寸: 100 x 10000 x 50 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_LongY_Rotate90AroundX_LargeYOffset() + { + // Y方向从 -1000 到 +9000(总长10000,中心在4000) + // X方向从 -50 到 +50(总长100) + // Z方向从 -25 到 +25(总长50) + var bounds = new BoundingBox3D( + new Point3D(-50, -1000, -25), + new Point3D(50, 9000, 25)); + + // 绕X轴旋转90度 (y'=-z, z'=y) + var rotation = new Rotation3D(new UnitVector3D(1, 0, 0), Math.PI / 2); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (0, 4000, 0) + // 8角点相对中心: (±50, -5000, ±25), (±50, 5000, ±25) + // + // 旋转90度后: + // y' = -z, z' = y + // (50, -5000, 25) -> (50, -25, -5000) + // (50, -5000, -25) -> (50, 25, -5000) + // (50, 5000, 25) -> (50, -25, 5000) + // (50, 5000, -25) -> (50, 25, 5000) + // (-50, -5000, 25) -> (-50, -25, -5000) + // (-50, -5000, -25) -> (-50, 25, -5000) + // (-50, 5000, 25) -> (-50, -25, 5000) + // (-50, 5000, -25) -> (-50, 25, 5000) + // + // 新X范围: [-50, 50] + // 新Y范围: [-25, 25] + // 新Z范围: [-5000, 5000] + // 新中心: (0, 0, 0) + // 偏移: (0-0, 0-4000, 0-0) = (0, -4000, 0) + + Console.WriteLine($"LongY-Rotate90: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(0, offset.X, 1, "X偏移应为0"); + Assert.AreEqual(-4000, offset.Y, 1, "Y方向大偏移应为-4000"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// Z方向超长的长方体绕Y轴旋转90度 + /// 尺寸: 100 x 100 x 10000 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_LongZ_Rotate90AroundY_LargeZOffset() + { + // Z方向从 -1000 到 +9000(总长10000,中心在4000) + // X方向从 -50 到 +50(总长100) + // Y方向从 -50 到 +50(总长100) + var bounds = new BoundingBox3D( + new Point3D(-50, -50, -1000), + new Point3D(50, 50, 9000)); + + // 绕Y轴旋转90度 (z'=x, x'=-z) - 等等,让我确认旋转矩阵 + // 绕Y轴旋转θ: x'=x*cosθ+z*sinθ, z'=-x*sinθ+z*cosθ + // 90度时: x'=z, z'=-x + var rotation = new Rotation3D(new UnitVector3D(0, 1, 0), Math.PI / 2); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (0, 0, 4000) + // 8角点相对中心: (±50,±50,-5000), (±50,±50,5000) + // + // 旋转90度后 (x'=z, z'=-x): + // (50, 50, -5000) -> (-5000, 50, -50) + // (50, 50, 5000) -> (5000, 50, -50) + // (-50, 50, -5000) -> (-5000, 50, 50) + // (-50, 50, 5000) -> (5000, 50, 50) + // 以及Y=-50的四个点... + // + // 新X范围: [-5000, 5000] + // 新Y范围: [-50, 50] + // 新Z范围: [-50, 50] + // 新中心: (0, 0, 0) + // 偏移: (0-0, 0-0, 0-4000) = (0, 0, -4000) + + Console.WriteLine($"LongZ-Rotate90: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(0, offset.X, 1, "X偏移应为0"); + Assert.AreEqual(0, offset.Y, 0.1, "Y偏移应为0"); + Assert.AreEqual(-4000, offset.Z, 1, "Z方向大偏移应为-4000"); + } + + /// + /// 三轴都不同比例的长方体绕Z轴旋转45度 + /// 尺寸: 1000 x 200 x 100 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_AsymmetricXYZ_Rotate45AroundZ_MultipleOffsets() + { + // X: -500 ~ +500 (中心0,但非对称也可测) + // Y: -100 ~ +100 (中心0) + // Z: -50 ~ +50 (中心0) + // 等等,中心0的话偏移还是0... + + // 改用非中心对称的 + // X: -100 ~ +900 (总长1000,中心400) + // Y: -80 ~ +120 (总长200,中心20) + // Z: -30 ~ +70 (总长100,中心20) + var bounds = new BoundingBox3D( + new Point3D(-100, -80, -30), + new Point3D(900, 120, 70)); + + // 绕Z轴旋转45度 + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI / 4); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (400, 20, 20) + // + // 8角点相对中心: + // (-500,-100,-50), (500,-100,-50), (-500,100,-50), (500,100,-50) + // (-500,-100,50), (500,-100,50), (-500,100,50), (500,100,50) + // + // 旋转45度后 (x'=(x-y)/√2, y'=(x+y)/√2): + // (-500,-100) -> (-400/√2, -600/√2) ≈ (-283, -424) + // (500,-100) -> (600/√2, 400/√2) ≈ (424, 283) + // (-500,100) -> (-600/√2, -400/√2) ≈ (-424, -283) + // (500,100) -> (400/√2, 600/√2) ≈ (283, 424) + // + // 新X范围: [-424, 424] + // 新Y范围: [-424, 424] + // 新中心: (0, 0, 20) - Z不变 + // 偏移: (0-400, 0-20, 20-20) = (-400, -20, 0) + + Console.WriteLine($"Asymmetric-Rotate45: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + // 允许一定误差(因为手动计算是近似值) + Assert.IsTrue(Math.Abs(offset.X - (-400)) < 50, $"X偏移应约-400,实际是{offset.X:F1}"); + Assert.IsTrue(Math.Abs(offset.Y - (-20)) < 50, $"Y偏移应约-20,实际是{offset.Y:F1}"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// 实际沙发案例放大版 + /// 模拟沙发: 长2000,宽100,高80,中心偏移 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_BigSofa_Rotate90AroundZ_RealisticCase() + { + // 沙发包围盒放大版 + // X: -200 ~ +1800 (总长2000,中心800) + // Y: -50 ~ +50 (总长100,中心0) + // Z: 0 ~ +80 (总高80,中心40) + var bounds = new BoundingBox3D( + new Point3D(-200, -50, 0), + new Point3D(1800, 50, 80)); + + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI / 2); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (800, 0, 40) + // + // 8角点相对中心: + // (-1000,-50,-40), (1000,-50,-40), (-1000,50,-40), (1000,50,-40) + // (-1000,-50,40), (1000,-50,40), (-1000,50,40), (1000,50,40) + // + // 旋转90度后 (x'=-y, y'=x): + // (-1000,-50) -> (50, -1000) + // (1000,-50) -> (50, 1000) + // (-1000,50) -> (-50, -1000) + // (1000,50) -> (-50, 1000) + // + // 新X范围: [-50, 50] + // 新Y范围: [-1000, 1000] + // 新中心: (0, 0, 40) - Z不变 + // 偏移: (0-800, 0-0, 40-40) = (-800, 0, 0) + + Console.WriteLine($"BigSofa-Rotate90: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(-800, offset.X, 10, "X方向偏移应为-800"); + Assert.AreEqual(0, offset.Y, 10, "Y偏移应为0"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// X方向超长绕Z轴旋转45度 - 测试非90度旋转 + /// 尺寸: 10000 x 100 x 50 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_LongX_Rotate45AroundZ_DiagonalOffset() + { + // X方向从 -1000 到 +9000(总长10000,中心4000) + // Y方向从 -50 到 +50(总长100) + var bounds = new BoundingBox3D( + new Point3D(-1000, -50, -25), + new Point3D(9000, 50, 25)); + + // 绕Z轴旋转45度 + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI / 4); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (4000, 0, 0) + // 8角点相对中心: (-5000,±50,±25), (5000,±50,±25) + // + // 旋转45度后 (x'=(x-y)/√2, y'=(x+y)/√2): + // (-5000, 50) -> (-5050/√2, -4950/√2) ≈ (-3571, -3500) + // (-5000, -50) -> (-4950/√2, -5050/√2) ≈ (-3500, -3571) + // (5000, 50) -> (4950/√2, 5050/√2) ≈ (3500, 3571) + // (5000, -50) -> (5050/√2, 4950/√2) ≈ (3571, 3500) + // + // 新X范围: [-3571, 3571] + // 新Y范围: [-3571, 3571] + // 新中心: (0, 0, 0) + // 偏移: (0-4000, 0-0, 0-0) = (-4000, 0, 0) + + Console.WriteLine($"LongX-Rotate45: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + // X方向应该有较大负偏移(接近-4000) + Assert.IsTrue(offset.X < -3500, $"45度旋转后X偏移应小于-3500,实际是{offset.X:F1}"); + Assert.IsTrue(offset.X > -4500, $"45度旋转后X偏移应大于-4500,实际是{offset.X:F1}"); + // Y方向偏移应该很小(因为原Y中心是0) + Assert.IsTrue(Math.Abs(offset.Y) < 100, $"45度旋转后Y偏移应接近0,实际是{offset.Y:F1}"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// XY方向都非对称的长方体绕Z轴旋转45度 - 测试双向偏移 + /// 尺寸: 8000 x 4000 x 100 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_XYBothAsymmetric_Rotate45AroundZ_BothOffsets() + { + // X方向从 -3000 到 +5000(总长8000,中心2000) + // Y方向从 -1000 到 +3000(总长4000,中心1000) + var bounds = new BoundingBox3D( + new Point3D(-3000, -1000, -50), + new Point3D(5000, 3000, 50)); + + // 绕Z轴旋转45度 + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI / 4); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + // 原中心: (2000, 1000, 0) + // 8角点相对中心: (-5000,-2000,±50), (3000,-2000,±50), (-5000,2000,±50), (3000,2000,±50) + // + // 旋转45度后: + // (-5000, -2000) -> (-3000/√2, -7000/√2) ≈ (-2121, -4950) + // (3000, -2000) -> (5000/√2, 1000/√2) ≈ (3536, 707) + // (-5000, 2000) -> (-7000/√2, -3000/√2) ≈ (-4950, -2121) + // (3000, 2000) -> (1000/√2, 5000/√2) ≈ (707, 3536) + // + // 新X范围: [-4950, 3536] + // 新Y范围: [-4950, 3536] + // 新中心: ((-4950+3536)/2, (-4950+3536)/2, 0) = (-707, -707, 0) + // 偏移: (-707-2000, -707-1000, 0-0) = (-2707, -1707, 0) + + Console.WriteLine($"XY-Asymmetric-Rotate45: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + // X方向应该有较大负偏移 + Assert.IsTrue(offset.X < -2000, $"45度旋转后X偏移应小于-2000,实际是{offset.X:F1}"); + Assert.IsTrue(offset.X > -3500, $"45度旋转后X偏移应大于-3500,实际是{offset.X:F1}"); + // Y方向也应该有负偏移,但比X小 + Assert.IsTrue(offset.Y < -1000, $"45度旋转后Y偏移应小于-1000,实际是{offset.Y:F1}"); + Assert.IsTrue(offset.Y > -2500, $"45度旋转后Y偏移应大于-2500,实际是{offset.Y:F1}"); + Assert.AreEqual(0, offset.Z, 0.1, "Z偏移应为0"); + } + + /// + /// 零旋转验证 - 无论什么形状,不旋转偏移都应为0 + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_NoRotation_ZeroOffset() + { + var bounds = new BoundingBox3D( + new Point3D(-1000, -100, -50), + new Point3D(9000, 100, 50)); + + var rotation = Rotation3D.Identity; + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + Console.WriteLine($"No-Rotation: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(0, offset.X, 1e-6, "无旋转时X偏移应为0"); + Assert.AreEqual(0, offset.Y, 1e-6, "无旋转时Y偏移应为0"); + Assert.AreEqual(0, offset.Z, 1e-6, "无旋转时Z偏移应为0"); + } + + /// + /// 180度旋转验证 - 包围盒应该不变(只是翻转) + /// + [TestMethod] + public void CalculateRotatedBoundingBoxCenterOffset_180DegreeRotation_ZeroOffset() + { + var bounds = new BoundingBox3D( + new Point3D(-1000, -100, -50), + new Point3D(9000, 100, 50)); + + var rotation = new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI); + + var offset = BoundingBoxGeometryUtils.CalculateRotatedBoundingBoxCenterOffset(bounds, rotation); + + Console.WriteLine($"180-Rotation: offset=({offset.X:F1}, {offset.Y:F1}, {offset.Z:F1})"); + + Assert.AreEqual(0, offset.X, 1e-6, "180度旋转后X偏移应为0"); + Assert.AreEqual(0, offset.Y, 1e-6, "180度旋转后Y偏移应为0"); + Assert.AreEqual(0, offset.Z, 1e-6, "180度旋转后Z偏移应为0"); + } + + #endregion + } +} diff --git a/deploy-plugin.bat b/deploy-plugin.bat index 1cce12f..789d687 100644 --- a/deploy-plugin.bat +++ b/deploy-plugin.bat @@ -1,26 +1,121 @@ @echo off setlocal -:: Stop Navisworks to release locked plugin DLLs. -taskkill /F /IM Roamer.exe 2>nul -if %errorlevel% == 0 ( - echo Navisworks process terminated. - timeout /t 1 /nobreak >nul -) - set "TARGET_DIR=%PROGRAMDATA%\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin" -set "BUILD_DIR=bin\x64\Release" +set "BUILD_DIR=%~dp0bin\x64\Release" +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$ErrorActionPreference = 'Stop';" ^ + "$targetDir = [System.IO.Path]::GetFullPath('%TARGET_DIR%');" ^ + "$buildDir = [System.IO.Path]::GetFullPath('%BUILD_DIR%');" ^ + "$deployDllNames = @(" ^ + " 'TransportPlugin.dll'," ^ + " 'geometry4Sharp.dll'," ^ + " 'Newtonsoft.Json.dll'," ^ + " 'Roy-T.AStar.dll'," ^ + " 'SQLite.Interop.dll'," ^ + " 'System.Data.SQLite.dll'," ^ + " 'Tomlyn.dll'" ^ + ");" ^ + "$deployRootFiles = @(" ^ + " 'TransportRibbon.xaml'," ^ + " 'TransportRibbon_16.png'," ^ + " 'TransportRibbon_32.png'" ^ + ");" ^ + "$stalePluginFiles = @(" ^ + " 'Autodesk.Navisworks.Api.dll'," ^ + " 'NavisworksTransport.UnitTests.dll'," ^ + " 'Microsoft.VisualStudio.TestPlatform.TestFramework.dll'," ^ + " 'Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll'" ^ + ");" ^ + "function Test-FileUnlocked([string]$path) {" ^ + " if (-not (Test-Path $path)) { return $true }" ^ + " try {" ^ + " $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None);" ^ + " $stream.Close();" ^ + " return $true;" ^ + " } catch {" ^ + " return $false;" ^ + " }" ^ + "}" ^ + "function Wait-FileUnlocked([string]$path, [datetime]$deadline) {" ^ + " while (-not (Test-FileUnlocked $path)) {" ^ + " if ((Get-Date) -ge $deadline) { throw ('Target file still locked: ' + $path) }" ^ + " Start-Sleep -Milliseconds 500;" ^ + " }" ^ + "}" ^ + "" ^ + "Get-Process Roamer -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue;" ^ + "$deadline = (Get-Date).AddSeconds(15);" ^ + "while (Get-Process Roamer -ErrorAction SilentlyContinue) {" ^ + " if ((Get-Date) -ge $deadline) { throw 'Roamer.exe still running after 15 seconds.' }" ^ + " Start-Sleep -Milliseconds 500;" ^ + "}" ^ + "$keyTargetFiles = @(" ^ + " (Join-Path $targetDir 'TransportPlugin.dll')," ^ + " (Join-Path $targetDir 'geometry4Sharp.dll')," ^ + " (Join-Path (Join-Path $targetDir 'resources') 'unit_cube.nwc')," ^ + " (Join-Path (Join-Path $targetDir 'resources') 'unit_cylinder.nwc')" ^ + ");" ^ + "foreach ($keyFile in $keyTargetFiles) { Wait-FileUnlocked $keyFile $deadline }" ^ + "" ^ + "New-Item -ItemType Directory -Force -Path $targetDir | Out-Null;" ^ + "foreach ($staleFileName in $stalePluginFiles) {" ^ + " $stalePath = Join-Path $targetDir $staleFileName;" ^ + " if (Test-Path $stalePath) { Remove-Item $stalePath -Force }" ^ + "}" ^ + "foreach ($dllName in $deployDllNames) {" ^ + " $sourceDll = Join-Path $buildDir $dllName;" ^ + " if (-not (Test-Path $sourceDll)) { throw ('Deployment source file missing: ' + $sourceDll) }" ^ + " Copy-Item $sourceDll $targetDir -Force;" ^ + "}" ^ + "foreach ($rootFileName in $deployRootFiles) {" ^ + " $sourceRootFile = Join-Path $buildDir $rootFileName;" ^ + " if (Test-Path $sourceRootFile) {" ^ + " Copy-Item $sourceRootFile $targetDir -Force;" ^ + " foreach ($locale in @('en-US','zh-CN')) {" ^ + " $localeDir = Join-Path $targetDir $locale;" ^ + " New-Item -ItemType Directory -Force -Path $localeDir | Out-Null;" ^ + " Copy-Item $sourceRootFile $localeDir -Force;" ^ + " }" ^ + " }" ^ + "}" ^ + "if (Test-Path (Join-Path $buildDir 'resources')) {" ^ + " New-Item -ItemType Directory -Force -Path (Join-Path $targetDir 'resources') | Out-Null;" ^ + " Copy-Item (Join-Path $buildDir 'resources\\*') (Join-Path $targetDir 'resources') -Recurse -Force;" ^ + "}" ^ + "" ^ + "$sourceFiles = @();" ^ + "foreach ($dllName in $deployDllNames) { $sourceFiles += Get-Item (Join-Path $buildDir $dllName) }" ^ + "foreach ($rootFileName in $deployRootFiles) {" ^ + " $sourceRootFile = Join-Path $buildDir $rootFileName;" ^ + " if (Test-Path $sourceRootFile) { $sourceFiles += Get-Item $sourceRootFile }" ^ + "}" ^ + "if (Test-Path (Join-Path $buildDir 'resources')) {" ^ + " $sourceFiles += Get-ChildItem (Join-Path $buildDir 'resources') -File;" ^ + "}" ^ + "" ^ + "foreach ($sourceFile in $sourceFiles) {" ^ + " $targetFile = if ($sourceFile.DirectoryName -eq $buildDir) { Join-Path $targetDir $sourceFile.Name } else { Join-Path (Join-Path $targetDir 'resources') $sourceFile.Name };" ^ + " if (-not (Test-Path $targetFile)) { throw ('Deployment verification failed: missing target file ' + $targetFile) }" ^ + " $targetInfo = Get-Item $targetFile;" ^ + " if ($sourceFile.Length -ne $targetInfo.Length -or $sourceFile.LastWriteTime -ne $targetInfo.LastWriteTime) {" ^ + " throw ('Deployment verification failed for ' + $sourceFile.Name + ': source=' + $sourceFile.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss.fff') + ' (' + $sourceFile.Length + '), target=' + $targetInfo.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss.fff') + ' (' + $targetInfo.Length + ')');" ^ + " }" ^ + " Write-Host ('Verified ' + $sourceFile.Name + ': ' + $targetInfo.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss.fff'));" ^ + " if ($deployRootFiles -contains $sourceFile.Name) {" ^ + " foreach ($locale in @('en-US','zh-CN')) {" ^ + " $localeTargetFile = Join-Path (Join-Path $targetDir $locale) $sourceFile.Name;" ^ + " if (-not (Test-Path $localeTargetFile)) { throw ('Deployment verification failed: missing locale target file ' + $localeTargetFile) }" ^ + " $localeTargetInfo = Get-Item $localeTargetFile;" ^ + " if ($sourceFile.Length -ne $localeTargetInfo.Length -or $sourceFile.LastWriteTime -ne $localeTargetInfo.LastWriteTime) {" ^ + " throw ('Deployment verification failed for locale copy ' + $localeTargetFile);" ^ + " }" ^ + " Write-Host ('Verified ' + $locale + '\\' + $sourceFile.Name + ': ' + $localeTargetInfo.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss.fff'));" ^ + " }" ^ + " }" ^ + "}" ^ + "" ^ + "Write-Host 'Plugin deployed successfully!';" -if not exist "%TARGET_DIR%" mkdir "%TARGET_DIR%" - -copy "%BUILD_DIR%\*.dll" "%TARGET_DIR%\" >nul -echo Plugin DLLs deployed. - -:: Copy bundled resources. -if exist "%BUILD_DIR%\resources" ( - if not exist "%TARGET_DIR%\resources" mkdir "%TARGET_DIR%\resources" - xcopy "%BUILD_DIR%\resources\*" "%TARGET_DIR%\resources\" /E /I /Y /Q >nul - echo Resources folder deployed. -) - -echo Plugin deployed successfully! +if errorlevel 1 exit /b 1 +exit /b 0 diff --git a/doc/design/2026/NavisworksAPI使用方法.md b/doc/design/2026/NavisworksAPI使用方法.md index 7caa4f3..114d809 100644 --- a/doc/design/2026/NavisworksAPI使用方法.md +++ b/doc/design/2026/NavisworksAPI使用方法.md @@ -1,4 +1,4 @@ -# Navisworks API 使用方法指南 +# Navisworks API 使用方法指南 基于真实官方示例的正确API用法总结 @@ -505,455 +505,464 @@ public void BatchNavisworksOperations(List items) ## 11. Transform 变换操作 -### 11.1 Transform 相关 API 概念 +### 11.1 官方文档结论 -**核心概念**: +以下几条是本项目后续关于变换问题的硬基线,优先级高于历史经验和猜测: -- `ModelItem.Transform` - 返回设计文件中的原始变换,**只读属性**,**不反映override后的状态** -- `OverridePermanentTransform()` - 应用增量变换(相对于原始Transform累积) -- `ResetPermanentTransform()` - 清除所有增量变换,恢复到设计文件原始位置 -- `ModelItem.BoundingBox()` - 返回**当前实际显示**的包围盒(反映override效果) +- `ModelItem.Transform` + - 官方说明:`Returns the Transform attached to this item in the original source design file` + - 结论:它表示原始设计文件变换,不是当前显示姿态 + - 官方页:`doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_ModelItem_Transform.htm` +- `ModelGeometry.ActiveTransform` + - 官方说明:`Returns the currently active transform of the geometry.` + - 结论:它表示当前几何实际生效的变换 + - 官方页:`doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_ModelGeometry_ActiveTransform.htm` +- `DocumentModels.OverridePermanentTransform(...)` + - 官方说明:`Apply an incremental transform to a selection.` + - 结论:这是增量变换,不是把对象直接设成目标绝对姿态 + - 官方页:`doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_OverridePermanentTransform_3_131351c5.htm` +- `DocumentModels.ResetPermanentTransform(...)` + - 官方说明:`Reset incremental transforms for all model items contained in the selection.` + - 结论:它清掉的是永久增量层 + - 官方页:`doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_ResetPermanentTransform_1_75193b86.htm` +- `ModelGeometry` + - 官方成员表明确提供: + - `OriginalTransform` + - `PermanentOverrideTransform` + - `PermanentTransform` + - `ActiveTransform` + - 官方页:`doc/navisworks_api/NET/documentation/NetAPIHtml/html/AllMembers_T_Autodesk_Navisworks_Api_ModelGeometry.htm` -**⚠️ 关键理解**: +### 11.2 变换层级的正确理解 -1. **`ModelItem.Transform` 永远返回原始值**,即使通过 `OverridePermanentTransform` 改变了物体位置 -2. **Override 信息存储在别处**,不会修改 `ModelItem.Transform` 属性 -3. **要获取实际位置,使用 `BoundingBox().Center`**,它反映override后的实际位置 +以后项目里统一这样理解: -### 11.2 Transform 操作的正确用法 +- `ModelItem.Transform` + - 原始设计文件层变换 + - 只读 + - 不反映 `OverridePermanentTransform` 后的当前状态 +- `ModelItem.Geometry` / `ModelItem.FindFirstGeometry()` + - 进入几何层的正式入口 +- `ModelGeometry.OriginalTransform` + - 几何加载时的原始变换 +- `ModelGeometry.PermanentOverrideTransform` + - 施加在几何上的永久增量覆盖层 +- `ModelGeometry.PermanentTransform` + - 原始变换和永久覆盖组合后的结果 +- `ModelGeometry.ActiveTransform` + - 当前真正生效的几何变换 + - 一般应优先用它判断当前姿态/当前位置 + +### 11.3 项目中的推荐读取入口 + +#### 11.3.1 读“当前实际姿态” + +优先顺序: + +1. `ModelItem.FindFirstGeometry()` 或 `ModelItem.Geometry` +2. `ModelGeometry.ActiveTransform` +3. 必要时再看 `ModelGeometry.PermanentTransform` ```csharp -// ✅ 获取物体的原始Transform(设计文件中的位置) -Transform3D originalTransform = modelItem.Transform; +ModelGeometry geometry = item.FindFirstGeometry(); +if (geometry != null) +{ + Transform3D current = geometry.ActiveTransform; +} +``` -// ✅ 应用增量变换(累积变换) +#### 11.3.2 读“原始姿态” + +优先顺序: + +1. `ModelGeometry.OriginalTransform` +2. `ModelItem.Transform` + +```csharp +ModelGeometry geometry = item.FindFirstGeometry(); +Transform3D original = geometry != null ? geometry.OriginalTransform : item.Transform; +``` + +#### 11.3.3 读“当前实际位置” + +推荐顺序: + +1. 如果语义上需要几何真实中心,优先 `geometry.BoundingBox.Center` +2. 如果业务上有自己定义的 tracked point,就用业务 tracked point +3. 不要把 `ModelItem.Transform.Translation` 直接当成 override 后实际位置 + +### 11.4 `OverridePermanentTransform` 的正确语义 + +`OverridePermanentTransform(items, transform, updateModelTransform)` 的本质是: + +- 对选中的对象施加增量变换 +- 不是设置对象的最终世界姿态 + +另外官方 Remarks 里还有一条很重要: + +- 如果 selection 包含文件,而且 `updateModelTransform = true` +- 则不是对 fragments 施加变换 +- 而是更新 File Units and Transform + +这也是项目里必须区分的两种用法: + +- `updateModelTransform = false` + - 对 item/geometry 的永久增量层操作 +- `updateModelTransform = true` + - 更新 model/file 层的 units and transform + +### 11.5 `ResetPermanentTransform` 的正确语义 + +`ResetPermanentTransform(items)` 只做一件事: + +- 清除选中对象的永久增量变换 + +它不会修改: + +- 原始设计文件几何 +- `ModelItem.Transform` +- `ModelGeometry.OriginalTransform` + +所以“恢复到原始状态”要明确到底指哪一层: + +- 对真实物体: + - 通常是清掉 override,回到原始几何状态 +- 对虚拟物体: + - 如果业务尺寸是后续叠加出来的,单纯 reset 会把业务尺寸也一起清掉 + - 因此虚拟物体常常需要 reset 后再重放业务尺寸 + +### 11.5.1 真实物体 Rail 起点旋转的两个关键经验 + +这是本项目在真实物体沿 `Rail` 路径“移动到起点”与“角度调整”排查中确认的两条硬结论。 + +#### A. 正确理解 Navisworks 的“增量旋转” + +`document.Models.OverridePermanentTransform(items, transform, false)` 施加的是**增量层**,不是“把对象直接设成最终姿态”。 + +对真实物体要区分 3 个量: + +1. `OriginalTransform` + - 原始设计文件里的姿态 +2. `PermanentOverrideTransform` + - 我们写进去的增量/覆盖姿态 +3. `PermanentTransform / ActiveTransform` + - Navisworks 最终真正显示出来的姿态 + +项目实测中,真实物体常见关系应按下面理解: + +```text +最终显示姿态 = OriginalTransform × PermanentOverrideTransform +``` + +因此: + +- 如果你手上拿到的是“最终想看到的目标姿态” +- 不能直接把它当 `PermanentOverrideTransform` 写进去 +- 必须先换算出真正应该写入的 override 姿态 + +否则就会出现: + +- 目标姿态日志看起来是对的 +- `OverridePermanentTransform` 也写进去了 +- 但最终 `ActiveTransform` 仍然被原始模型姿态再转一次 + +这类问题在真实物体上非常常见,尤其是 Revit 导入件。 + +#### B. 正确区分“宿主坐标系语义”和“物体自身坐标系语义” + +UI 里的角度调整,用户理解的一定是**宿主坐标系**: + +- `X/Y/Z` 表示宿主世界 `X/Y/Z` + +但 Navisworks 对真实物体施加旋转时,底层消费的仍然是**物体自身轴语义**。 + +因此正确链路必须是: + +```text +宿主世界轴角度输入 +-> 映射到当前真实物体的本地轴 +-> 再生成本地 correction quaternion +-> 最后交给 OverridePermanentTransform 链 +``` + +不能直接把“宿主世界 X/Y/Z”角度传给一个只接受物体本地轴语义的方法。 + +#### C. 哪种映射是对的 + +真实物体这里至少有两套看起来很像、但不能混用的映射: + +1. **宿主世界轴 -> raw 本地轴** + - 用于 UI 角度调整 + - 例如:宿主世界 `X` 对应物体本地 `+Y` +2. **宿主语义轴 -> raw 本地轴** + - 用于路径姿态解释 + - 例如:`Rail` 的 `forward/up` 选择 + +这两套映射在 `up` 轴上可能碰巧一致,但在水平轴上经常不同。 + +项目这次问题的根因之一,就是把: + +- “路径姿态用的宿主语义映射” + +误当成了: + +- “角度调整用的宿主世界轴映射” + +结果表现为: + +- `Y` 调整看起来正确 +- `X/Z` 调整方向却反了 + +#### D. 项目中的推荐实现 + +对真实物体: + +1. 先从 fragment representative frame 读取 `rawAxisX / rawAxisY / rawAxisZ` +2. 单独解析: + - 宿主世界 `X/Y/Z` 分别对应哪根 raw 本地轴 + - 宿主语义 `forward/up` 对应哪根 raw 本地轴 +3. UI 角度调整只使用“宿主世界轴映射” +4. 路径姿态求解只使用“宿主语义映射” +5. 不要让这两套映射复用同一组字段名后再靠上下文猜 + +一句话记忆: + +- **角度调整看宿主世界轴** +- **路径姿态看业务语义轴** +- **最终落到 Navisworks 时,始终要转回物体自身轴** + +### 11.6 常见误区 + +#### 11.6.1 误区:`ModelItem.Transform` 代表当前姿态 + +错误: + +```csharp +Transform3D current = item.Transform; // 这不是当前 override 后姿态 +``` + +正确: + +```csharp +ModelGeometry geometry = item.FindFirstGeometry(); +Transform3D current = geometry != null ? geometry.ActiveTransform : item.Transform; +``` + +#### 11.6.2 误区:`OverridePermanentTransform` 是绝对落位 + +错误理解: + +- “我把目标旋转/平移直接传进去,显示结果就应该等于它” + +正确理解: + +- 这是增量层 +- 最终显示结果要结合 `OriginalTransform / PermanentTransform / ActiveTransform` 一起看 + +#### 11.6.3 误区:只看 `Transform` 不看 `Geometry` + +对于很多真实物体和虚拟物体,真正决定当前显示姿态的是: + +- `ModelGeometry.ActiveTransform` +- 或 fragment 层矩阵 + +而不是: + +- `ModelItem.Transform` + +### 11.7 Fragment 与 Geometry 的关系 + +项目里对 fragment 的定位要统一: + +- fragment 适合做: + - 真实物体参考姿态解释 + - fragment 代表姿态统计 + - COM 层几何分析 +- fragment 不应优先替代 `ModelGeometry.ActiveTransform` 来读取“当前显示姿态” + +当前结论: + +- 读“当前实际姿态”优先用 `ModelGeometry` +- 读“真实物体参考姿态/原始语义姿态”时,fragment 仍然有用 + +### 11.8 COM Fragment 变换的使用规则 + +COM fragment 提供的是: + +- `GetLocalToWorldMatrix()` + - fragment 从本地到世界的完整矩阵 + +项目当前实测结论: + +- fragment 矩阵解释必须和 `ModelGeometry.ActiveTransform` 对齐验证 +- 不要只凭经验猜行列顺序 +- 当前项目排查中已经验证过: + - 解释 fragment 矩阵时,必须以 `.NET ModelGeometry` 的结果为标尺 + +### 11.9 推荐代码模式 + +#### 11.9.1 读取当前几何姿态 + +```csharp +public static bool TryGetCurrentGeometryTransform(ModelItem item, out Transform3D transform) +{ + transform = Transform3D.Identity; + if (item == null) + { + return false; + } + + ModelGeometry geometry = item.FindFirstGeometry(); + if (geometry == null) + { + return false; + } + + transform = geometry.ActiveTransform; + return true; +} +``` + +#### 11.9.2 清掉增量层后恢复到原始状态 + +```csharp var doc = Application.ActiveDocument; -var modelItems = new ModelItemCollection { modelItem }; -doc.Models.OverridePermanentTransform(modelItems, newTransform, false); - -// ✅ 重置到原始位置(清除所有增量变换) -doc.Models.ResetPermanentTransform(modelItems); +var items = new ModelItemCollection { item }; +doc.Models.ResetPermanentTransform(items); ``` -### 11.3 Transform 操作的关键区别 - -| API方法 | 作用 | 使用场景 | 注意事项 | -|---------|------|---------|---------| -| `ModelItem.Transform` | 获取原始变换 | 记录物体初始位置 | 只读属性,返回设计文件位置 | -| `OverridePermanentTransform()` | 应用增量变换 | 动画中移动物体 | 与现有变换累积,不是绝对位置 | -| `ResetPermanentTransform()` | 重置到原始位置 | 清除所有移动,恢复初始状态 | 忽略所有之前的变换 | - -### 11.4 实际应用案例 - -**案例1:动画系统中的Transform管理** +#### 11.9.3 对 item 施加增量变换 ```csharp -// 动画开始时记录原始位置 -private Transform3D _originalTransform; - -public void StartAnimation(ModelItem animatedObject) -{ - // 记录原始Transform - _originalTransform = animatedObject.Transform; - - // 移动到路径起点(增量变换) - var startTransform = Transform3D.CreateTranslation(startPosition); - var modelItems = new ModelItemCollection { animatedObject }; - doc.Models.OverridePermanentTransform(modelItems, startTransform, false); -} - -public void ResetAnimation() -{ - // 动画结束后,使用原始Transform恢复位置 - var modelItems = new ModelItemCollection { _animatedObject }; - doc.Models.OverridePermanentTransform(modelItems, _originalTransform, false); -} +var doc = Application.ActiveDocument; +var items = new ModelItemCollection { item }; +doc.Models.OverridePermanentTransform(items, incrementalTransform, false); ``` -**案例2:用户手动位置恢复** +#### 11.9.4 对 model 层更新 Units and Transform ```csharp -public void RestoreToOriginalPosition(ModelItem selectedObject) -{ - // 不需要记录Transform,直接重置到设计文件原始位置 - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { selectedObject }; - - // 清除所有增量变换,恢复到设计文件原始位置 - doc.Models.ResetPermanentTransform(modelItems); -} +Document doc = Application.ActiveDocument; +Model model = doc.Models[0]; +Transform3D oldTransform = model.Transform; +Transform3DComponents components = oldTransform.Factor(); + +components.Translation = new Vector3D(x, y, z); +components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), angleInRadians); +components.Scale = new Vector3D(scaleX, scaleY, scaleZ); + +Transform3D newTransform = components.Combine(); +doc.Models.SetModelUnitsAndTransform(model, Units.Meters, newTransform, true); ``` -### 11.5 常见Transform问题和解决方案 +#### 11.9.5 矩阵语义与行列顺序速查 -**问题1:获取不到实际位置** +这是项目后续排查姿态问题时的硬基线。 + +##### A. `Transform3D.Linear` / `Matrix3` 的项目语义 + +项目当前统一按下面这套语义理解: + +- `Transform3D.Linear` 是 3x3 线性部分 +- `Matrix3.Get(row, column)` 的参数顺序是: + - 先 `row` + - 后 `column` +- 在项目里,`Linear` 的 **列** 表示“局部轴在世界中的方向” + - 第 1 列 = 本地 `X` 轴在世界中的方向 + - 第 2 列 = 本地 `Y` 轴在世界中的方向 + - 第 3 列 = 本地 `Z` 轴在世界中的方向 + +也就是说: ```csharp -// ❌ 错误:以为 Transform 反映当前位置 -var transform = item.Transform; -// 问题:这永远返回原始Transform,即使物体已被移动 +Vector3D worldX = new Vector3D( + linear.Get(0, 0), + linear.Get(1, 0), + linear.Get(2, 0)); -// ✅ 正确:使用 BoundingBox 获取实际位置 -var actualCenter = item.BoundingBox().Center; // 反映override后的实际位置 +Vector3D worldY = new Vector3D( + linear.Get(0, 1), + linear.Get(1, 1), + linear.Get(2, 1)); + +Vector3D worldZ = new Vector3D( + linear.Get(0, 2), + linear.Get(1, 2), + linear.Get(2, 2)); ``` -**问题2:动画结束后位置不准确** +##### B. `Matrix3` 构造时的顺序 + +项目里当前按“逐行传参”构造 `Matrix3`: ```csharp -// ✅ 动画系统应该记录原始Transform并使用增量恢复 -private Transform3D _originalTransform; - -// 动画开始时 -_originalTransform = animatedObject.Transform; - -// 动画结束时恢复 -doc.Models.OverridePermanentTransform(modelItems, _originalTransform, false); +var linear = new Matrix3( + m00, m01, m02, + m10, m11, m12, + m20, m21, m22); ``` -**问题3:记录Transform但不使用** +其中: + +- 第 1 行 = `(m00, m01, m02)` +- 第 2 行 = `(m10, m11, m12)` +- 第 3 行 = `(m20, m21, m22)` + +如果你的业务语义是“列 = 局部轴”,那么组矩阵时应写成: ```csharp -// ❌ 不必要:记录Transform但使用Reset -private Transform3D _originalTransform; -_originalTransform = selectedItem.Transform; // 记录了但不使用 -doc.Models.ResetPermanentTransform(modelItems); // 直接重置 - -// ✅ 简化:直接重置,无需记录 -doc.Models.ResetPermanentTransform(modelItems); +linear = new Matrix3( + worldX.X, worldY.X, worldZ.X, + worldX.Y, worldY.Y, worldZ.Y, + worldX.Z, worldY.Z, worldZ.Z); ``` -### 11.6 Transform 最佳实践 +##### C. `System.Numerics.Matrix4x4` 的项目用法 -1. **选择合适的恢复方式**: - - 动画系统:使用 `OverridePermanentTransform` + 原始Transform - - 用户操作:使用 `ResetPermanentTransform` 直接重置 - -2. **避免不必要的Transform记录**: - - 如果只需要恢复到设计文件原始位置,使用 `ResetPermanentTransform` - - 只有需要恢复到特定中间状态时才记录Transform - -3. **理解增量vs绝对变换**: - - `OverridePermanentTransform` 是增量的,会与现有变换叠加 - - `ResetPermanentTransform` 是绝对的,清除所有变换 - -4. **线程安全**: - - 所有Transform操作都必须在主UI线程中执行 - - 使用 `Dispatcher.Invoke` 确保线程安全 - -### 11.7 旋转操作的关键限制和解决方案 ⚠️ 重要 - -基于实际测试验证的关键发现(2025-12-15)。 - -#### 11.7.1 旋转中心的API限制 - -**⚠️ 核心限制:Navisworks API的旋转总是绕世界原点(0,0,0)进行** +项目里把 `Matrix4x4` 也按同样的“列 = 基向量”语义使用: ```csharp -// ❌ 错误理解:以为旋转绕物体中心 -var rotation = new Transform3D(new Rotation3D(new UnitVector3D(0, 0, 1), angle)); -doc.Models.OverridePermanentTransform(modelItems, rotation, false); -// 实际效果:物体绕世界原点(0,0,0)"公转",不是绕自己"自转" - -// 🔍 实际测试验证: -// 物体在 (-2.499, -1.640, 0.500) 位置 -// 旋转45度后移动到 (-0.608, -2.927, 0.500) -// 验证公式:x' = x*cos(45°) - y*sin(45°) = -0.607 ✓ -// y' = x*sin(45°) + y*cos(45°) = -2.927 ✓ -// 证明:旋转中心是世界原点(0,0,0),不是物体中心 +Matrix4x4 basis = new Matrix4x4( + worldX.X, worldY.X, worldZ.X, 0f, + worldX.Y, worldY.Y, worldZ.Y, 0f, + worldX.Z, worldY.Z, worldZ.Z, 0f, + 0f, 0f, 0f, 1f); ``` -**验证代码**: +在这套写法下: + +- 第 1 列是局部 `X` 轴 +- 第 2 列是局部 `Y` 轴 +- 第 3 列是局部 `Z` 轴 + +然后再用: ```csharp -// ✅ 测试代码:证明旋转绕世界原点 -var initialCenter = item.BoundingBox().Center; // (-2.499, -1.640, 0.500) - -// 应用45度旋转 -var rotation = new Transform3D(new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI/4)); -doc.Models.OverridePermanentTransform(modelItems, rotation, false); - -var afterCenter = item.BoundingBox().Center; // (-0.608, -2.927, 0.500) - -// 计算期望位置(绕原点旋转) -double cos45 = Math.Cos(Math.PI/4); -double sin45 = Math.Sin(Math.PI/4); -double expectedX = initialCenter.X * cos45 - initialCenter.Y * sin45; // -0.607 -double expectedY = initialCenter.X * sin45 + initialCenter.Y * cos45; // -2.927 - -// 验证:实际位置 = 期望位置(绕原点旋转)✓ +Quaternion q = Quaternion.CreateFromRotationMatrix(basis); ``` -#### 11.7.2 Transform3DComponents 的行为 +##### D. 最容易犯错的地方 -**关键理解:`Transform3DComponents.Combine()` 的变换顺序** +1. 把“列是局部轴”误看成“行是局部轴” +2. 读取 `linear.Get(0, 1)` 时,以为拿到的是 `Y.X`,但后续又按行语义消费 +3. 把 `Matrix3` 直接喂给手写 quaternion 公式时,没有先确认公式使用的是“行主序矩阵”还是“列向量基矩阵” +4. 日志里看到 `X=(...), Y=(...), Z=(...)` 时,没有先确认它打印的是“列”还是“行” -```csharp -// Transform3DComponents.Combine() 应用顺序: -// 1. Scale(缩放) -// 2. Rotation(旋转,绕原点) -// 3. Translation(平移) +##### E. 项目中的统一建议 -// ❌ 错误:直接设置rotation和translation -var components = identity.Factor(); -components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); -components.Translation = deltaPos; // 平移在旋转之后应用 -var transform = components.Combine(); +1. 只要是在解释物体三轴,就统一按“列 = 局部轴在世界中的方向” +2. `Matrix3 -> Quaternion` 优先使用 `Matrix4x4 + Quaternion.CreateFromRotationMatrix(...)` +3. 不要在不同文件里混用两套相反的行列语义 +4. 新增日志时,明确写出“这里打印的是列向量/局部轴”,不要只写 `X/Y/Z` -// 问题:物体先绕原点旋转(产生位置偏移),然后平移 -// 结果:物体"公转"到错误位置 -``` - -#### 11.7.3 正确实现"绕物体中心旋转" - -**解决方案:手动计算旋转导致的位置偏移并补偿** - -```csharp -// ✅ 正确方法:计算补偿平移量 -private void UpdateObjectPosition(Point3D newPosition, double newYaw) -{ - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { _animatedObject }; - - // 计算旋转和平移增量 - var deltaPos = new Vector3D( - newPosition.X - _currentPosition.X, - newPosition.Y - _currentPosition.Y, - newPosition.Z - _currentPosition.Z - ); - - Transform3D incrementalTransform; - - if (!double.IsNaN(newYaw)) - { - double deltaYaw = newYaw - _currentYaw; - - // 🎯 关键:计算绕当前位置旋转的等效变换 - // 1. 如果绕原点旋转deltaYaw,当前位置会移到哪里? - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = _currentPosition.X * cos - _currentPosition.Y * sin; - double rotatedY = _currentPosition.X * sin + _currentPosition.Y * cos; - - // 2. 我们希望物体绕自己旋转,位置移动到newPosition - // 所以需要的平移 = newPosition - (旋转后的位置) - var compensatedTranslation = new Vector3D( - newPosition.X - rotatedX, // 补偿X方向的偏移 - newPosition.Y - rotatedY, // 补偿Y方向的偏移 - newPosition.Z - _currentPosition.Z // Z保持增量 - ); - - // 3. 组合:先旋转(绕原点),再平移(补偿+目标位置) - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = compensatedTranslation; // 关键:使用补偿后的平移 - - incrementalTransform = components.Combine(); - _currentYaw = newYaw; - } - else - { - // 纯平移:直接使用增量 - incrementalTransform = Transform3D.CreateTranslation(deltaPos); - } - - // 应用增量变换 - doc.Models.OverridePermanentTransform(modelItems, incrementalTransform, false); - _currentPosition = newPosition; -} -``` - -**原理说明**: - -``` -API限制: - 旋转 → 物体绕(0,0,0)旋转 → 位置从P1偏移到P2 - -我们需要的效果: - 旋转 → 物体绕自己旋转 → 位置从P1移动到P_target - -解决方案: - 补偿平移 = P_target - P2 - 最终变换 = Rotation(deltaYaw) + Translation(P_target - P2) - -结果: - 物体先绕原点旋转到P2,然后平移到P_target - 看起来像是绕自己旋转并移动到目标位置 -``` - -#### 11.7.4 初始化问题 - -**⚠️ 重要:初始化yaw必须与第一帧匹配** - -```csharp -// ❌ 错误:初始化为0 -_currentYaw = 0.0; -// 第一帧调用UpdateObjectPosition时: -// deltaYaw = firstFrame.YawRadians - 0.0 // 产生大的旋转增量 -// 导致物体从起点"公转"飞走 - -// ✅ 正确:初始化为第一帧的yaw -if (_animationFrames != null && _animationFrames.Count > 0) -{ - _currentYaw = _animationFrames[0].YawRadians; // 使deltaYaw=0 - - // 第一次调用UpdateObjectPosition - var firstFrame = _animationFrames[0]; - UpdateObjectPosition(firstFrame.Position, firstFrame.YawRadians); - // 此时:deltaYaw = firstFrame.YawRadians - firstFrame.YawRadians = 0 - // 结果:只有平移,没有旋转偏移 -} -``` - -#### 11.7.5 相关API限制说明 - -Autodesk官方论坛已确认的限制(Issue NW-53280): - -- **无法设置旋转中心点**:API不提供指定旋转中心的方法 -- **UI的Override Transform功能**:也是通过计算补偿实现的 -- **建议的解决方案**:手动计算T(center) × R × T(-center)的等效变换 - -#### 11.7.6 旋转操作最佳实践 - -| 场景 | 方法 | 注意事项 | -|------|------|---------| -| 简单旋转(原地) | 使用位置补偿公式 | 必须计算旋转导致的偏移 | -| 旋转+移动 | 组合补偿平移和目标平移 | 理解Combine()的变换顺序 | -| 动画初始化 | `_currentYaw = firstFrame.YawRadians` | 避免第一帧产生旋转增量 | -| 调试验证 | 测试物体远离原点的情况 | 原点附近可能掩盖问题 | - -#### 11.7.7 关键原则:移动物体前必须先重置到CAD位置 ⚠️ 重要 - -**问题场景**: -当物体已经被移动过(如动画结束在终点位置),再次移动时如果直接从当前位置计算增量,会导致错误的结果。 - -**原因**: -`OverridePermanentTransform` 的增量是相对于**CAD原始位置**的,不是相对于当前位置。 - -**❌ 错误做法**: -```csharp -// 物体当前在终点位置,但我们要移动到另一个位置 -var currentPos = item.BoundingBox().Center; // 终点位置 -var deltaPos = new Vector3D( - targetPos.X - currentPos.X, // 从终点计算增量 - 错误! - targetPos.Y - currentPos.Y, - targetPos.Z - currentPos.Z -); -var transform = Transform3D.CreateTranslation(deltaPos); -doc.Models.OverridePermanentTransform(modelItems, transform, false); -// 结果:物体会移动到错误位置(因为增量是相对于CAD位置的) -``` - -**✅ 正确做法**: -```csharp -// 1. 先重置到CAD原始位置 -doc.Models.ResetPermanentTransform(modelItems); - -// 2. 从CAD原始位置计算到目标位置的增量 -var originalBounds = item.BoundingBox(); -var originalPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z -); -var deltaPos = new Vector3D( - targetPos.X - originalPos.X, // 从CAD位置计算增量 - 正确! - targetPos.Y - originalPos.Y, - targetPos.Z - originalPos.Z -); - -// 3. 应用变换 -var transform = Transform3D.CreateTranslation(deltaPos); -doc.Models.OverridePermanentTransform(modelItems, transform, false); -``` - -**使用场景**: -- 碰撞报告还原物体到碰撞位置 -- 手动指定物体位置 -- 任何需要精确控制物体最终位置的操作 - -**最佳实践**: -```csharp -/// -/// 将物体移动到指定位置和朝向(先回到CAD原始位置) -/// -public static void MoveItemToPositionAndYaw(ModelItem item, Point3D targetPosition, double targetYaw) -{ - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { item }; - - // 🔥 关键:先回到CAD原始位置 - doc.Models.ResetPermanentTransform(modelItems); - - // 获取CAD原始状态 - var originalBounds = item.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); - var originalYaw = GetYawFromTransform(item.Transform); - - // 计算从CAD位置到目标位置的增量 - var deltaPos = new Vector3D( - targetPosition.X - originalGroundPos.X, - targetPosition.Y - originalGroundPos.Y, - targetPosition.Z - originalGroundPos.Z - ); - double deltaYaw = targetYaw - originalYaw; - - // 应用增量变换(包含旋转补偿) - Transform3D transform; - if (Math.Abs(deltaYaw) > 0.001) - { - // 计算旋转补偿 - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; - - var compensatedTranslation = new Vector3D( - targetPosition.X - rotatedX, - targetPosition.Y - rotatedY, - deltaPos.Z - ); - - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = compensatedTranslation; - transform = components.Combine(); - } - else - { - transform = Transform3D.CreateTranslation(deltaPos); - } - - doc.Models.OverridePermanentTransform(modelItems, transform, false); -} -``` - -**调试技巧**: - -```csharp -// ✅ 测试旋转中心的方法 -// 1. 将物体移动到远离原点的位置(如(-5, -5, 0)) -// 2. 应用旋转 -// 3. 检查物体是否"公转"(位置大幅移动)还是"自转"(位置基本不变) -// 4. 如果发现"公转",说明没有正确补偿 - -// ✅ 验证补偿计算的公式 -double expectedX_afterRotation = currentX * cos(angle) - currentY * sin(angle); -double expectedY_afterRotation = currentX * sin(angle) + currentY * cos(angle); -var compensationX = targetX - expectedX_afterRotation; -var compensationY = targetY - expectedY_afterRotation; - -LogManager.Debug($"旋转前: ({currentX}, {currentY})"); -LogManager.Debug($"绕原点旋转后: ({expectedX_afterRotation}, {expectedY_afterRotation})"); -LogManager.Debug($"目标位置: ({targetX}, {targetY})"); -LogManager.Debug($"需要补偿: ({compensationX}, {compensationY})"); -``` +### 11.10 项目级硬约束 +1. 读当前姿态时,不要再默认用 `ModelItem.Transform` +2. 真实物体参考姿态和当前姿态是两回事,不要混 +3. `OverridePermanentTransform` 是增量,不是绝对落位 +4. `ResetPermanentTransform` 清的是增量层,不是原始几何 +5. 涉及当前显示姿态时,优先看 `ModelGeometry.ActiveTransform` +6. 涉及 fragment 矩阵解释时,先与 `ModelGeometry.ActiveTransform` 对齐验证 ## 12. Item属性和自定义属性访问 基于官方示例的正确属性访问方法总结。 diff --git a/doc/design/2026/coordinate-system-canonical-space-design.md b/doc/design/2026/coordinate-system-canonical-space-design.md new file mode 100644 index 0000000..77acdb5 --- /dev/null +++ b/doc/design/2026/coordinate-system-canonical-space-design.md @@ -0,0 +1,664 @@ +# 坐标系统一架构设计方案 + +## 1. 背景 + +当前项目运行在 Navisworks 宿主环境中,程序直接读取和写入的都是 Navisworks 世界坐标: + +- `Point3D` +- `Vector3D` +- `BoundingBox3D` +- `Transform3D` +- 鼠标点击拾取点 +- `Document.UpVector` + +这些坐标对程序来说属于**外部坐标**。 + +项目目前的问题不是“完全没有坐标系抽象”,而是: + +- 有一部分代码已经开始抽象 `Y-up / Z-up` +- 另一部分代码仍然直接写死世界 `Z` +- 还有一部分代码把世界原点直接当作业务球心 + +结果是三种语义混在一起: + +1. Navisworks 外部坐标语义 +2. 程序内部几何计算语义 +3. 工程业务基准语义(球心、安装基准、轨道参考面) + +这会导致: + +- `Y-up` / `Z-up` 项目切换后功能不一致 +- 终端安装仿真把世界原点误当成球心 +- Rail 姿态和渲染层偷用世界 `Z` +- 不同模块对同一个点的解释不一致 + +因此,需要建立一套更成熟、更清晰的坐标系架构。 + +## 2. 设计目标 + +本方案的目标不是“到处加 `if (isYUp)`”,而是建立一个标准的分层架构: + +- Navisworks 世界坐标统一视为**外部坐标** +- 程序内部统一使用一套**规范内部坐标** +- 工程语义使用独立的**业务基准坐标** +- 只有插件自带资源才允许引入**资产坐标系** +- 坐标转换只发生在少数边界入口 +- 业务逻辑禁止直接依赖宿主坐标语义 + +## 3. 总体方案 + +### 3.1 内部统一坐标 + +程序内部统一使用: + +- **Canonical Space(规范内部坐标)** +- **固定为 `Z-up`** + +选择 `Z-up` 的原因: + +1. 当前项目大量成熟逻辑本身就是按 `Z-up` 语义构建的 +2. 动画、渲染、yaw 语义、很多 helper 都更接近 `Z-up` +3. 以 `Z-up` 作为内部标准,改造成本低于整体改成 `Y-up` + +注意: + +- 这只是程序内部选择 +- 不代表客户模型必须是 `Z-up` +- 客户仍可继续使用 `Y-up` 项目 + +### 3.2 三层坐标语义 + +```mermaid +flowchart LR + A["Navisworks Host Space\n外部坐标"] --> B["Host Coordinate Adapter\n边界适配层"] + B --> C["Canonical Space (Z-up)\n内部统一坐标"] + C --> D["Project Reference Frame\n业务基准坐标"] + + C --> E["路径规划/几何计算"] + C --> F["Rail 姿态/动画"] + C --> G["碰撞检测/结果恢复"] + C --> H["渲染/辅助线/通行空间"] + + D --> I["球心"] + D --> J["终端安装基准"] + D --> K["轨道参考面"] + + C --> B + B --> L["Navisworks 输出\n渲染/移动/截图恢复"] +``` + +三层职责如下: + +#### A. Navisworks Host Space(外部坐标) + +宿主 API 直接提供的坐标。 + +特点: + +- 是程序的输入/输出坐标 +- UI 文本框、对话框、日志、鼠标拾取结果,统一按宿主坐标系解释和显示 +- 可能来自 `Y-up` 项目,也可能来自 `Z-up` 项目 +- 不应直接当成内部计算坐标 + +#### B. Canonical Space(内部统一坐标) + +程序内部唯一允许进行几何计算、路径计算、姿态计算的坐标空间。 + +特点: + +- 固定为 `Z-up` +- 只解决坐标轴和方向语义统一问题 +- 不承载业务基准含义 + +#### C. Project Reference Frame(业务基准坐标) + +建立在内部统一坐标基础上的工程语义层。 + +负责表达: + +- 球心 +- 项目 up 方向的业务解释 +- 终端安装参考面 +- 轨道参考面 + +### 3.2.1 术语约束 + +后续文档、代码注释和日志中,统一使用以下说法: + +- **宿主坐标系(Host Space)** + - 指 Navisworks 文档坐标系 + - `Y-up` / `Z-up` 的判断只属于这一层 +- **内部坐标系(Canonical Space)** + - 指程序内部统一使用的 `Z-up` 坐标系 + - 仅供内部计算使用,禁止直接暴露给 UI +- **资产坐标系(Asset Space)** + - 只用于插件自带资源 + - 当前明确只有两类:`虚拟物体` 与 `单位圆柱体(参考杆资源)` + - 用于描述这些资源文件自身的 `Forward/Up/Side` 轴约定 + +禁止继续使用含糊的“本地坐标系”说法,因为它容易混淆: + +- 宿主坐标系 +- 内部坐标系 +- 资产坐标系 + +后续凡是 UI 输入/输出,必须明确是**宿主坐标系**语义;凡是姿态或几何内部计算,必须明确是**内部坐标系**语义;凡是虚拟物体/单位圆柱体资源朝向,必须明确是**资产坐标系**语义。 +- 业务锚点 + +### 3.2.2 Quaternion / Rotation3D 解释约束 + +坐标系分层之外,还必须固定一条旋转解释规则: + +- Navisworks `Rotation3D(double, double, double, double)` 的参数顺序固定为 `x, y, z, w` +- `Rotation3D.A/B/C/D` 固定对应 quaternion 的 `x/y/z/w` + +这条规则在项目中视为硬约束,禁止后续代码再次用不同顺序解释四元数。 + +因此: + +- 内部坐标系里算出的 quaternion `(qx, qy, qz, qw)`,写回 Navisworks 时必须使用: + +```csharp +var rotation = new Rotation3D(qx, qy, qz, qw); +``` + +- 后续遇到姿态异常时,不应再次怀疑 `Rotation3D` 分量顺序;应优先检查: + - 宿主坐标系 / 内部坐标系 / 资产坐标系 是否混用 + - fragment 参考姿态是否被误当成业务语义姿态 + - 真实物体是否错误地只传 quaternion 而没有保留显式参考轴 + +这层不能偷用世界原点,也不能偷用宿主世界轴。 + +### 3.3 坐标系定义必须包含的语义 + +在本项目中,`Y-up` / `Z-up` 不能只被理解成“点坐标怎么换算”。 + +一个完整的坐标系定义,至少必须显式包含: + +- `UpAxis` +- `ElevationAxis` +- `HorizontalPlane` +- 必要时的 `Handedness` + +因此: + +- `Y-up` 的含义是: + - `Y` 为 up 轴 + - `Y` 为高程轴 + - `XZ` 为水平平面 +- `Z-up` 的含义是: + - `Z` 为 up 轴 + - `Z` 为高程轴 + - `XY` 为水平平面 + +后续凡是出现: + +- 高度 +- 底面 +- 顶面 +- 通行空间高度轴 +- 俯仰/法向 + +都必须基于这组定义来解释,不能继续偷用“世界 Z 就是 up”的旧假设。 + +### 3.4 资产坐标系是独立层 + +除了宿主坐标系和内部统一坐标系,还必须区分**资产坐标系**。 + +注意,这一层**不是所有模型都有**。当前项目里只有插件自带资源需要它: + +- 虚拟物体资源 +- 单位圆柱体参考杆资源 + +这是另一层独立定义,至少包含: + +- `AssetForwardAxis` +- `AssetUpAxis` + +例如: + +- 虚拟物体资源通常按程序约定构建,可能是 `Asset X = Forward, Asset Z = Up` +- 单位圆柱体参考杆资源当前约定为 `Asset X = 杆轴方向, Asset Z = 截面 Up` + +而对于客户真实模型: + +- 不单独引入“资产坐标系”概念 +- UI 和数据输入输出仍只认宿主坐标系 +- 内部算法如需统一姿态,先进入 Canonical Space,再叠加业务参考信息 + +这意味着: + +- 即使宿主坐标系转换已经正确 +- 如果资产坐标系语义没有显式处理 +- 动画和姿态仍然会出现“路径对了、通行空间对了、模型自己站歪了”的现象 + +因此,后续姿态系统必须显式区分: + +1. 宿主坐标系定义 +2. 内部统一坐标系定义 +3. 业务基准定义 +4. 资产坐标系约定(仅限虚拟物体和单位圆柱体等插件自带资源) + +### 3.5 Rail 局部坐标系与动画跟踪点 + +`Rail` 路径不能再只理解成“沿世界 up 做上下偏移”。 + +对 `Rail` 来说,必须显式建立一套局部坐标系: + +- `Forward` + - 沿轨道前进方向 +- `Normal` + - 安装法向 + - 可以是任意空间角度 + - 但应尽量贴近项目 up +- `Lateral` + - 由 `Normal x Forward` 或正交化后确定 + +后续所有 `Rail` 相关计算都应建立在这套 `RailLocalFrame` 之上: + +- 物体姿态 +- 通行空间姿态 +- 路径参考点到动画跟踪点的偏移 +- 终点贴合语义 + +### 3.6 动画跟踪点统一语义 + +动画系统内部不应混用: + +- 底面中心 +- 顶面中心 +- 包围盒中心 +- 路径参考点 + +统一规则应为: + +- **动画跟踪点 = 当前动画主链路使用的唯一位置语义** +- 当前阶段建议统一为:**几何中心** + +这样做的原因是: + +- 中心点对三维旋转最中性 +- 不依赖“当前哪一面朝上” +- 不依赖宿主 `Y-up / Z-up` +- 物体姿态与碰撞恢复、截图回放更容易共用同一套语义 + +不同路径的业务需求通过“参考点 -> 跟踪中心点”的显式偏移来表达: + +- 地面/吊装:通常沿 `+up / -up` +- Rail:沿 `RailLocalFrame.Normal` + +这样: + +- 业务层只表达“路径参考点”和“法向偏移” +- 动画层只认“中心点 + 姿态” + +## 4. 核心设计原则 + +### 4.1 Navisworks 世界坐标统一视为外部坐标 + +这是本方案的第一条硬规则。 + +对程序而言,以下数据统一视为外部输入: + +- `Point3D` +- `Vector3D` +- `BoundingBox3D` +- `Transform3D` +- `ModelItem.BoundingBox()` +- `Document.UpVector` +- 鼠标点击拾取点 + +无论当前 NWD/NWC 是客户原始模型,还是预先转换保存后的模型, +**只要是从 Navisworks API 读出来的,它对程序来说就是外部坐标。** + +### 4.1.1 外部坐标不等于内部语义 + +外部坐标虽然来自 Navisworks,但不能直接拿来推导内部业务语义。 + +特别是以下概念必须先经过坐标定义解释: + +- 哪个轴是 up +- 哪个轴是高程 +- 哪个平面是水平面 +- 一个 `BoundingBox` 的“底面”到底是哪一面 + +也就是说: + +- 外部坐标是输入 +- 坐标定义决定如何解释这个输入 +- 业务代码不得跳过这一步 + +### 4.2 程序内部一律只认 Canonical Space + +业务层不得直接消费 Navisworks 坐标。 + +禁止这样做: + +```csharp +// ❌ 错误:直接拿宿主包围盒结果开始做业务计算 +var bounds = item.BoundingBox(); +var center = bounds.Center; +var direction = new Vector3D(center.X, center.Y, center.Z); +``` + +正确做法应当是: + +```csharp +// ✅ 正确:先通过边界适配层转换到内部统一坐标 +var hostBounds = item.BoundingBox(); +var bounds = adapter.ToCanonicalBounds(hostBounds); +var center = bounds.Center; +``` + +### 4.3 坐标系层只解决轴变换,不解决业务语义 + +坐标适配层只负责: + +- 外部坐标到内部坐标的变换 +- 内部坐标到外部坐标的逆变换 +- up 轴、高程轴、水平面定义 + +不负责: + +- 球心是否在 `(0,0,0)` +- 终端安装是否指向球心 +- 顶面/底面对接如何定义 +- 轨道参考面在哪里 + +这些都属于业务基准层。 + +### 4.4 项目基准点必须显式配置或显式求解 + +像“球心”这类工程点不属于坐标系本身。 + +不能因为某一批模型里球心恰好在世界原点,就长期把它写死。 + +应使用以下来源之一: + +- 项目配置 +- 明确的设计资料 +- 多条向心轴线拟合 +- 其他明确的业务求解方式 + +### 4.5 不在业务代码中散落 `Y-up / Z-up` 分支 + +不推荐: + +```csharp +// ❌ 错误 +if (isYUp) +{ + ... +} +else +{ + ... +} +``` + +推荐: + +- 边界适配层统一处理 `Host -> Canonical` +- 业务层只消费 Canonical 数据 + +### 4.6 渲染几何也必须完成坐标转换 + +坐标系改造不能只停留在: + +- 点坐标转换 +- 中心点偏移 +- 业务锚点转换 + +对于以下可视化对象,还必须同步改造它们的**局部几何轴构造**: + +- 通行空间长方体 +- 辅助线杆体 +- 圆形/圆柱标记 +- 切向、法向、侧向相关的渲染面片 + +否则会出现一种典型错误: + +- 对象中心点已经在正确位置 +- 但渲染几何仍然按世界 `Z-up` 去构造 `right/up/height` +- 最终看起来仍然像 `Z-up`,即使业务点位已经是对的 + +本项目已经出现过这一类问题: + +- `Y-up` 模型中,通行空间中心点偏移已经正确 +- 但长方体本体仍然按世界 `XY + Z` 构造 +- 导致通行空间整体看起来仍是 `Z-up` + +因此,渲染层必须遵守以下规则: + +1. 凡是依赖 `up/right/forward/normal` 的渲染几何,必须基于统一坐标语义构造局部轴。 +2. 不允许只修改“中心点/偏移量”而保留旧的世界轴构造公式。 +3. 如果渲染输出面向 Navisworks 宿主,则应先在 Canonical Space 中完成几何语义计算,再转换回宿主坐标输出。 +4. 对长方体、圆柱体这类实体渲染,必须同时验证: + - 中心点是否正确 + - 局部高度轴是否正确 + - 局部侧向轴是否正确 + - 法向/截面方向是否正确 + +## 5. 推荐的技术方案 + +### 5.1 使用完整变换矩阵作为适配基础 + +不推荐继续停留在: + +- `GetElevation()` +- `GetHorizontalCoords()` +- `CreatePoint()` + +这类偏二维、局部的接口上。 + +对三维动画、Rail 姿态、碰撞恢复来说,更成熟的方案是: + +- 使用完整的空间变换对象 +- 以矩阵/旋转+平移为核心 + +即: + +- `ExternalToCanonical` +- `CanonicalToExternal` + +这两套变换应成为边界层的基础能力。 + +### 5.2 建议新增的核心组件 + +#### 1. `HostCoordinateAdapter` + +职责: + +- 统一处理 Navisworks 外部坐标到 Canonical Space 的转换 +- 统一处理 Canonical Space 到 Navisworks 外部坐标的反向转换 + +建议能力: + +- `ToCanonicalPoint(...)` +- `ToCanonicalVector(...)` +- `ToCanonicalBounds(...)` +- `ToCanonicalTransform(...)` +- `FromCanonicalPoint(...)` +- `FromCanonicalVector(...)` +- `FromCanonicalBounds(...)` +- `FromCanonicalTransform(...)` + +#### 2. `CanonicalTransform` + +职责: + +- 封装 `ExternalToCanonical / CanonicalToExternal` +- 提供点、向量、姿态、包围盒的统一变换 + +#### 3. `ProjectReferenceFrame` + +职责: + +- 管理业务基准语义 + +建议包含: + +- `SphereCenterInCanonical` +- `ProjectUpInCanonical` +- `RailReferencePlane` +- `AssemblyReferenceFrame` + +### 5.3 业务层只消费 Canonical 对象 + +终端安装、Rail、动画、碰撞、渲染层不应直接使用: + +- Navisworks `Point3D` +- Navisworks `BoundingBox3D` +- Navisworks `Transform3D` + +而应通过适配层先转成内部统一对象后再计算。 + +## 6. 输入输出边界 + +### 6.1 必须拦截的输入入口 + +以下都是必须拦截的宿主输入点: + +- 鼠标点击获取路径点 +- `ModelItem.BoundingBox()` +- `ModelItem.Transform` +- `Geometry.BoundingBox` +- `Document.UpVector` +- `PathClickToolPlugin` 等交互工具返回的点 + +这些点一旦进入业务逻辑,就应先转换到 Canonical Space。 + +### 6.2 必须反向转换的输出入口 + +以下都是必须回写到宿主时做反向转换的输出点: + +- 3D 渲染点、线、法向 +- 动画对象位置与姿态 +- 碰撞点恢复 +- 碰撞报告截图定位 +- 辅助线/参考杆/通行空间可视化 + +## 7. 当前项目中的优先改造模块 + +以下模块优先级最高,因为它们既参与三维姿态,又直接暴露了世界坐标假设问题: + +### 7.1 必改 + +- [PathEditingViewModel.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/UI/WPF/ViewModels/PathEditingViewModel.cs) + - 当前终端安装仿真里仍把世界原点当球心 +- [RailPathPoseHelper.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Utils/RailPathPoseHelper.cs) + - 当前仍把世界 `Z` 当 `worldUp` +- [PathPointRenderPlugin.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/PathPointRenderPlugin.cs) + - 当前大量渲染法向仍写死 `(0,0,1)` +- [PathAnimationManager.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/Animation/PathAnimationManager.cs) + - 需要逐步统一输入输出边界 +- [CollisionSceneHelper.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Utils/CollisionSceneHelper.cs) + - 需要确保碰撞恢复只使用内部语义 + +### 7.2 次改 + +- 自动路径规划相关的高度、坡度、网格构建 +- 历史二维 `yaw` 辅助逻辑 +- 旧视图辅助和截图辅助 + +这些部分可以后续逐步纳入统一架构,不必阻塞当前终端安装与 Rail 主线。 + +## 8. 迁移策略 + +不建议一次性全项目重构。 + +建议按以下顺序推进: + +### 阶段 1 + +建立边界层: + +- `HostCoordinateAdapter` +- `CanonicalTransform` +- `ProjectReferenceFrame` + +### 阶段 2 + +先接入以下主线功能: + +- 终端安装仿真 +- Rail 姿态 +- 动画播放 +- 碰撞恢复 +- 辅助线/通行空间渲染 + +阶段 2 的两个重要约束: + +1. 真实物体物理尺寸必须固定 + +- 起点贴合、动画帧生成、通行空间尺寸、碰撞恢复 +- 必须共用同一份固定物理尺寸 +- 不允许在对象已经旋转后,再从当前世界 AABB 重新推导“真实高度” + +否则会产生典型错误: + +- 起点贴合正确 +- 动画第一帧立刻出现固定间隙 + +2. 渲染与业务计算必须同时改 + +- 不能只改参考点、中心点、偏移量 +- 还必须同步改渲染几何局部轴: + - `right` + - `up` + - `normal` + - `height axis` + +否则会出现: + +- 业务点位正确 +- 但通行空间/辅助杆/长方体仍按旧 `Z-up` 轴构造 + +### 阶段 3 + +再逐步改造: + +- 自动路径规划 +- 高度检测 +- 坡度分析 +- 旧二维路径辅助逻辑 + +### 部署约束 + +WPF 插件的最终部署,必须依赖完整主项目构建产物,而不应默认复用测试顺带生成的程序集。 + +原因: + +- DLL 时间戳正确,不代表插件可运行 +- 如果 `TransportPlugin.g.resources` 不完整,Navisworks 在创建面板时仍会因缺少 `.baml` 崩溃 + +必须保证: + +- 主项目完整构建成功 +- 关键视图资源已经编入程序集 + +最低检查集: + +- `LogisticsControlPanel.baml` +- `PathEditingView.baml` +- `AnimationControlView.baml` +- `LayerManagementView.baml` + +## 9. 关键结论 + +本项目如果要长期稳定支持 `Y-up` 和 `Z-up` 项目,正确方向不是: + +- 到处散落 `if (isYUp)` +- 强行要求客户把模型先转成 `Z-up` +- 继续混用世界原点和业务球心 + +而应该是: + +- **Navisworks 世界坐标统一视为外部坐标** +- **程序内部统一使用 Canonical Space(Z-up)** +- **业务基准点单独建模** +- **UI 输入输出统一使用宿主坐标系** +- **资产坐标系只属于插件自带资源,不得泛化成“所有模型都有本地轴”** +- **坐标转换只发生在边界层** + +这是一条更接近业界三维软件成熟做法的路线。 diff --git a/doc/design/2026/double-rail-mount-path-design.md b/doc/design/2026/double-rail-mount-path-design.md new file mode 100644 index 0000000..aa9211a --- /dev/null +++ b/doc/design/2026/double-rail-mount-path-design.md @@ -0,0 +1,299 @@ +# 双轨安装头空轨路径扩展设计 + +## 背景 + +当前项目中的 `PathType.Rail` 语义实际等同于“轨下悬挂运输”: + +- 基准路径来自空轨下表面中心线 +- 动画默认将物体沿世界 `Z` 方向向下偏移一个物体高度 +- 通行空间也默认按“路径点在物体上方”渲染 + +这套模型无法准确表达新的双轨安装头场景: + +- 轨道为两根平行轨道 +- 安装头在两轨中间滑动 +- 箱型构件与安装头刚性连接 +- 构件以顶面中心或底面中心与安装头对接 +- 安装头支持轨上、轨下两种安装方式 +- 轨道支持斜向上、斜向下 + +## 目标 + +将现有“空轨路径”升级为“轨道导向装配运输路径”,支持以下 4 种基本构型: + +1. 轨上 + 斜向上 +2. 轨上 + 斜向下 +3. 轨下 + 斜向上 +4. 轨下 + 斜向下 + +并支持两种构件对接方式: + +1. 顶面中心对接安装头 +2. 底面中心对接安装头 + +## 设计原则 + +1. 不再以“轨下中心线”作为 Rail 路径的唯一几何语义。 +2. 路径数据应表达“安装头中心的参考路径”。 +3. 构件实际动画位置和碰撞包络,应从“参考路径 + 安装构型”推导。 +4. 对斜轨路径,偏移和姿态必须基于轨道局部坐标系,而不是简单沿世界 `Z` 轴处理。 +5. 保持旧 Rail 路径可加载,并为旧数据提供默认兼容语义。 + +## 新数据模型 + +### RailMountMode + +```csharp +public enum RailMountMode +{ + UnderRail = 0, + OverRail = 1 +} +``` + +含义:安装头位于双轨参考平面的下方或上方。 + +### RailPayloadAnchorMode + +```csharp +public enum RailPayloadAnchorMode +{ + TopCenter = 0, + BottomCenter = 1 +} +``` + +含义:构件以顶面中心或底面中心与安装头刚性对接。 + +### RailPathDefinitionMode + +```csharp +public enum RailPathDefinitionMode +{ + LegacyBottomCenterLine = 0, + InstallationHeadCenterLine = 1 +} +``` + +含义: + +- `LegacyBottomCenterLine` 用于兼容旧空轨路径 +- `InstallationHeadCenterLine` 表示新的安装头中心参考路径 + +### PathRoute 新增字段 + +建议为 `PathRoute` 增加以下字段: + +```csharp +public RailMountMode RailMountMode { get; set; } +public RailPayloadAnchorMode RailPayloadAnchorMode { get; set; } +public RailPathDefinitionMode RailPathDefinitionMode { get; set; } +public double RailHeadToPayloadAnchorOffset { get; set; } +public double RailGaugeCenterOffset { get; set; } +``` + +说明: + +- `RailHeadToPayloadAnchorOffset` + - 安装头中心到构件对接中心的距离 + - 使用模型单位 + - 用于精确表达安装头结构厚度和连接件高度 +- `RailGaugeCenterOffset` + - 双轨中心线到安装头中心线的附加偏移 + - 默认通常为 `0` + - 为后续非对称安装场景留扩展位 + +## 局部坐标系 + +每一段 Rail 路径在采样时建立局部坐标系: + +- `T`:沿轨道方向的单位向量 +- `N`:轨道平面法向,用于区分轨上 / 轨下 +- `B`:横向向量,满足右手系 + +建议约束: + +- 双轨所在平面由轨道主方向和全局竖直方向共同确定 +- 当轨道接近竖直时,仍应优先走显式异常,而不是隐式回退 + +构件实际锚点位置: + +```text +PayloadAnchorPosition = RailReferencePoint + sign(mountMode) * N * offset +``` + +其中: + +- `sign(UnderRail) = -1` +- `sign(OverRail) = +1` + +构件几何中心再根据 `TopCenter` / `BottomCenter` 与物体高度换算得到。 + +## 参考路径定义 + +### 旧逻辑 + +当前 `RailGeometryHelper` 输出的是: + +- 空轨下表面中心线 + +### 新逻辑 + +建议升级为输出: + +- 双轨中间的安装头参考中心线 + +实现上分两层: + +1. `ExtractRailReferencePath` + - 负责提取轨道导向参考线 +2. `ResolvePayloadPath` + - 根据安装构型计算构件实际运动参考路径 + +## 兼容策略 + +### 历史路径 + +旧路径未包含 `RailMountMode`、`RailPayloadAnchorMode` 等信息时,按以下默认值兼容: + +```text +RailMountMode = UnderRail +RailPayloadAnchorMode = TopCenter +RailPathDefinitionMode = LegacyBottomCenterLine +RailHeadToPayloadAnchorOffset = 0 +RailGaugeCenterOffset = 0 +``` + +兼容语义: + +- 保持旧项目行为尽量不变 +- 旧路径仍按“轨下悬挂”解释 + +### 新路径 + +新创建的 Rail 路径统一采用: + +```text +RailPathDefinitionMode = InstallationHeadCenterLine +``` + +## 需要修改的模块 + +### 1. 模型层 + +文件: + +- `src/Core/PathPlanningModels.cs` +- `src/Commands/CreateAerialPathCommand.cs` + +工作: + +- 增加 Rail 相关枚举 +- 为 `PathRoute` 增加 Rail 构型字段 +- 新建 Rail 路径时写入默认配置 + +### 2. Rail 几何提取 + +文件: + +- `src/PathPlanning/RailGeometryHelper.cs` + +工作: + +- 将“下表面中心线”抽象为“参考路径提取” +- 区分旧路径提取和新路径提取 +- 为双轨场景预留“轨道中心参考线”提取接口 + +### 3. 交互吸附 + +文件: + +- `src/Core/PathPlanningManager.cs` +- `src/UI/WPF/ViewModels/PathEditingViewModel.cs` + +工作: + +- 用户点击继续吸附到 Rail 参考路径 +- 新建 Rail 路径时允许指定安装构型 + +### 4. 渲染 + +文件: + +- `src/Core/PathPointRenderPlugin.cs` + +工作: + +- 将 `PathType.Rail` 的固定“向上/向下 Z 偏移”改为局部法向偏移 +- 通行空间不再假定路径点永远位于物体顶部 + +### 5. 动画 + +文件: + +- `src/Core/Animation/PathAnimationManager.cs` + +工作: + +- 将 Rail 路径下的构件位置计算改为: + - 先得到安装头参考点 + - 再根据 `RailMountMode` 和 `RailPayloadAnchorMode` 推导物体位置 +- 后续增加 pitch/roll 对齐能力 + +### 6. 导出 + +文件: + +- `src/Core/PathDataManager.cs` + +工作: + +- 将 `suspension` 语义调整为更中性的 rail-guided aerial path +- 视下游格式能力决定是否补充导出字段 + +## 分阶段实现建议 + +### Phase 1:最小可用版本 + +目标:先支持 4 种构型的碰撞检测和动画位置正确。 + +范围: + +- 增加 Rail 构型字段 +- Rail 动画和通行空间偏移改为基于安装构型计算 +- 继续沿用现有参考线提取,但将其抽象为“参考路径” + +限制: + +- 暂不处理完整 pitch/roll 姿态 +- 暂不精确重建双轨实体几何关系 + +### Phase 2:几何语义升级 + +目标:把参考路径从“轨下中心线”升级为“安装头中心线”。 + +范围: + +- 重构 `RailGeometryHelper` +- 为双轨模型提取真正的中线 +- 新旧路径分别兼容 + +### Phase 3:姿态精化 + +目标:对斜轨和长臂构件提供更真实的空间姿态。 + +范围: + +- 动画支持 pitch / roll +- 构件包络与局部坐标系一致 +- 提升碰撞检测准确性 + +## 当前建议 + +本次开发先从 Phase 1 开始: + +1. 先把路径模型改为可表达 4 种构型 +2. 先把渲染和动画从“世界 Z 偏移”改成“构型驱动偏移” +3. 再进入轨道参考线提取的升级 + +这样风险更低,也更容易验证。 diff --git a/doc/design/2026/linear-assembly-reference-path.md b/doc/design/2026/linear-assembly-reference-path.md new file mode 100644 index 0000000..002141f --- /dev/null +++ b/doc/design/2026/linear-assembly-reference-path.md @@ -0,0 +1,299 @@ +# 终点反推的直线装配路径设计 + +## 背景 + +当前空轨路径已经扩展出轨上/轨下、顶面对接/底面对接、局部姿态等能力,但用户进一步澄清了真实业务: + +- 已知箱体最终安装后的准确位置和空间方向 +- 该场景只需要一条起点到终点的直线运动 +- Navisworks 里用户无法点击空气中的点,必须点击到某个实际对象 + +因此,这类路径不应继续建模为“轨道识别”问题,而应建模为“已知终点位姿的直线装配运动”问题。 + +## 核心思路 + +### 1. 终点位姿作为主输入 + +由用户选择终点处已经组装好的箱体,系统读取: + +- 包围盒 +- 当前朝向 +- 终点构件中心 / 顶面中心 / 底面中心 + +终点位姿是整个运动仿真的主基准。 + +### 2. 生成可点击的参考杆 + +为了满足 Navisworks 的点击限制,系统不只绘制一条渲染线,而是生成一个临时参考实体: + +- 形态:细长杆体 +- 初版建议:直接复用 `unit_cube.nwc`,通过缩放生成细长方杆 +- 作用: + - 可视化直线参考路径 + - 允许用户点击取点 + - 为起点吸附提供稳定对象 + +### 3. 用户点击参考杆确定起点 + +用户在三维视图中点击参考杆上的某个位置后: + +- 获取点击坐标 +- 将点击点投影到参考杆轴线 +- 该投影点作为真实起点 + +### 4. 生成最终直线路径 + +系统根据: + +- 起点 +- 终点位姿 +- 轨上/轨下 +- 顶面对接/底面对接 + +生成直线路径点列,并继续复用现有: + +- 动画 +- 通行空间 +- 碰撞检测 + +## 与当前 Rail 方案的关系 + +### 保留 + +- `RailMountMode` +- `RailPayloadAnchorMode` +- `RailPathDefinitionMode` +- `RailPathPoseHelper` +- 动画完整姿态支持 + +这些都仍然有价值,因为终点和路径参考点之间仍需要姿态推导。 + +### 弱化 / 退出主路径 + +- 轨道几何识别 +- 双轨自动分析 +- 基于轨道装置的中心线提取 + +这些能力可保留为辅助模式,但不再作为该场景的主生成逻辑。 + +## 最小可行实现 + +### Phase 1 + +新增“终点反推参考杆”基础能力: + +- 选择终点箱体 +- 读取终点包围盒和朝向 +- 生成参考杆临时实体 +- 支持显示 / 隐藏 / 更新参考杆 + +### Phase 2 + +新增“点击参考杆生成直线路径”: + +- 用户点击参考杆 +- 起点投影到参考线 +- 终点由终点箱体位姿推导 +- 自动生成两点直线路径 + +### Phase 3 + +补齐 UI 与持久化: + +- 在 PathEditingViewModel / PathEditingView 中新增入口 +- 保存终点箱体引用、参考线方向、参考杆长度等参数 + +## 推荐复用点 + +- `VirtualObjectManager` + - 复用 `unit_cube.nwc` 的加载、缩放、隐藏逻辑 +- `ModelItemTransformHelper` + - 复用带缩放的位姿变换 +- `NavisworksSelectionHelper` + - 复用当前选择对象读取 +- `PathPlanningManager` + - 复用点击工具和路径点落点链路 + +## 需要新增的能力 + +建议新增一个专用管理器,而不是继续塞进 `VirtualObjectManager`: + +- `AssemblyReferencePathManager` + +职责: + +- 管理参考杆临时实体 +- 根据终点箱体位姿生成参考轴线 +- 提供点击点到参考线的投影 +- 输出最终直线路径点 + +## 结论 + +本需求的主问题是“箱体的直线装配运动仿真”,不是“轨道识别”。 +推荐将实现重心切换为: + +`终点箱体位姿 -> 参考杆 -> 点击起点 -> 直线路径 -> 动画/碰撞` + +这条路线比轨道几何分析更稳定、更符合 Navisworks 交互约束,也更贴近用户真实使用方式。 + +## 当前实现状态 + +当前 worktree 中已经完成一版可试用实现,核心能力包括: + +- 在路径编辑页新增“直线装配参考”区域 +- 支持捕获终点处已安装箱体 +- 支持选择 `轨上安装 / 轨下安装` +- 支持选择 `顶面对接 / 底面对接` +- 根据终点箱体位姿生成一根可点击的参考杆 +- 在三维中渲染终点对接点标记 +- 用户点击参考杆后,自动吸附到参考线并生成两点直线路径 +- 新生成路径仍使用 `PathType.Rail`,因此可继续复用现有 Rail 动画、通行空间和碰撞检测链路 + +说明: + +- 顶部原有“空轨路径”入口仍保留,但已改名为“传统空轨路径” +- 当前推荐优先使用“直线装配参考”流程 + +## 当前操作流程 + +### 1. 选择终点箱体 + +先在 Navisworks 三维视图中选中终点处已经安装好的箱体,然后在路径编辑页点击: + +- `捕获终点箱体` + +系统会记录: + +- 箱体名称 +- 包围盒中心与尺寸 +- 当前选择的安装方式 +- 当前选择的对接基准 +- 推导出的终点对接点 + +### 2. 设置装配构型 + +在“直线装配参考”区域中设置: + +- `安装方式` + - `轨下安装` + - `轨上安装` +- `对接基准` + - `顶面对接` + - `底面对接` + +这两个参数会直接写入最终生成的 `PathRoute`: + +- `RailMountMode` +- `RailPayloadAnchorMode` + +### 3. 生成参考杆 + +设置参考杆参数后点击: + +- `生成参考杆` + +当前实现会: + +- 从终点对接点沿箱体前向反推出参考杆 +- 在三维场景中生成一根可点击的细长参考杆 +- 同时渲染终点对接点标记,便于确认当前到底是顶面中心还是底面中心 + +参考杆资源优先使用: + +- `resources/unit_cylinder.nwc` + +如果该资源暂未放入,则会临时退回: + +- `resources/unit_cube.nwc` + +### 4. 点击参考杆生成路径 + +点击: + +- `取起点并生成路径` + +然后在三维中点击参考杆附近任意可拾取位置。系统会: + +- 获取点击坐标 +- 投影到参考杆轴线 +- 将投影点作为真实起点 +- 将终点对接点作为真实终点 +- 自动创建一条两点直线路径 + +生成后的路径名称形如: + +- `直线装配_1` + +## 当前界面入口 + +相关界面位于: + +- `路径编辑页 -> 直线装配参考` + +当前主要按钮: + +- `捕获终点箱体` +- `生成参考杆` +- `取起点并生成路径` +- `隐藏参考杆` + +传统模式入口: + +- `传统空轨路径` + +## 当前已知边界 + +### 1. 终点对接点依赖包围盒和当前朝向 + +当前终点对接点通过以下信息推导: + +- 世界包围盒 +- 当前变换的局部 Z 方向 +- 估算出的局部箱体高度 + +这对规则箱型构件是可行的,但如果对象几何明显偏离箱体、或包围盒受附属零件影响较大,终点锚点可能仍需进一步校核。 + +### 2. 参考杆方向当前取箱体前向 + +当前参考杆方向来自: + +- 箱体变换后的局部 X 轴 + +这适合“装配移动方向与构件主朝向一致”的场景。 +如果后续遇到“箱体朝向正确,但装配退让方向不是局部 X 轴”的场景,需要再补一个“参考方向选择”输入。 + +### 3. 仍然复用 Rail 路径类型 + +当前新路径仍记为: + +- `PathType.Rail` + +这是有意为之,目的是复用已有 Rail 动画和通行空间逻辑。 +如果未来这类路径越来越多,可以再考虑单独抽象成新的路径类型,例如 `LinearAssembly`。 + +## 建议测试用例 + +建议至少验证以下组合: + +1. `轨下安装 + 顶面对接` +2. `轨下安装 + 底面对接` +3. `轨上安装 + 顶面对接` +4. `轨上安装 + 底面对接` + +每组建议检查: + +- 终点对接点标记是否落在预期位置 +- 参考杆是否从终点沿正确方向反推 +- 点击参考杆后起点是否吸附到参考线上 +- 生成路径后,动画位置和通行空间偏移是否符合构型 + +## 后续建议 + +下一阶段建议优先做这几项: + +1. 增加“参考方向来源”选项 + 例如允许用户直接指定反推方向,而不只使用箱体局部 X 轴。 +2. 为直线装配路径增加更明确的导出字段 + 便于区分“传统空轨路径”和“终点反推的直线装配路径”。 +3. 补一份面向最终用户的简短操作说明 + 可以从本节内容提炼成更短的 UI 使用指南。 diff --git a/doc/design/2026/real-object-pose-source-migration-draft.md b/doc/design/2026/real-object-pose-source-migration-draft.md new file mode 100644 index 0000000..364a370 --- /dev/null +++ b/doc/design/2026/real-object-pose-source-migration-draft.md @@ -0,0 +1,423 @@ +# 真实物体位姿来源迁移草案 + +## 1. 背景 + +当前真实物体位姿链路中,`fragment` 承担了两种不同语义: + +- 读取真实物体的**参考姿态** +- 间接参与判断真实物体的**当前显示姿态** + +这两个语义不应混在一起。 + +当前工程已确认: + +- `ModelItem.Transform` 不能默认视为真实物体当前姿态或原始姿态 +- `fragment` 代表姿态适合做“参考姿态解释” +- `ModelGeometry.ActiveTransform` 更适合做“当前实际显示姿态”读取 + +因此,本次迁移目标不是“简单移除 fragment”,而是: + +1. 先把**参考姿态来源**与**当前显示变换来源**拆开 +2. 优先把“当前显示姿态读取”切到准确变换 API +3. 保留 fragment 参考姿态链作为稳定基线 +4. 在测试和 shadow 验证足够充分后,再评估参考姿态主来源是否切换 + +--- + +## 2. 当前问题 + +### 2.1 语义混用 + +当前真实物体链路同时关心: + +- 物体原始业务姿态是什么 +- 物体此刻在 Navisworks 里实际显示成什么姿态 +- 增量变换叠加后,应以哪一层为“当前状态” + +如果这三者没有统一入口,就容易出现: + +- 起点姿态正确,但播放过程漂移 +- `ResetPermanentTransform` 后内部状态与宿主状态不同步 +- 通行空间、碰撞恢复、动画终点使用了不同的姿态基线 + +### 2.2 fragment 并不是“当前姿态 API” + +fragment 的 `GetLocalToWorldMatrix()` 更接近几何层矩阵。 + +它适合: + +- 统计代表姿态 +- 解释真实物体的原始参考框架 +- 参与 COM 几何分析 + +它不应优先承担: + +- 当前 override 后显示姿态读取 +- 动画增量变换后的宿主状态读回 + +### 2.3 直接回退 `ModelItem.Transform` 风险很高 + +对于很多 Revit/复合件导入模型: + +- `ModelItem.Transform` 可能是单位旋转 +- 不反映当前 override 后姿态 +- 不足以表达真实物体的业务参考姿态 + +所以迁移不能走“fragment -> ModelItem.Transform”的单步替换。 + +--- + +## 3. 迁移目标 + +### 3.1 核心目标 + +建立两条明确分离的来源链: + +- **当前显示变换链** + - 用于读取当前实际显示姿态 + - 优先使用 `ModelGeometry.ActiveTransform` + +- **参考姿态链** + - 用于表达真实物体的原始业务姿态 + - 当前仍以 fragment 解释链为稳定主来源 + +### 3.2 非目标 + +本阶段不做以下事情: + +- 不重写 `Ground / Hoisting / Rail` 的姿态求解器 +- 不直接移除现有 fragment 参考姿态链 +- 不修改 Canonical / Host / Asset 三层坐标语义 +- 不在没有验证前,把 `ModelItem.Transform` 升级为真实姿态主来源 + +--- + +## 4. 目标架构 + +### 4.1 新的职责划分 + +建议新增两类 provider: + +#### A. `ICurrentDisplayTransformProvider` + +职责: + +- 读取当前几何实际显示姿态 +- 读取当前几何实际旋转 +- 读取当前几何原始姿态与 override 后姿态 + +建议首个实现: + +- `GeometryActiveTransformProvider` + +优先数据源: + +1. `ModelGeometry.ActiveTransform` +2. `ModelGeometry.PermanentOverrideTransform` +3. `ModelGeometry.OriginalTransform` +4. 明确失败,不偷偷 fallback 到不可靠语义 + +#### B. `IReferencePoseProvider` + +职责: + +- 提供真实物体参考姿态 +- 输出解释后的 `Rotation + AxisX/Y/Z` +- 对上层屏蔽具体来源是 fragment 还是 geometry + +建议实现顺序: + +1. `FragmentReferencePoseProvider` + - 复用当前 `RealObjectReferencePoseResolver` +2. `GeometryReferencePoseProvider` + - 后续用于 shadow 验证 + - 初期不接主链 + +### 4.2 上层调用关系 + +建议上层只依赖语义接口: + +- `PathAnimationManager` + - 读取参考姿态时,只依赖 `IReferencePoseProvider` + - 读取当前宿主实际姿态时,只依赖 `ICurrentDisplayTransformProvider` + +- `ModelItemTransformHelper` + - 统一封装 `ActiveTransform / OriginalTransform / delta transform` + - 成为“当前显示变换链”的基础工具层 + +- `RealObjectPlanarPoseSolver` +- `CanonicalRailPoseBuilder` + - 保持纯数学求解,不感知来源替换 + +--- + +## 5. 分阶段迁移方案 + +### 阶段 0:冻结语义与测试基线 + +目标: + +- 不改正式行为 +- 先把关键业务场景和期望值固定下来 + +产出: + +- 参考姿态语义测试 +- 当前显示姿态读取测试 +- 增量变换恢复测试 +- `YUp / ZUp` 双宿主测试矩阵 + +通过条件: + +- 现有 fragment 主链全部通过基线测试 + +### 阶段 1:抽象来源接口,但不改行为 + +目标: + +- 只做代码结构调整 +- 不改变当前正式结果 + +建议动作: + +- 新增 `ICurrentDisplayTransformProvider` +- 新增 `IReferencePoseProvider` +- 让 `PathAnimationManager` 从直接依赖 helper/fragment,改为依赖 provider +- 默认配置仍然使用 `FragmentReferencePoseProvider` + +通过条件: + +- 行为无变化 +- 回归测试全绿 + +### 阶段 2:迁移“当前显示姿态读取” + +目标: + +- 把“当前姿态读取”切到准确变换 API + +建议动作: + +- 将以下读当前姿态的场景统一迁到 `ModelGeometry.ActiveTransform` + - 当前旋转读回 + - 当前显示姿态调试日志 + - override 后的实际姿态验证 + - 动画恢复/碰撞恢复前的状态同步 + +注意: + +- 这一阶段不改“参考姿态”来源 +- 只改“当前实际显示变换”的读取 + +通过条件: + +- 起点/播放/暂停/终点/恢复行为与当前主链一致 +- `ResetPermanentTransform` 后内部跟踪状态不再依赖 `ModelItem.Transform` + +### 阶段 3:引入 `GeometryReferencePoseProvider` shadow 验证 + +目标: + +- 并行验证 geometry 是否能稳定替代 fragment 参考姿态 + +建议动作: + +- 新增几何级参考姿态读取实现 +- 不接正式主链 +- 只在日志/调试模式下输出与 fragment 基线的差异 + +建议记录差异: + +- quaternion 夹角差 +- `AxisX / AxisY / AxisZ` 与基线的夹角差 +- 多 geometry 是否一致 +- `YUp / ZUp` 下解释后是否仍满足宿主语义 + +判定规则建议: + +- 若 geometry 内部姿态不一致,则自动判定“不适合作为语义参考姿态主来源” +- 若与 fragment 基线夹角超阈值,则继续保留 fragment 主来源 + +### 阶段 4:按路径类型逐步接入 + +目标: + +- 在证据足够充分后,才让新的参考姿态来源参与正式链路 + +建议接入顺序: + +1. `Rail` +2. `Ground` +3. `Hoisting` + +原因: + +- `Rail` 当前姿态框架最明确,收益最大 +- `Ground / Hoisting` 对“对象前进轴语义”更敏感 + +通过条件: + +- `Rail 0°` 基线不变 +- Ground 逐帧姿态不退化 +- 通行空间与真实物体仍共用同一尺寸语义 + +--- + +## 6. 建议新增/调整的代码结构 + +### 6.1 建议新增文件 + +- `src/Utils/CoordinateSystem/ICurrentDisplayTransformProvider.cs` +- `src/Utils/CoordinateSystem/IReferencePoseProvider.cs` +- `src/Utils/CoordinateSystem/GeometryActiveTransformProvider.cs` +- `src/Utils/CoordinateSystem/FragmentReferencePoseProvider.cs` +- `src/Utils/CoordinateSystem/GeometryReferencePoseProvider.cs` + +### 6.2 建议优先调整的现有文件 + +- `src/Core/Animation/PathAnimationManager.cs` + - 只依赖 provider + - 去掉对 fragment/helper 的直接耦合 + +- `src/Utils/ModelItemTransformHelper.cs` + - 成为统一的当前显示变换读取入口 + +- `src/Utils/CoordinateSystem/RealObjectReferencePoseResolver.cs` + - 收敛为 fragment provider 的内部实现 + - 不再直接被业务层广泛调用 + +### 6.3 不建议本阶段改动的文件 + +- `CanonicalPlanarPoseBuilder` +- `CanonicalRailPoseBuilder` +- `CanonicalTrackedPositionResolver` +- `RailPathPoseHelper` + +原因: + +- 这些文件主要负责姿态求解与偏移语义 +- 不是本次来源迁移的根因层 + +--- + +## 7. 测试先行策略 + +### 7.1 测试原则 + +本次迁移必须遵循: + +1. 先补测试 +2. 再抽象接口 +3. 再切换读取来源 +4. 最后才考虑正式接入 + +### 7.2 第一批必须补的测试 + +#### A. 参考姿态基线测试 + +建议位置: + +- `UnitTests/CoordinateSystem/RealObjectReferencePoseResolverTests.cs` + +锁定内容: + +- fragment 多片段平均后的代表姿态 +- `Fragment默认Up` 为 `Y/Z` 时的解释结果 +- `YUp / ZUp` 宿主下输出轴语义一致 + +#### B. 当前显示姿态读取测试 + +建议新增: + +- `UnitTests/CoordinateSystem/CurrentDisplayTransformProviderTests.cs` + +锁定内容: + +- `ActiveTransform` 优先级 +- `OriginalTransform / PermanentOverrideTransform / ActiveTransform` 的语义区分 +- override 后读取结果与预期一致 + +#### C. 动画增量恢复测试 + +建议新增: + +- `UnitTests/CoordinateSystem/IncrementalTransformRestoreTests.cs` + +锁定内容: + +- reset 后状态同步 +- 当前跟踪姿态与宿主状态一致 +- 不再错误依赖 `ModelItem.Transform` + +#### D. 业务回归测试 + +建议补到现有测试中: + +- `Ground + 真实物体` +- `Rail + 真实物体` +- `YUp / ZUp` +- 起点 / 逐帧 / 终点 / restore + +### 7.3 第二批 shadow 验证测试 + +建议新增: + +- `UnitTests/CoordinateSystem/GeometryReferencePoseProviderTests.cs` + +锁定内容: + +- 单 geometry 对象能否稳定导出参考姿态 +- 多 geometry 姿态不一致时是否正确拒绝 +- geometry 基线与 fragment 基线差异是否在阈值内 + +--- + +## 8. 验收标准 + +### 8.1 阶段 1-2 验收 + +- 正式行为无肉眼可见退化 +- 所有已有姿态回归测试通过 +- `PathAnimationManager` 不再把“当前姿态读取”和“参考姿态来源”混用 + +### 8.2 阶段 3 验收 + +- 能输出 geometry vs fragment 的稳定差异报告 +- 能区分“可替代对象”和“不可替代对象” + +### 8.3 阶段 4 验收 + +- `Rail 0°` 基线不变 +- `Ground / Hoisting` 逐帧行为不退化 +- 通行空间、碰撞恢复、终点诊断不出现新的语义分叉 + +--- + +## 9. 当前推荐执行顺序 + +建议下一步按下面顺序推进: + +1. 先补测试,不接正式代码 +2. 抽 provider 接口,不改默认实现 +3. 先切“当前显示姿态读取” +4. 做 geometry 参考姿态 shadow 验证 +5. 证据足够后,再讨论 fragment 主来源是否退出 + +--- + +## 10. 当前结论 + +本次迁移的关键不是“删掉 fragment”,而是: + +- 先把语义拆开 +- 先把准确 API 用在该用的地方 +- 先用测试固定业务场景 +- 再用 shadow 验证去证明新的参考姿态来源是否真的可靠 + +在当前阶段,**最稳的路线**是: + +- fragment 继续作为真实物体参考姿态主来源 +- `ModelGeometry.ActiveTransform` 接管当前显示姿态读取 +- 测试先行 +- 分阶段接入 + diff --git a/doc/design/2026/直线装配路径_用户操作说明.md b/doc/design/2026/直线装配路径_用户操作说明.md new file mode 100644 index 0000000..85b9764 --- /dev/null +++ b/doc/design/2026/直线装配路径_用户操作说明.md @@ -0,0 +1,142 @@ +# 直线装配路径用户操作说明 + +## 适用场景 + +适用于以下场景: + +- 已知箱体最终安装后的准确位置和朝向 +- 只需要做起点到终点的直线运动仿真 +- 目标是检查运动过程中的碰撞 + +不推荐优先用于以下场景: + +- 需要沿复杂轨道中心线连续编辑路径 +- 需要依赖传统空轨模型吸附逐点绘制路径 + +## 推荐流程 + +### 1. 选择终点箱体 + +先在 Navisworks 三维视图中选中终点处已经安装好的箱体。 + +然后在路径编辑页的“直线装配参考”区域点击: + +- `捕获终点箱体` + +### 2. 设置装配构型 + +在“直线装配参考”区域设置: + +- `安装方式` + - `轨下安装` + - `轨上安装` +- `对接基准` + - `顶面对接` + - `底面对接` + +### 3. 生成参考杆 + +设置参考杆长度、直径后点击: + +- `生成参考杆` + +生成后,三维中会看到: + +- 一根可点击的参考杆 +- 一个终点对接点标记 + +说明: + +- 终点对接点标记用于确认当前终点是落在顶面中心还是底面中心 + +### 4. 取起点并生成路径 + +点击: + +- `取起点并生成路径` + +然后在三维里点击参考杆附近位置。系统会: + +- 自动吸附到参考杆轴线 +- 用吸附点作为起点 +- 用终点对接点作为终点 +- 自动生成一条两点直线路径 + +### 5. 检查路径与动画 + +路径生成后,建议检查: + +- 终点对接点是否正确 +- 起点是否吸附在参考杆上 +- 安装方式是否正确 +- 顶面/底面对接是否正确 +- 动画过程中构件位置是否合理 + +## 当前界面入口 + +主要入口: + +- `路径编辑页 -> 直线装配参考` + +当前主要按钮: + +- `捕获终点箱体` +- `生成参考杆` +- `取起点并生成路径` +- `隐藏参考杆` + +## 与传统空轨路径的区别 + +### 直线装配路径 + +特点: + +- 以终点箱体位姿为主输入 +- 自动生成参考杆 +- 只需要点一次起点 +- 适合直线装配仿真 + +### 传统空轨路径 + +入口: + +- `传统空轨路径` + +特点: + +- 基于空轨基准线吸附 +- 适合沿空轨逐点编辑路径 +- 不作为当前装配仿真场景的首选入口 + +## 建议试用检查项 + +建议至少试以下四组: + +1. `轨下安装 + 顶面对接` +2. `轨下安装 + 底面对接` +3. `轨上安装 + 顶面对接` +4. `轨上安装 + 底面对接` + +每组建议检查: + +- 终点标记位置是否正确 +- 参考杆方向是否正确 +- 起点吸附是否准确 +- 路径生成后动画是否符合预期 + +## 常见提示 + +如果无法生成参考杆,先检查: + +- 是否已经在三维里选中终点箱体 +- 当前选择对象是否是有效模型对象 + +如果无法取起点,先检查: + +- 是否已经生成参考杆 +- 是否进入了“取起点并生成路径”状态 + +如果终点位置不符合预期,优先检查: + +- 当前是否选对了 `轨上安装 / 轨下安装` +- 当前是否选对了 `顶面对接 / 底面对接` diff --git a/doc/guide/design_principles.md b/doc/guide/design_principles.md index f05e188..d3961d1 100644 --- a/doc/guide/design_principles.md +++ b/doc/guide/design_principles.md @@ -259,11 +259,303 @@ private static void TryRecoverComponents() ### 适用场景 +## 18. 坐标系分层原则 + +### 问题描述 + +项目已经同时面对以下三类不同语义的坐标: + +- Navisworks 世界坐标 +- 程序内部计算坐标 +- 工程业务基准坐标(如球心、安装基准、轨道参考面) + +如果这三层语义混在一起使用,就会出现典型问题: + +- 把世界原点误当成业务球心 +- 把世界 `Z` 误当成项目唯一的 up 方向 +- 一部分代码按源模型坐标算,一部分代码按转换后坐标算 +- Y-up / Z-up 项目切换后,路径、姿态、渲染结果彼此不一致 + +### 设计原则 + +#### 18.1 必须明确区分三层坐标语义 + +```mermaid +flowchart LR + A["Navisworks 世界坐标\n(模型原始坐标, 可能是 Y-up 或 Z-up 语义)"] --> B["内部统一坐标\n(程序所有计算都使用)"] + B --> C["业务基准坐标\n(球心、安装方向、轨道参考面)"] + + C --> D["终端安装仿真"] + C --> E["Rail 姿态/动画"] + C --> F["碰撞检测/结果恢复"] + + B --> G["路径规划/网格/几何运算"] + B --> H["渲染/可视化"] + + I["UI 输入/日志/坐标显示"] <-->|按需转换| B +``` + +三层语义的职责如下: + +- **Navisworks 世界坐标** + - 这是 API 直接返回的坐标、包围盒、变换矩阵 + - 反映模型当前在 Navisworks 场景中的真实位置 + - 不能自动等同于业务上的球心、安装参考面或统一 up 方向 + +- **内部统一坐标** + - 程序内部所有几何计算、路径规划、姿态计算应尽量统一使用这一层 + - 这一层负责消化源模型的 Y-up / Z-up 差异 + - 这一层只处理坐标轴和方向语义,不处理业务规则 + +- **业务基准坐标** + - 用来表达球心、安装基准点、轨道参考面等工程语义 + - 这一层不应偷用世界原点或世界某个固定轴 + - 业务基准点应显式配置或显式计算,不得隐式假设 + +#### 18.2 坐标系层只负责轴语义转换,不负责业务解释 + +坐标系抽象层应只回答以下问题: + +- 当前项目 up 方向是什么 +- 高程轴是哪一轴 +- 水平面由哪两轴组成 +- 点和向量如何在源模型坐标与内部统一坐标之间转换 + +坐标系层不应负责以下业务问题: + +- 球心是否在 `(0,0,0)` +- 终端安装参考线是否指向球心 +- 顶面/底面对接如何定义 +- 轨道参考面偏移量是多少 + +这些都属于业务基准层。 + +#### 18.3 业务逻辑不得直接写死世界原点和世界 Z + +以下写法都应视为高风险设计: + +```csharp +// ❌ 错误:把世界原点直接当成业务球心 +Vector3D direction = new Vector3D(centerPoint.X, centerPoint.Y, centerPoint.Z); + +// ❌ 错误:把世界Z直接当成项目up +var worldUp = new Vector3D(0, 0, 1); + +// ❌ 错误:把Min.Z / Max.Z直接当成统一的高程语义 +double top = bounds.Max.Z; +double bottom = bounds.Min.Z; +``` + +更合理的写法应当是: + +```csharp +// ✅ 正确:球心来自业务基准配置或业务计算 +Vector3D direction = centerPoint - sphereCenter; + +// ✅ 正确:up方向来自坐标系抽象或对象自身姿态 +Vector3D worldUp = CoordinateSystemManager.Instance.Current.UpVector; + +// ✅ 正确:高程语义来自坐标系抽象 +double elevation = coordinateSystem.GetElevation(point); +``` + +#### 18.4 程序内部应优先统一计算坐标,再按需转换回用户语义 + +当客户项目坚持使用 Y-up 坐标时,不应要求客户先把模型硬转成 Z-up 再继续使用。 + +更合理的做法是: + +- 保留客户熟悉的输入输出坐标语义 +- 程序内部统一转换到内部计算坐标 +- 计算完成后,再把需要展示给用户的数据映射回客户语义 + +也就是说: + +- **客户层**可以是 Y-up +- **内部计算层**可以统一成程序更容易处理的一套坐标 +- **显示/日志层**再根据需要转回客户语义 + +#### 18.5 不要在全项目到处散落 Y-up / Z-up 条件分支 + +不推荐这种扩散式兼容写法: + +```csharp +// ❌ 错误:在业务逻辑中到处散落坐标系分支 +if (isYUp) +{ + // ... +} +else +{ + // ... +} +``` + +更推荐的方式是: + +- 在坐标系层统一封装 `UpVector`、`GetElevation()`、`GetHorizontalCoords()` 等能力 +- 在业务层统一消费抽象结果 +- 让路径、姿态、渲染尽量只面对“内部统一坐标” + +#### 18.6 业务基准点必须单独配置或显式求解 + +像“球心”这类点,不属于坐标系本身的一部分,必须单独处理。 + +例如: + +- 球心可以来自项目配置 +- 或来自多条已知向心轴线的拟合结果 +- 或来自设计资料中的明确基准点 + +但不能因为过去某批模型里球心正好在世界原点,就长期把它写死。 + +### 适用场景 + +- 终端安装仿真 +- Rail 三维姿态与动画 +- 路径规划中的高程与水平平面计算 +- 多坐标系项目的输入、显示与日志输出 + - 所有对外接口方法 - 事件处理器 - 后台任务执行 - Navisworks API调用 +## 19. 框架先行与测试先行原则 + +### 问题描述 + +当功能涉及到底层几何语义、坐标系语义、局部轴约定或姿态矩阵时,如果直接在业务链路里一边试一边改,通常会出现这些问题: + +- 虚拟物体和真实模型的语义被混用 +- 一处补丁修好,另一条链路被带坏 +- 日志越来越多,但问题边界越来越模糊 +- 业务代码里充满临时补偿,最终难以维护 + +这类问题的根因往往不是业务流程本身,而是底层框架语义没有先被验证。 + +### 设计原则 + +#### 19.1 先抽离框架,再接业务 + +对于以下类型的问题,不应先改业务代码: + +- 坐标系转换 +- 局部轴约定 +- 姿态构造 +- 纯几何补偿 +- 宿主坐标与内部统一坐标之间的映射 + +正确顺序应当是: + +1. 先抽出纯框架层 +2. 用最小测试验证框架层 +3. 通过后再接入业务模块 + +错误示例: + +```csharp +// ❌ 错误:尚未验证坐标/姿态语义,直接改动画和渲染链路 +if (isYUp) +{ + rotation = routeRotation * someCorrection; +} +else +{ + rotation = routeRotation; +} +``` + +正确示例: + +```csharp +// ✅ 正确:先让纯框架层定义并验证语义 +Quaternion rotation = canonicalPoseBuilder.CreateQuaternion( + canonicalForward, + canonicalUp, + modelAxisConvention); + +// 业务层只消费已验证的结果 +frame.Rotation = ToNavisworksRotation(rotation); +``` + +#### 19.2 可测试的核心框架不得直接依赖宿主 API + +凡是需要单元测试验证的核心几何框架,不应直接绑定 Navisworks `Point3D`、`Vector3D`、`BoundingBox3D` 这类宿主类型。 + +原因是: + +- 宿主 API 在脱离 Navisworks 进程时通常无法初始化 +- 会导致测试只能在宿主环境里间接验证 +- 这样测试粒度太粗,定位问题非常慢 + +更合理的方式是: + +- 框架核心使用纯数学类型 + - 例如 `System.Numerics.Vector3` + - 例如 `System.Numerics.Matrix4x4` + - 例如 `System.Numerics.Quaternion` +- 只有边界适配层才接触 Navisworks API + +也就是说: + +- **纯数学层**:可单元测试 +- **宿主适配层**:负责包装和转换 +- **业务层**:消费已验证结果 + +#### 19.3 先验证“语义”,再验证“效果” + +这类框架测试的重点不是先看动画画面,而是先验证最小语义: + +- Y-up 到 Canonical Z-up 的点/向量转换是否正确 +- 本地 `X-forward / Y-up` 与 `X-forward / Z-up` 的轴约定是否正确 +- 给定世界前进方向和上方向,生成的姿态矩阵列向量是否正确 +- 包围盒和参考点经过往返转换是否保持一致 + +只有这些最小语义先对,业务画面才值得继续看。 + +#### 19.4 业务接入应尽量只做“选择约定”,不做“临时补偿” + +当框架层已经定义了: + +- 宿主坐标系 +- 内部统一坐标 +- 模型局部轴约定 + +那么业务层最理想的职责应该只是: + +- 选择当前对象使用哪一种约定 +- 调用框架生成姿态或坐标 + +而不是在业务层继续做: + +- `+90°` +- `-90°` +- “如果 Y-up 就补一下” +- “如果真实模型就再扭一下” + +这些都属于典型的补丁式开发,会掩盖框架问题。 + +#### 19.5 一旦测试表明框架不纯,必须先回到框架层 + +如果在测试阶段发现: + +- 纯框架层还依赖宿主 API +- 测试无法脱离 Navisworks 运行 +- 同一个姿态语义在框架和业务里各算一遍 + +就不应该继续改业务代码,而应立即回到框架层收口。 + +这比继续在业务链路里加日志、加补偿更重要。 + +### 适用场景 + +- 坐标系改造 +- Y-up / Z-up 兼容 +- Rail 三维姿态 +- 真实模型与虚拟物体局部轴约定统一 +- 碰撞恢复姿态语义 + ## 3. 内存管理与性能优化 ### 问题描述 @@ -2145,4 +2437,114 @@ DialogHelper.ShowDialog(new MyDialog()); --- +## 18. 基础框架改造的开发顺序 + +### 原则 + +当功能涉及以下任一类基础语义时,禁止直接在业务代码里试错式修补: + +- 坐标系 +- 三维姿态 +- 几何局部轴 +- 单位系统 +- 动画/碰撞恢复的定位基准 + +正确顺序必须是: + +1. 先抽出框架层/数学层 +2. 先写最小可验证测试 +3. 测试通过后再接回业务模块 + +### 原因 + +这类问题一旦直接在业务链路里反复修补,最容易出现: + +- 虚拟物体正确、真实物体错误 +- 起点正确、动画第一帧错误 +- 动画正确、碰撞恢复错误 +- 中心点正确、渲染几何局部轴错误 + +根因通常不是“又差一个补偿”,而是底层语义没有先被验证。 + +### 本项目实证经验 + +本项目在 Rail 三维姿态与坐标系改造中,采用以下顺序后明显更稳: + +1. 先建立 `HostCoordinateAdapter / ModelAxisConvention / CanonicalRailPoseBuilder` +2. 再写单元测试验证 `Y-up / Z-up`、局部 `forward/up`、四元数/线性姿态 +3. 最后再接入: + - 终端安装仿真 + - Rail 姿态 + - 动画播放 + - 碰撞恢复 + +结论: + +- 对基础语义改造,**测试先行接业务** 是推荐流程 +- 没有测试支撑时,不应在业务代码里靠补偿和 fallback 猜结果 + +### 额外经验 1:物体物理尺寸必须固定 + +对于真实物体动画,物体的物理尺寸一旦确定,就必须固定存储并重复使用: + +- 起点贴合使用的尺寸 +- 动画帧预计算使用的尺寸 +- 通行空间尺寸语义 +- 碰撞恢复使用的尺寸 + +这些必须来自**同一份固定物理尺寸**,不能在动画过程中反复从“当前已旋转的 AABB”重新推导。 + +否则会出现典型问题: + +- 起点贴合正确 +- 动画第一帧立刻拉开固定间隙 + +### 额外经验 2:部署必须校验 WPF 资源完整性 + +对 WPF 插件来说,仅校验 DLL 被复制成功还不够。 + +必须确认构建产物包含完整的 `.g.resources / .baml` 资源;否则会出现: + +- 插件 DLL 存在 +- Navisworks 能加载程序集 +- 但一打开面板就因缺少 `*.baml` 崩溃 + +本项目已实际遇到: + +- `TransportPlugin.dll` 成功部署 +- 但缺少 `LogisticsControlPanel.baml / PathEditingView.baml / AnimationControlView.baml` +- 最终表现为“插件布局丢失”或“手工打开插件窗口即崩溃” + +因此经验是: + +- 单元测试顺带产出的 DLL 不能默认用于最终部署 +- 最终部署前应至少确认主项目完整构建成功 +- 必要时校验关键 WPF 视图资源是否真的编入程序集 + +### 额外经验 3:三维碰撞验证前禁止先重置宿主姿态 + +对受 `PathAnimationManager` 控制的三维动画对象,`ClashDetective` 候选验证前不能先调用: + +- `doc.Models.ResetPermanentTransform(modelItems)` + +原因: + +- `PathAnimationManager` 的内部跟踪姿态记录的是“当前动画目标姿态” +- 一旦先把宿主物体重置回 CAD 原始姿态,宿主真实姿态就会变成单位姿态 +- 如果随后仍复用 `PathAnimationManager.MoveAnimatedObjectToPose(...)` +- 则它会把“缓存姿态”误当成当前姿态,导致增量旋转退化成单位旋转,只剩平移 + +实际后果: + +- 动画结束后,碰撞检测汇总一启动,真实物体会被摆平或姿态跳变 +- 后续所有 `ClashDetective` 验证都在错误姿态上进行 + +正确做法: + +- 对三维 `PAM` 主链路对象,直接复用 `MoveAnimatedObjectToPose(...)` +- 不再在验证入口额外执行 `ResetPermanentTransform` +- `ResetPermanentTransform` 只保留给旧二维/非 `PAM` 恢复分支 + +--- + *本文档将随着项目发展持续更新,确保设计指导的有效性和实用性。* diff --git a/doc/requirement/todo_features.md b/doc/requirement/todo_features.md index 17d59b3..7e8a1ee 100644 --- a/doc/requirement/todo_features.md +++ b/doc/requirement/todo_features.md @@ -2,9 +2,24 @@ ## 功能点 +### [2026/4/12] + +1. [x] (功能)实现自动集成测试框架 +2. [x] (优化)自动路径规划的坐标系适配 + +### [2026/4/2] + +1. [x] (功能)增加按选择项显示剖面盒的功能 +2. [x] (优化)让地面和吊装路径摆脱fragment依赖 + +### [2026/3/20] + +1. [x] (优化)实现Yup模型兼容 +2. [x] (功能)运动物体在起点支持三个方向的旋转 + ### [2026/3/18] -1. [x](功能)增加轨上路径(角度可倾斜) +1. [x] (功能)增加终端仿真需要的轨上和轨下路径(角度可倾斜) ### [2026/3/11] diff --git a/doc/working/2026-04-06-navisworks-transform-api-official-reference.md b/doc/working/2026-04-06-navisworks-transform-api-official-reference.md new file mode 100644 index 0000000..7a8899b --- /dev/null +++ b/doc/working/2026-04-06-navisworks-transform-api-official-reference.md @@ -0,0 +1,1146 @@ +# Navisworks 变换 API 官方原始定义整理 + +更新时间:2026-04-06 + +本文只整理当前讨论中直接用到的 Navisworks .NET API 官方原始定义与语法。 + +原则: + +- 正文尽量保留官方原始内容 +- 不混入项目内部“局部坐标系”“参考姿态”等二次解释 +- 如果官方示例目录中未找到对应 API 的直接示例,就如实记录“未找到直接示例” +- 只在最后增加一段简短归纳 + +--- + +## 1. `DocumentModels.OverridePermanentTransform(...)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_OverridePermanentTransform_3_131351c5.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_OverridePermanentTransform_3_131351c5.htm) + +官方标题: + +```text +DocumentModels.OverridePermanentTransform Method +``` + +官方定义: + +```text +Apply an incremental transform to a selection. +``` + +官方 C# 语法: + +```csharp +public void OverridePermanentTransform( + IEnumerable items, + Transform3D transform, + bool updateModelTransform +) +``` + +官方 Remarks: + +```text +If the selection contains any files, and the updateModelTransform +parameter is true, then instead of applying a transform to the +fragments, the File Units and Transform will be updated. +``` + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该方法的直接示例代码。 +``` + +--- + +## 2. `DocumentModels.ResetPermanentTransform(...)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_ResetPermanentTransform_1_75193b86.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_DocumentParts_DocumentModels_ResetPermanentTransform_1_75193b86.htm) + +官方标题: + +```text +DocumentModels.ResetPermanentTransform Method +``` + +官方定义: + +```text +Reset incremental transforms for all model items contained in the selection. +``` + +官方 C# 语法: + +```csharp +public void ResetPermanentTransform( + IEnumerable items +) +``` + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该方法的直接示例代码。 +``` + +--- + +## 3. `ModelGeometry.ActiveTransform` + +官方文档: + +- [P_Autodesk_Navisworks_Api_ModelGeometry_ActiveTransform.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_ModelGeometry_ActiveTransform.htm) + +官方标题: + +```text +ModelGeometry.ActiveTransform Property +``` + +官方定义: + +```text +Returns the currently active transform of the geometry. +``` + +官方 C# 语法: + +```csharp +public Transform3D ActiveTransform { get; } +``` + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该属性的直接示例代码。 +``` + +--- + +## 4. `Transform3D` + +官方文档: + +- [T_Autodesk_Navisworks_Api_Transform3D.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/T_Autodesk_Navisworks_Api_Transform3D.htm) + +官方标题: + +```text +Transform3D Class +``` + +官方定义: + +```text +A generic transformation in 3D space. +``` + +官方 Remarks: + +```text +Considered an immutable value type. +``` + +官方 C# 类型语法: + +```csharp +public class Transform3D : NativeHandle +``` + +官方成员页中可见的相关构造/工厂: + +- `Transform3D(Rotation3D)` +- `Transform3D(Matrix3, Vector3D)` +- `Transform3D(Rotation3D, Vector3D)` +- `CreateIdentity()` +- `CreateTranslation(Vector3D)` + +来源: + +- [AllMembers_T_Autodesk_Navisworks_Api_Transform3D.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/AllMembers_T_Autodesk_Navisworks_Api_Transform3D.htm) + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该类型的直接示例代码。 +``` + +--- + +## 5. `Transform3D(Rotation3D)` + +官方文档: + +- [C_Autodesk_Navisworks_Api_Transform3D_ctor_1_73bf3bc1.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/C_Autodesk_Navisworks_Api_Transform3D_ctor_1_73bf3bc1.htm) + +官方定义: + +```text +Make a transform from a rotation (3D homogenous). +``` + +官方 C# 语法: + +```csharp +public Transform3D( + Rotation3D rotation +) +``` + +--- + +## 6. `Transform3D(Rotation3D, Vector3D)` + +官方文档: + +- [C_Autodesk_Navisworks_Api_Transform3D_ctor_2_499970f8.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/C_Autodesk_Navisworks_Api_Transform3D_ctor_2_499970f8.htm) + +官方定义: + +```text +Make a transform from a rotation and a translation. +``` + +官方 C# 语法: + +```csharp +public Transform3D( + Rotation3D rotation, + Vector3D translation +) +``` + +--- + +## 7. `Transform3D.CreateIdentity()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_CreateIdentity.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_CreateIdentity.htm) + +官方定义: + +```text +Create an identity transform. +``` + +官方 C# 语法: + +```csharp +public static Transform3D CreateIdentity() +``` + +--- + +## 8. `Transform3D.CreateTranslation(Vector3D)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_CreateTranslation_1_aa2b59dc.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_CreateTranslation_1_aa2b59dc.htm) + +官方定义: + +```text +Set matrix to be 3D homogenous translation matrix. +``` + +官方 C# 语法: + +```csharp +public static Transform3D CreateTranslation( + Vector3D translation +) +``` + +--- + +## 9. `Rotation3D(UnitVector3D, Double)` + +官方文档: + +- [C_Autodesk_Navisworks_Api_Rotation3D_ctor_2_a6cb51d1.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/C_Autodesk_Navisworks_Api_Rotation3D_ctor_2_a6cb51d1.htm) + +官方定义: + +```text +Create a rotation as a rotation about an axis by an angle. +``` + +官方 C# 语法: + +```csharp +public Rotation3D( + UnitVector3D axis, + double angle +) +``` + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该构造函数的直接示例代码。 +``` + +--- + +## 10. `Transform3D.Inverse()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Inverse.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Inverse.htm) + +官方定义: + +```text +Return inverse transformation. +``` + +官方 C# 语法: + +```csharp +public Transform3D Inverse() +``` + +--- + +## 11. `Transform3D.Multiply(Transform3D, Transform3D)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Multiply_2_141222c1.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Multiply_2_141222c1.htm) + +官方定义: + +```text +Multiply two transformations in the order supplied. +``` + +官方 C# 语法: + +```csharp +public static Transform3D Multiply( + Transform3D left, + Transform3D right +) +``` + +--- + +## 12. 当前已用实验钉死的最小用法 + +本节不是官方原文,而是基于上面这些官方 API 和本仓库 `ReadTransformTestCommand` 的实验结果整理出的最小结论。 + +### 12.1 宿主世界轴直接转 90° 的 API 参数 + +`90°` 在 API 中应写成弧度: + +```csharp +Math.PI / 2.0 +``` + +宿主世界轴应直接使用世界单位轴: + +```csharp +var hostX = new UnitVector3D(1, 0, 0); +var hostY = new UnitVector3D(0, 1, 0); +var hostZ = new UnitVector3D(0, 0, 1); +``` + +对应的旋转对象写法: + +```csharp +var rx90 = new Rotation3D(hostX, Math.PI / 2.0); +var ry90 = new Rotation3D(hostY, Math.PI / 2.0); +var rz90 = new Rotation3D(hostZ, Math.PI / 2.0); +``` + +如果要直接喂给 `OverridePermanentTransform(...)`: + +```csharp +var transform = new Transform3D(ry90); +doc.Models.OverridePermanentTransform(items, transform, false); +``` + +### 12.2 从当前姿态到目标姿态的纯旋转增量 + +当前实验已经验证: + +```csharp +deltaRotation = currentInverse * target +``` + +在 API 上应写成: + +```csharp +var currentTransform = new Transform3D(currentRotation); +var targetTransform = new Transform3D(targetRotation); +var deltaTransform = Transform3D.Multiply( + currentTransform.Inverse(), + targetTransform); +``` + +已验证: + +- `currentInverse * target` 对 +- `target * currentInverse` 不对 + +### 12.3 纯旋转增量的默认旋转中心 + +当前实验已验证: + +- `OverridePermanentTransform(..., rotationOnly, false)` 的纯旋转增量默认绕宿主原点 `(0,0,0)` 生效 + +因此: + +- 不能把纯旋转理解成“围绕当前业务跟踪点原地自转” +- 对 `Ground + 真实物体`,旋转后必须再单独做位置重对齐 + +--- + +## 9. `Transform3D.Factor(Vector3D worldCenter)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Factor_1_aa2b59dc.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Factor_1_aa2b59dc.htm) + +官方标题: + +```text +Transform3D.Factor Method (Vector3D) +``` + +官方定义: + +```text +Treat as homogenous 3D matrix and factor into scale orientation, scale, rotation and translation components. +``` + +官方 C# 语法: + +```csharp +public Transform3DComponents Factor( + Vector3D worldCenter +) +``` + +目前从官方原文可确认: + +- 该重载显式接收 `worldCenter` +- 说明 `Transform3D` 的分解结果与指定的世界中心有关 + +当前实验用途: + +- 用它比较“同一个旋转增量”在 + - `worldCenter = (0,0,0)` + - `worldCenter = 基线BoundingBox.Center` +- 两种情况下分解出来的 `Translation` +- 以验证 Navisworks 增量旋转是否表现得像“绕宿主原点旋转” + +--- + +## 9. `Transform3DComponents` + +官方文档: + +- [T_Autodesk_Navisworks_Api_Transform3DComponents.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/T_Autodesk_Navisworks_Api_Transform3DComponents.htm) + +官方标题: + +```text +Transform3DComponents Class +``` + +官方定义: + +```text +Affine transform represented as individual components that combine to form complete transform. Immutable. +``` + +官方 Remarks: + +```text +Affine transform represented as individual components that combine to form complete transform. Immutable. M = c' * so' * s * so * r * c * t * p, where so is scale orientation, s is scale, c is center, r is rotation and t is translation. +``` + +官方 C# 类型语法: + +```csharp +public class Transform3DComponents : NativeHandle +``` + +官方示例情况: + +```text +在官方 NET examples 目录中,未找到该类型的直接示例代码。 +``` + +--- + +## 10. `Transform3DComponents.Translation` + +官方文档: + +- [P_Autodesk_Navisworks_Api_Transform3DComponents_Translation.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_Transform3DComponents_Translation.htm) + +官方定义: + +```text +The translation component of the transform. +``` + +官方 C# 语法: + +```csharp +public Vector3D Translation { get; set; } +``` + +--- + +## 11. `Transform3DComponents.Rotation` + +官方文档: + +- [P_Autodesk_Navisworks_Api_Transform3DComponents_Rotation.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_Transform3DComponents_Rotation.htm) + +官方定义: + +```text +The rotation component of the transform. +``` + +官方 C# 语法: + +```csharp +public Rotation3D Rotation { get; set; } +``` + +--- + +## 12. `Transform3DComponents.Scale` + +官方文档: + +- [P_Autodesk_Navisworks_Api_Transform3DComponents_Scale.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/P_Autodesk_Navisworks_Api_Transform3DComponents_Scale.htm) + +官方定义: + +```text +The scale component of the transform. +``` + +官方 C# 语法: + +```csharp +public Vector3D Scale { get; set; } +``` + +--- + +## 13. `Transform3DComponents.Combine()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3DComponents_Combine.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3DComponents_Combine.htm) + +官方定义: + +```text +Combine components together into a composite transform. +``` + +官方 C# 语法: + +```csharp +public Transform3D Combine() +``` + +--- + +## 14. `Transform3D.Multiply(Transform3D, Transform3D)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Multiply_2_141222c1.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Multiply_2_141222c1.htm) + +官方定义: + +```text +Multiply two transforms in the order that the arguments are given, and return the result. +``` + +官方 C# 语法: + +```csharp +public static Transform3D Multiply( + Transform3D leftTransform, + Transform3D rightTransform +) +``` + +--- + +## 15. `Transform3D.Factor()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Factor.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Factor.htm) + +官方定义: + +```text +Treat as homogenous 3D matrix and factor into scale orientation, scale, rotation and translation components. +``` + +官方 C# 语法: + +```csharp +public Transform3DComponents Factor() +``` + +--- + +## 16. `Transform3D.Inverse()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_Inverse.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_Inverse.htm) + +官方定义: + +```text +Return inverse of matrix. Matrix must be non-singular (non-zero determinant). +``` + +官方 C# 语法: + +```csharp +public Transform3D Inverse() +``` + +官方异常说明: + +```text +ObjectDisposedException +Object has been Disposed +``` + +--- + +## 17. `Transform3D.TranslateRight(Vector3D, Transform3D)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Transform3D_TranslateRight_2_9ab750ca.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Transform3D_TranslateRight_2_9ab750ca.htm) + +官方定义: + +```text +Translate a transform by a vector. +``` + +官方 C# 语法: + +```csharp +public static Transform3D TranslateRight( + Vector3D translation, + Transform3D transform +) +``` + +--- + +## 18. `Rotation3D` + +官方文档: + +- [T_Autodesk_Navisworks_Api_Rotation3D.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/T_Autodesk_Navisworks_Api_Rotation3D.htm) + +官方 C# 类型语法: + +```csharp +public class Rotation3D : NativeHandle +``` + +--- + +## 19. `Rotation3D(UnitVector3D, Double)` + +官方文档: + +- [C_Autodesk_Navisworks_Api_Rotation3D_ctor_2_afd1d818.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/C_Autodesk_Navisworks_Api_Rotation3D_ctor_2_afd1d818.htm) + +官方定义: + +```text +Creates rotation about given axis by angle in radians +``` + +官方 C# 语法: + +```csharp +public Rotation3D( + UnitVector3D axis, + double angle +) +``` + +--- + +## 20. `Rotation3D.CreateFromEulerAngles(Double, Double, Double)` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Rotation3D_CreateFromEulerAngles_3_d36b82d7.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Rotation3D_CreateFromEulerAngles_3_d36b82d7.htm) + +官方定义: + +```text +Creates a Euler angle rotation. +Parameters are in radians. +Rotation is created by combination of rotations about X, Y and Z axes. +``` + +官方 C# 语法: + +```csharp +public static Rotation3D CreateFromEulerAngles( + double x, + double y, + double z +) +``` + +--- + +## 21. `Rotation3D.ToAxisAndAngle()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Rotation3D_ToAxisAndAngle.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Rotation3D_ToAxisAndAngle.htm) + +官方定义: + +```text +Calculates an axis and angle representation of this rotation. +``` + +官方 C# 语法: + +```csharp +public AxisAndAngleResult ToAxisAndAngle() +``` + +--- + +## 22. `Rotation3D.ToEulerAngles()` + +官方文档: + +- [M_Autodesk_Navisworks_Api_Rotation3D_ToEulerAngles.htm](/C:/Users/Tellme/apps/NavisworksTransport/doc/navisworks_api/NET/documentation/NetAPIHtml/html/M_Autodesk_Navisworks_Api_Rotation3D_ToEulerAngles.htm) + +官方定义: + +```text +Calculates the Euler angles for this rotation. +``` + +官方 C# 语法: + +```csharp +public EulerAngleResult ToEulerAngles() +``` + +--- + +## 23. 由官方 API 原始定义直接能表达的增量求法 + +这一节不引入项目术语,只把前面几条官方定义并列摆出: + +```text +currentTransform = 当前生效的几何变换(ModelGeometry.ActiveTransform) +targetTransform = 目标完整 Transform3D +currentInverse = currentTransform.Inverse() +incrementalTransform = Transform3D.Multiply(targetTransform, currentInverse) +OverridePermanentTransform(..., incrementalTransform, ...) +``` + +这一段的依据完全来自前文官方定义: + +- `ActiveTransform` = Returns the currently active transform of the geometry. +- `Inverse()` = Return inverse of matrix. +- `Multiply(left, right)` = Multiply two transforms in the order that the arguments are given. +- `OverridePermanentTransform(...)` = Apply an incremental transform to a selection. + +这里只说明“这些 API 可以这样直接串起来表达完整当前变换 -> 目标变换 -> 增量变换”,不额外添加项目侧解释。 + +--- + +## 24. 官方示例检索结果 + +检索范围: + +```text +C:\Users\Tellme\apps\NavisworksTransport\doc\navisworks_api\NET\examples\ +``` + +检索关键词: + +```text +OverridePermanentTransform +ResetPermanentTransform +ActiveTransform +Transform3DComponents +CreateTranslation +new Transform3D( +``` + +检索结论: + +```text +当前官方 NET examples 目录中,未找到这些 API 的直接示例代码。 +``` + +因此本文中的“示例”部分不补写项目侧示例,只保留官方文档中的原始定义与语法。 + +--- + +## 25. 2026-04-06 Transform API 实验结论 + +以下结论来自仓库内实验按钮: + +- [ReadTransformTestCommand.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Commands/ReadTransformTestCommand.cs) + +实验对象: + +```text +Chair Lounge Couch Double +``` + +实验日志位置: + +```text +C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\debug.log +``` + +### 25.1 纯旋转增量的真实行为 + +实验结果表明: + +- 对真实物体调用 + - `OverridePermanentTransform(..., rotationOnly, false)` +- 其视觉效果等价于: + - **绕宿主原点 `(0,0,0)` 旋转** + +已验证的旋转: + +- `X + 90°` +- `Y + 90°` +- `Z + 90°` + +实验日志中,这三项都满足: + +```text +按原点旋转预期Center == 实际Center +误差 = (0, 0, 0) +``` + +因此当前可以视为已证实: + +- `.NET API` 下的纯旋转增量默认不是“围绕对象当前中心自转” +- 而是“围绕宿主原点旋转” + +### 25.2 `Factor(worldCenter)` 的语义价值 + +实验结果表明: + +- 对同一个纯旋转增量: + - `Factor(Vector3D.Zero)` 得到的 `Translation = (0,0,0)` + - `Factor(基线Center)` 会得到非零 `Translation` + +这说明: + +- `Transform3D` 的分解结果确实依赖 `worldCenter` +- `Factor(worldCenter)` 可以用于分析“同一变换相对于不同世界中心的平移语义” + +但它只是: + +- **分解分析 API** + +不是: + +- “设置旋转中心”的构造 API + +### 25.3 “绕指定中心旋转”的三种 API 组合实验 + +我们验证了三种做法,目标都是尝试复现 UI 中“指定变换中心后旋转”的效果。 + +#### 做法 A:直接把 `T(center) * R * T(-center)` 当作增量 + +结果: + +- 不成立 +- 对基线中心的误差为: + +```text +(427.280, -492.199, 0.000) +``` + +#### 做法 B:完整目标左乘 + +做法: + +```text +target = centerRotation * current +incremental = target * current.Inverse() +``` + +结果: + +- 与做法 A 等价 +- 同样不成立 + +#### 做法 C:完整目标右乘 + +做法: + +```text +target = current * centerRotation +incremental = target * current.Inverse() +``` + +结果: + +- 也不成立 +- 误差更大: + +```text +(693.643, 0.000, -689.804) +``` + +### 25.4 当前可以正式采用的结论 + +基于本轮实验,当前可以采用以下工程结论: + +1. `OverridePermanentTransform(..., false)` 的纯旋转增量,默认绕宿主原点旋转。 +2. 当前 `.NET API` 暴露的这套: + - `Transform3D.CreateTranslation` + - `Rotation3D` + - `Transform3D.Multiply` + - `OverridePermanentTransform` + 不能直接复现 UI 的“指定变换中心旋转”语义。 +3. 因此对 `Ground` 这类业务链,不能把问题继续建模成“通过 API 复刻 UI 中心旋转”。 +4. 对 `Ground` 更可靠的策略应是: + - 接受旋转默认绕宿主原点 + - 再基于业务跟踪点语义做位置重对齐 + +### 25.5 当前未证实的事项 + +以下事项目前仍未在原始 API 文档中找到明确说明: + +1. UI 中“变换中心”是否有对应的 .NET API 可直接读写。 +2. UI 的“变换中心旋转”是否走的是另一套未公开的内部机制。 +3. 是否存在未被当前文档索引捕获的 COM API / UI API 可直接设置该中心。 + +--- + +## 26. 归纳 + +基于以上官方原始定义,可以先得到一个非常克制的结论: + +1. `OverridePermanentTransform(...)` 的官方语义就是“应用增量变换”。 +2. `ResetPermanentTransform(...)` 的官方语义就是“重置这层增量变换”。 +3. `ModelGeometry.ActiveTransform` 表示“当前生效的几何变换”。 +4. `Transform3D` / `Transform3DComponents` 官方暴露的核心概念是: + - 平移 + - 旋转 + - 缩放 +5. `Transform3D.Multiply(...)` 官方明确说明“按参数给定顺序相乘”。 +6. `Transform3D.Inverse()` 官方明确是“返回矩阵逆”。 +7. `Rotation3D(UnitVector3D, Double)` 官方明确是“绕给定轴、按弧度创建旋转”。 +8. `Rotation3D.CreateFromEulerAngles(...)` 官方明确是“按 X/Y/Z 轴组合欧拉旋转,参数单位为弧度”。 +9. 这些官方定义里并没有把“局部业务坐标系解释”当成变换 API 的主语。 + +因此,从官方原始定义可以直接得到一个非常具体的增量表达方式: + +- 当前变换:`ModelGeometry.ActiveTransform` +- 目标变换:调用方构造的 `targetTransform` +- 增量变换:`Transform3D.Multiply(targetTransform, currentTransform.Inverse())` +- 应用:`OverridePermanentTransform(...)` + +因此,后续如果要重构真实物体变换工具,更合理的方向是: + +- 先把工具方法收成“宿主坐标系下的平移 / 旋转 / 缩放” +- 再在需要时把它们组合成完整 `Transform3D` +- 再用 `currentTransform.Inverse()` 和 `Transform3D.Multiply(...)` 求增量 +- 最后把这个增量变换交给 `OverridePermanentTransform(...)` + +而不是在变换工具层继续传播 `reference axis / local axis / hostUpLocalAxis` 之类的概念。 + +--- + +## 27. 2026-04-06 当天追加结论 + +基于当天对真实物体、Ground 起点、逐帧、实验按钮的连续实验,可以再补充一条更直接的工程结论: + +1. 对 Navisworks 当前这套增量 API,真正稳定的做法只有**最简单的增量法**: + - 直接构造宿主坐标系下的旋转增量 + - 直接构造宿主坐标系下的平移增量 + - 按业务需要分步应用 +2. 对真实物体来说,旋转和平移不需要再引入“物体局部映射”来解释 API 行为。 +3. 也就是说,变换主语应始终是: + - 宿主坐标系下绕哪根轴旋转 + - 宿主坐标系下平移多少 +4. 如果继续把问题建模成: + - 先解释物体局部轴 + - 再把宿主旋转翻译成局部旋转 + - 再去重建完整目标姿态 + 这条链在真实物体上非常容易把问题复杂化,而且会引入额外歧义。 +5. 因此,后续 Ground 真实物体变换链的工程方向应当是: + - 只保留宿主坐标系增量变换 + - 旋转和平移分开处理 + - 不再在变换工具层传播“局部坐标系 / 局部轴映射”概念 + +一句话总结: + +- **只有最简单的宿主坐标系增量法,才能尽量无副作用地做真实物体变换。** +- **旋转和平移不需要物体局部映射,局部坐标的概念可以从这条变换链里彻底移除。** + +--- + +## 28. 2026-04-09 Ground 真实物体增量链追加结论 + +以下结论来自 2026-04-09 对 `Ground + 真实物体` 的连续回归: + +- 起点落位 +- `X / Y / Z` 角度调整 +- 逐帧动画 +- 通行空间尺寸 + +以及同一对象在 Navisworks 中的实际可视结果与日志对照。 + +### 28.1 Ground 角度调整的正确主语 + +对 `Ground + 真实物体`,角度调整的正确主语仍然只有: + +- 宿主世界 `X` 轴 +- 宿主世界 `Y` 轴 +- 宿主世界 `Z` 轴 + +也就是说: + +- `XDegrees` 就是绕宿主 `X` 轴的增量 +- `YDegrees` 就是绕宿主 `Y` 轴的增量 +- `ZDegrees` 就是绕宿主 `Z` 轴的增量 + +不需要再额外解释: + +- 物体当前局部轴 +- 参考姿态 +- 基姿态 +- fragment 代表姿态 +- 局部轴业务映射 + +### 28.2 角度调整必须按“单轴增量”逐次应用 + +本轮实验已经验证: + +- `Ground` 的角度调整不能先重建“完整目标姿态” +- 也不能把多个轴的旋转先合成为一份“总姿态”再去应用 + +更稳定的工程做法是: + +1. 对当前物体直接叠加一个宿主轴旋转增量 +2. 一次只处理一个轴 +3. 非 `up` 轴修正做完后,再处理路径方向对应的 `yaw` +4. 最后再做平移补偿 / tracked point 对齐 + +一句话: + +- **Ground 真实物体的角度调整应实现为“直接对物体叠加宿主轴增量”,而不是“先解释姿态,再重建目标姿态”。** + +### 28.3 不要从当前显示姿态反解新的平面角 + +本轮实际问题证明: + +- 对 `X / Z` 非 `up` 轴修正,如果先应用一次宿主轴增量 +- 再从修正后的当前显示姿态里重新反解 `currentYaw` +- 很容易把非平面旋转的结果错误混入后续平面转向链 + +对 `Ground` 这条链,更稳定的做法是: + +- 非 `up` 轴修正只负责自身的单轴增量旋转 +- 路径 `yaw` 仍然只作为“平面转向”处理 +- 不再把前一步的三维旋转结果重新解释成新的 `yaw` 起点 + +### 28.4 Ground 通行空间的正确数学模型 + +对 `Ground + 真实物体` 的通行空间,当前验证通过的最简单数学模型是: + +1. 先确定一组固定的宿主语义尺寸: + - `forward` + - `side` + - `up` +2. 再只根据宿主轴角度修正,计算旋转后的: + - `forwardExtent` + - `sideExtent` + - `upExtent` +3. 不再把这组三个尺寸二次投影到 `pathForward` + +原因是: + +- 渲染器本身已经会把: + - `along` + - `across` + - `normal` + 摆到路径上 +- 如果在尺寸链里再按路径方向投影一次,就会把路径方向重复计算 + +因此对 `Ground` 通行空间,更合理的输入应当是: + +- 角度修正后的宿主语义尺寸 + +而不是: + +- 再投影到路径方向后的尺寸 + +### 28.5 当前已补齐的最小数学测试集合 + +本轮已经在: + +- [RotatedObjectExtentHelperTests.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/UnitTests/CoordinateSystem/RotatedObjectExtentHelperTests.cs) + +补齐 `Ground / YUp` 下的宿主轴尺寸测试,包括: + +- `X / Y / Z` +- `45 / 90 / 135 / 180 / 270` + +这些测试锁住的不是业务解释,而是最基础的数学结论: + +- 一个长方体在宿主语义尺寸下 +- 经宿主轴旋转后 +- `forward / side / up` 三个尺寸应如何变化 + +### 28.6 当前可以正式采用的 Ground 工程约束 + +对 `Ground + 真实物体`,当前可以正式采用以下工程约束: + +1. 起点、角度调整、逐帧动画都优先走宿主坐标系增量链。 +2. `X / Y / Z` 角度调整一次只处理一个宿主轴,不重建总姿态。 +3. `yaw` 只处理平面路径转向,不再承担三维姿态解释职责。 +4. 通行空间只消费“角度调整后的宿主语义尺寸”,不再重复按路径方向投影。 +5. 这条链不再传播: + - 基姿态 + - 参考姿态 + - 局部轴映射 + - fragment 代表姿态 + 这些概念作为变换主语。 + +一句话总结: + +- **Ground 真实物体这条链,已经证明“宿主轴单轴增量 + 平面 yaw + 平移补偿”是目前最稳定、最可控的实现方式。** diff --git a/doc/working/2026-04-08-ground-remove-fragment-dependency-plan.md b/doc/working/2026-04-08-ground-remove-fragment-dependency-plan.md new file mode 100644 index 0000000..1dad3c6 --- /dev/null +++ b/doc/working/2026-04-08-ground-remove-fragment-dependency-plan.md @@ -0,0 +1,254 @@ +# Ground 去 Fragment 依赖实施方案 + +更新时间:2026-04-08 + +## 1. 这份方案现在只解决什么 + +只解决一件事: + +- `Ground + 真实物体` 主链里,尽量去掉 `fragment` 参考姿态依赖 + +只允许改动: + +- `PathAnimationManager.cs` +- 必要时补少量日志 + +明确不做: + +- 不新建大范围工具链 +- 不改 `Hoisting` +- 不改 `Rail` +- 不重写 `ModelItemTransformHelper` +- 不删除 `RealObjectReferencePoseResolver` +- 不做“整项目去 fragment” + +这份方案的目标是:**缩小修改范围,先把 Ground 主链收干净。** + +--- + +## 2. 当前已确认的事实 + +1. `Ground` 的变换更适合走最简单的宿主增量法: + - 宿主旋转增量 + - 宿主平移增量 +2. `Ground` 这条链不应该再扩散 `local/reference/fragment` 概念。 +3. `fragment` 现在的问题,不在于“所有地方都要立刻删”,而在于: + - Ground 主链还会读它 + - 导致旋转来源不稳定 +4. `Ground` 这条链仍然有旋转目标,但这个旋转目标不再来自 `fragment` 参考姿态解释。 +5. `Ground` 后续只需要求: + - 路径在宿主平面里的方向 + - 对应的宿主平面旋转量 +6. 也就是说,`Ground` 不再求“reference-based pose”,而只求: + - `hostForward` + - `hostUp` + - `host-planar rotation delta` + +--- + +## 3. 只保留的改造目标 + +这轮只保留 3 个具体目标: + +1. `Ground` 初始化时,不再优先读 fragment 参考姿态 +2. `Ground` 平面姿态求解时,不再走 fragment 参考旋转入口,而只求宿主平面旋转量 +3. `Ground` 不再允许 fragment planar fallback + +只要这 3 点做到,就算这一轮完成。 + +--- + +## 4. 当前 Ground 需要处理的入口 + +### 4.1 初始化入口 + +当前重点看: + +- `SyncTrackedRotationToObjectReference(...)` + +要求: + +- 当 `PathType == Ground` 且是真实物体时 +- 不再去走 `TryCaptureRealObjectReferenceRotation(...)` +- 直接改用当前实际几何姿态,或现有非 fragment 入口 +- 这一步的目的不是继续建立另一套“参考姿态定义”,而只是拿到当前增量起点 + +### 4.2 平面姿态求解入口 + +当前重点看: + +- `TryGetRealObjectReferenceRotation(...)` +- `TryCreateReferenceBasedRealObjectPlanarPoseSolution(...)` + +要求: + +- `Ground` 不再从这里拿 fragment 参考旋转 +- `Ground` 不再继续求 reference-based pose +- `Ground` 只求宿主平面旋转量: + - 路径 `hostForward` + - 宿主 `hostUp` + - 当前对象在宿主坐标系下要追加的平面旋转量 + +### 4.3 fallback 入口 + +当前重点看: + +- `ShouldAllowFragmentPlanarFallback(PathType pathType)` + +要求: + +- `Ground` 改成和 `Hoisting` 一样,不再允许 fragment planar fallback + +--- + +## 5. 实施顺序 + +只按下面顺序做,不扩展: + +1. 先改 `Ground` 初始化入口 +2. 再改 `Ground` 平面姿态求解入口 +3. 再改 `Ground` 尺寸/通行空间入口,避免它们继续偷偷走 `reference-based pose` +4. 最后关掉 `Ground` 的 fragment fallback + +每一步都要求: + +- 先看日志 +- 只改 `Ground` +- 不顺手改别的路径 + +### 5.1 2026-04-07 夜间分析结论 + +第一刀已经证明: + +- `Ground` 初始化入口可以先切掉 fragment +- 但这还不够 + +这次日志暴露出的真正问题是: + +- `Ground` 虽然不再直接读 fragment +- 但起点姿态求解仍然在走 `TryCreateRealObjectPlanarPoseSolution(...)` +- 也就是仍然在走 `reference-based pose` 入口 + +所以第二刀必须明确成: + +1. `Ground` 不再进入 `TryCreateRealObjectPlanarPoseSolution(...)` +2. `Ground` 起点旋转改成: + - 当前显示旋转 + - 当前宿主平面 yaw + - 路径目标宿主平面 yaw + - 基于这三者直接求目标旋转 +3. 也就是 `Ground` 只保留: + - 当前显示状态作为增量起点 + - 路径方向作为目标方向 + - 宿主平面旋转量作为唯一旋转语义 + +### 5.2 现有链路与目标链路 + +当前代码里,`Ground + 真实物体` 至少有 3 个入口会接触姿态: + +1. 起点 + - `MoveObjectToPathStart(...)` + - `TryCreatePlanarPathRotationAtStart(...)` + - `TryCreateRealObjectPlanarRotationFromHostForward(...)` +2. 逐帧 + - `ApplyGroundAnimationFrame(...)` +3. 尺寸/通行空间 + - `TryCalculateCurrentRealObjectPlanarProjectedExtents(...)` + +这一轮的目标链路应该统一成: + +1. 初始化 + - 当前显示姿态只用来拿“当前增量起点” + - 不再把它包装成 `referenceRotation` +2. 起点/逐帧旋转 + - 只求 `hostForward` + - 只求宿主平面旋转量 + - 不再进入 `TryCreateRealObjectPlanarPoseSolution(...)` + - 起点应用层不再走 `currentRotation -> targetRotation -> deltaRotation` + - 改成直接施加宿主轴旋转增量,再补平移把 tracked point 拉回目标点 +3. 尺寸/通行空间 + - 对 `Ground` 直接复用固定业务约定: + - `forward = PositiveX` + - `up = HostUp` + - 不再通过 `reference-based pose` 推导 `ModelAxisConvention` + +这 3 条链必须保持同一件事: + +- `Ground` 不再解释 reference pose +- `Ground` 只解释“当前显示状态 + 路径方向 + 宿主平面旋转量” + +--- + +## 6. 验证标准 + +这轮不追求“大而全测试矩阵”,只看 3 条: + +1. 起点 + - `Ground + 真实物体` 到起点后不再读 fragment 姿态 + +2. 逐帧 + - `Ground` 播放时旋转来源不再依赖 fragment + - `Ground` 只根据路径方向继续求宿主平面旋转量 + +3. 尺寸/通行空间 + - `Ground` 的 projected extents 不再因为 fragment/reference pose 缺失而失败 + - `Ground` 的 `ModelAxisConvention` 不再来自 `TryCreateRealObjectPlanarPoseSolution(...)` + +4. fallback + - `Ground` 关闭 fragment fallback 后,主链要么成功,要么明确报错 + - 不允许再偷偷回退 + +--- + +## 7. 当前停止线 + +如果做到下面这句话,就先停: + +- **Ground 主链不再依赖 fragment,但 Hoisting / Rail / 通用参考姿态系统暂时不动。** + +不要在这一轮里再继续追求: + +- 抽象统一工具类 +- 清理全部 reference/local 命名 +- 一次性删光 fragment 代码 +- 统一三类路径的所有姿态入口 + +这些都属于下一轮的事。 + +--- + +## 8. 2026-04-08 夜间新增:Ground 角度调整的最小实现策略 + +在 `Ground + 真实物体` 这条新纯增量链上,角度调整不再通过“完整目标姿态重建”实现,而只允许走最单纯的宿主增量法: + +1. `up` 轴修正 + - 不单独构造三维姿态 + - 直接并入路径目标 `yaw` + - `YUp` 取 `YDegrees` + - `ZUp` 取 `ZDegrees` + +2. 非 `up` 轴修正 + - 不与 `yaw` 一次性组合 + - 只在起点落位后,围绕业务跟踪点按宿主轴逐次增量应用 + - 当前约定: + - `YUp`:应用 `XDegrees`、`ZDegrees` + - `ZUp`:应用 `XDegrees`、`YDegrees` + +3. 逐帧播放 + - 继续只吃“路径宿主平面角 + up 轴修正” + - 不在每帧重复叠加非 `up` 轴修正 + - 非 `up` 轴修正应由起点姿态一次性建立,并在后续 `yaw` 增量中自然保持 + +### 明早优先验证 + +1. `Ground + 真实物体` 到起点 + - 位置是否继续保持正确 + - `X/Y/Z` 角度调整是否真正进入增量旋转链 + +2. `Ground` 逐帧播放 + - 转弯是否仍保持当前已修好的效果 + - `up` 轴修正是否能跟随路径持续生效 + +3. 非 `up` 轴修正 + - 是否表现为“起点一次性按宿主轴旋转” + - 播放过程中不应每帧累加放大 diff --git a/doc/working/2026-04-13-auto-path-planning-coordinate-refactor-plan.md b/doc/working/2026-04-13-auto-path-planning-coordinate-refactor-plan.md new file mode 100644 index 0000000..1e24334 --- /dev/null +++ b/doc/working/2026-04-13-auto-path-planning-coordinate-refactor-plan.md @@ -0,0 +1,338 @@ +# 自动路径规划坐标系重构实施方案 + +更新时间:2026-04-13 +状态:实施中 +目标:将 `Ground` 自动路径规划整条链路并入当前稳定的“宿主坐标系 -> Canonical Space -> 宿主坐标系”架构,消除旧 `XY` 平面 / `Z` 高度硬编码,不再针对单一 `YUp` 现象打补丁。 + +--- + +## 1. 背景与问题定义 + +当前项目的坐标系架构已经明确: + +- 宿主坐标系(Host Space) + - Navisworks 文档坐标系 + - UI 输入输出、鼠标拾取、日志、渲染交互都属于这一层 +- 内部坐标系(Canonical Space) + - 项目内部统一固定为 `ZUp` + - 几何、姿态、法向、路径计算优先在这一层完成 +- 业务语义层 + - 在 canonical 之上表达地面接触点、吊点、轨道法向、动画跟踪点等业务含义 + +但自动路径规划链路目前仍大量保留旧实现习惯: + +- 默认 `XY` 是水平平面 +- 默认 `Z` 是高度轴 +- 默认世界 `Z` 可以直接代表“向上” +- 路径起终点修正仍然是“保留 `XY`,替换 `Z`” + +这说明当前问题不是“某个 `YUp` 细节没处理”,而是: + +- 自动路径规划整条链没有接入当前稳定的坐标系架构 + +因此本方案的目标不是修一个 `YUp` bug,而是把自动路径规划整体收编到现有坐标系体系中。 + +--- + +## 2. 现象与直接证据 + +以 `YUp` 文档中的地面自动路径为例,日志显示: + +- 起点:`(-209.94, 14.83, -1.68)` +- 终点:`(-169.20, 14.83, -5.35)` + +对当前文档来说,宿主 `up` 是 `Y`,所以: + +- 地面高度应主要体现在 `Y` +- `Z` 应属于宿主水平面的一部分 + +但自动路径规划链中仍有如下旧假设: + +1. `PathPlanningManager` 按 `X/Y` 计算边界与网格尺寸 +- `src/Core/PathPlanningManager.cs` +- `GetModelBounds()` +- `CalculateOptimalGridSize(...)` + +2. `AutoPathFinder` 把图节点还原为: +- `new Point3D(worldPos.X, worldPos.Y, nodeInfo.Z)` + +3. `AutoPathFinder` 的起终点修正逻辑仍是: +- 保留原始 `X/Y` +- 只替换 `Z` + +这套实现天然绑定旧 `ZUp` 语义,无法正确支持当前多坐标系架构。 + +--- + +## 3. 重构目标 + +本次重构要达成的不是“YUp 也能跑”,而是以下更高层目标: + +1. 自动路径规划输入输出继续保持宿主坐标语义 +- 用户点击点、UI 日志、路径显示、数据库路径点仍然按宿主坐标解释 + +2. 自动路径规划内部计算统一切到 canonical +- 网格范围 +- 栅格映射 +- A* 路径求解 +- 高度层处理 +- 曲线化前的路径点 + +3. Ground 自动路径与通行空间、路径渲染、动画主链共享同一套 up / 高度语义 + +4. 禁止继续保留旧 `XY/Z` 写死逻辑作为 fallback + +--- + +## 4. 范围界定 + +### 4.1 本次重构范围 + +- `PathEditingViewModel` + - 自动路径起点/终点输入边界 +- `PathPlanningManager` + - 自动路径入口、边界、网格参数、路径落库前收口 +- `GridMapGenerator` + - BIM 到网格的平面/高度语义 +- `AutoPathFinder` + - 路径搜索、节点转世界坐标、首尾点修正 +- 与自动路径规划直接耦合的测试 + +### 4.2 暂不纳入本次范围 + +- 手动路径编辑主链 +- 吊装路径自动生成链 +- Rail 自动生成链 +- 动画姿态链 +- 碰撞恢复链 + +说明: + +- 本次先聚焦 `Ground` 自动路径规划 +- 但设计必须是通用的宿主/内部坐标边界,不允许做成“只为 `YUp` 单独补丁” + +--- + +## 5. 关键设计原则 + +### 5.1 边界清晰 + +- 宿主坐标只在输入输出边界存在 +- 内部计算统一使用 canonical +- 不允许在算法中间层直接假设世界 `Z` 是高度 + +### 5.2 不新增 fallback + +不允许: + +- canonical 转换失败时回退旧 `XY/Z` 逻辑 +- `YUp` 失败时偷偷走旧分支 +- 用“如果是 YUp 就特判”掩盖整体架构问题 + +### 5.3 优先复用现有坐标工具 + +优先复用: + +- `HostCoordinateAdapter` +- `CanonicalTrackedPositionResolver` +- 坐标系统一设计文档中已经稳定的术语和边界 + +### 5.4 先锁测试,再动实现 + +本次重构必须先补针对多坐标系的自动路径测试,再改实现。 + +--- + +## 6. 目标架构 + +目标链路应收敛为: + +```text +宿主点击点 / 宿主通道边界 / 宿主模型几何 + ↓ HostCoordinateAdapter +Canonical 起点 / 终点 / 边界 / 网格 / 高度层 + ↓ 自动路径规划算法(统一在 canonical 内) +Canonical 路径点 + ↓ HostCoordinateAdapter +宿主路径点 / 渲染 / UI / 数据库存储 +``` + +关键语义: + +- canonical 内部固定: + - `XY` 为水平平面 + - `Z` 为高度 +- 宿主坐标系的 `YUp/ZUp` 差异只由 `HostCoordinateAdapter` 负责吸收 + +--- + +## 7. 分阶段实施计划 + +### Phase 0:现状锁定与测试补齐 + +目标: + +- 先把当前正确/错误行为锁定,避免重构中语义漂移 + +任务: + +- [ ] 新增 `Ground` 自动路径在 `ZUp` 文档下的首尾点测试 +- [ ] 新增 `Ground` 自动路径在 `YUp` 文档下的首尾点测试 +- [ ] 新增“宿主点 -> 自动路径 -> 路径点输出”边界测试 +- [ ] 新增“路径点 / 路径线 / 通行空间”首尾地面一致性测试 + +验收标准: + +- 在没有改实现前,测试至少能明确暴露 `YUp` 下当前错误 +- `ZUp` 当前稳定行为被锁住,不允许回归 + +### Phase 1:输入边界 canonical 化 + +目标: + +- 自动路径规划入口不再把宿主点直接扔进旧 `XY/Z` 规划逻辑 + +任务: + +- [ ] 梳理 `PathEditingViewModel` 起点/终点点击数据的宿主语义 +- [ ] 在自动路径规划入口显式引入 `HostCoordinateAdapter` +- [ ] 将 `AutoPlanPath(...)` 的内部输入切换为 canonical 起点/终点 +- [ ] 明确日志里同时打印宿主输入点与 canonical 输入点 + +验收标准: + +- 自动路径规划入口不再直接依赖宿主 `YUp/ZUp` +- 起点终点进入算法前,坐标语义清晰可见 + +### Phase 2:网格与边界 canonical 化 + +目标: + +- 网格边界、障碍物、通道数据全部按 canonical 平面/高度工作 + +任务: + +- [ ] 重构 `GetModelBounds()` / `CalculateChannelsBounds(...)` +- [ ] 重构 `CalculateOptimalGridSize(...)`,不再把 `X/Y` 固定当平面 +- [ ] 重构 `GridMapGenerator.GenerateFromBIM(...)` 的输入语义 +- [ ] 明确网格二维平面坐标与 canonical 三维坐标的边界 + +验收标准: + +- 网格生成不再依赖宿主世界 `XY` +- `YUp` / `ZUp` 两类文档进入网格阶段后语义完全一致 + +### Phase 3:路径求解与输出 canonical 化 + +目标: + +- `AutoPathFinder` 内部只处理 canonical +- 路径输出统一经适配器返回宿主 + +任务: + +- [ ] 重构图节点到三维点的转换逻辑 +- [ ] 删除或重写“保留 XY、替换 Z”的旧首尾点修正 +- [ ] 重构路径输出与落库前的宿主还原逻辑 +- [ ] 日志里同时打印 canonical 路径首尾点与宿主路径首尾点 + +验收标准: + +- 路径首尾点在 `YUp/ZUp` 下都能正确贴回宿主地面 +- 不再出现“终点被压到地面下方”的旧 `ZUp` 语义残留 + +### Phase 4:联动校验与收尾 + +目标: + +- 自动路径规划与可视化、通行空间、动画链的基础语义重新对齐 + +任务: + +- [ ] 检查路径点显示是否仍使用宿主坐标 +- [ ] 检查路径线和通行空间首尾点是否与路径点一致 +- [ ] 清理旧 `XY/Z` 注释、旧日志、旧辅助方法 +- [ ] 更新 `current-engineering-state.md` 中自动路径规划状态说明 + +验收标准: + +- 自动路径规划的起点、终点、路径线、通行空间在多坐标系文档下语义一致 +- 旧 `XY/Z` 硬编码实现不再残留为正式执行链 + +--- + +## 8. 关键改动点清单 + +高风险文件: + +- `src/UI/WPF/ViewModels/PathEditingViewModel.cs` +- `src/Core/PathPlanningManager.cs` +- `src/PathPlanning/GridMapGenerator.cs` +- `src/PathPlanning/AutoPathFinder.cs` + +重点清理的旧逻辑: + +- [ ] “默认 `XY` 为地面平面”的边界计算 +- [ ] “默认 `Z` 为高度”的路径节点组装 +- [ ] “保留 `XY`,替换 `Z`”的起终点修正 +- [ ] 任何直接写死世界 `Z` 为 up 的自动路径规划实现 + +--- + +## 9. 测试与验证策略 + +### 9.1 单元/集成测试 + +至少补齐: + +- [ ] `ZUp Ground` 自动路径首尾点测试 +- [ ] `YUp Ground` 自动路径首尾点测试 +- [ ] 起点/终点保持宿主地面接触语义测试 +- [ ] 路径输出后首尾点与通行空间一致性测试 + +### 9.2 手工验证 + +在真实 Navisworks 场景中验证: + +- [ ] `ZUp` 模型中自动地面路径仍正常显示 +- [ ] `YUp` 模型中自动地面路径终点不再掉到地面下 +- [ ] 默认显示模式下能看到起点、终点和路径线 +- [ ] 打开通行空间后,首尾位置与路径点一致 + +--- + +## 10. 风险与注意事项 + +1. 自动路径规划链路历史较老,旧实现可能分散在多个辅助方法中 +- 不能只改主入口,必须顺着网格、A*、输出一路清理 + +2. 数据库存储仍然是宿主坐标语义 +- 不能把 canonical 点直接落库 + +3. 路径渲染和自动路径规划不能各自维护一套“高度轴”解释 +- 否则会再次出现“路径点对、通行空间错”或反过来的问题 + +4. 本次重构不是 `Ground` 的最后一步 +- 后续 `Hoisting/Rail` 若要做自动路径,也应直接复用这次收好的边界层 + +--- + +## 11. 进度记录 + +### 2026-04-13 + +- [x] 确认当前自动路径规划主链仍大量保留旧 `XY/Z` 假设 +- [x] 明确本次方向应为“并入现有坐标系架构”,而不是追加 `YUp` 特判 +- [x] 建立本实施方案文档 +- [x] 引入 `HostPlanarGridHelper`,明确“旧 ICoordinateSystem 水平/高程契约”与 `HostCoordinateAdapter` 的桥接边界 +- [x] `YUpCoordinateSystem` / `ZUpCoordinateSystem` 改为复用统一桥接 helper,而不是各自散落实现 +- [x] 为宿主平面/高程桥接补充纯数学单测,锁定: + - `YUp` 高程走宿主 `Y` + - `YUp` 水平面走宿主 `XZ` + - `ZUp` 仍保持宿主 `XY + Z` +- [x] `AutoPathFinder` 第一轮收口完成: + - 起终点映射改为通过 `gridMap.GetWorldElevation(...)` + - 图节点 / 路径点还原改为通过 `gridMap.CreateWorldPoint(...)` + - 高度层匹配与路径恢复改为通过 `gridMap.GetWorldElevation(...) / SetWorldElevation(...)` +- [ ] Phase 0 剩余:补“自动路径首尾点”更贴近业务的回归测试 +- [ ] Phase 2 尚未开始:`GridMapGenerator` 仍存在较多旧 `XY/Z` 硬编码 diff --git a/doc/working/coordinate-system-canonical-space-implementation-plan.md b/doc/working/coordinate-system-canonical-space-implementation-plan.md new file mode 100644 index 0000000..cf3928a --- /dev/null +++ b/doc/working/coordinate-system-canonical-space-implementation-plan.md @@ -0,0 +1,460 @@ +# 坐标系统一架构实施清单 + +## 1. 文档目的 + +本文档基于 +[coordinate-system-canonical-space-design.md](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/doc/design/2026/coordinate-system-canonical-space-design.md) +中的方案,拆解出一份可执行的实施清单。 + +目标不是一次性全项目重构,而是: + +- 先建立“外部坐标 -> 内部统一坐标 -> 业务基准坐标”的边界 +- 再优先改造当前最重要的非自动路径规划功能 +- 逐步把历史 `Z-up` 假设收口 + +## 2. 总体原则 + +### 2.1 本轮范围 + +当前优先范围: + +- 终端安装仿真 +- Rail 三维姿态 +- 动画播放 +- 碰撞检测与碰撞恢复 +- 辅助线、通行空间、碰撞点相关渲染 + +当前明确**不优先**处理: + +- 自动路径规划 +- 高度检测 +- 坡度分析 +- 旧网格高度/通道高度相关算法 + +### 2.2 内部统一坐标 + +当前实施目标固定为: + +- **内部统一坐标 = Canonical Space = Z-up** + +### 2.3 外部坐标定义 + +当前实施中,统一定义: + +- **Navisworks API 返回的世界坐标 = 外部坐标** + +即: + +- `Point3D` +- `Vector3D` +- `BoundingBox3D` +- `Transform3D` +- `Document.UpVector` +- `ModelItem.BoundingBox()` +- 鼠标点击拾取点 + +都先视为外部输入。 + +## 3. 里程碑划分 + +### M1. 建立坐标边界层 + +目标: + +- 引入统一的宿主坐标适配器 +- 不再在业务代码里直接解释 Navisworks 坐标 + +### M2. 接入终端安装仿真 + +目标: + +- 终端安装仿真不再直接依赖世界原点和世界 `Z` +- 球心与项目 up 都走统一边界层/业务基准层 + +### M3. 接入 Rail 三维姿态 + +目标: + +- Rail 姿态和动画只认内部统一坐标 +- 不再在姿态 helper 中写死 `(0,0,1)` + +### M4. 接入碰撞恢复和可视化 + +目标: + +- 碰撞报告、碰撞点恢复、自动截图、辅助线渲染统一使用同一套内部语义 + +## 4. 任务清单 + +## 4.1 M1 建立坐标边界层 + +### Task 1. 新增 `HostCoordinateAdapter` + +目标: + +- 建立 Navisworks 外部坐标与内部统一坐标之间的唯一适配入口 + +建议职责: + +- `ToCanonicalPoint(...)` +- `ToCanonicalVector(...)` +- `ToCanonicalBounds(...)` +- `ToCanonicalTransform(...)` +- `FromCanonicalPoint(...)` +- `FromCanonicalVector(...)` +- `FromCanonicalBounds(...)` +- `FromCanonicalTransform(...)` + +建议要求: + +- 统一使用完整三维变换语义 +- 不再停留在 `GetElevation()/GetHorizontalCoords()` 这种偏二维接口 +- 点、向量、包围盒、旋转、变换都必须有明确变换规则 +- 坐标定义中必须显式暴露: + - `UpAxis` + - `ElevationAxis` + - `HorizontalPlane` + +验收: + +- 对同一组 Navisworks 输入,适配结果在 Y-up 和 Z-up 项目中都能稳定输出 Canonical Space 数据 + +### Task 2. 新增 `ProjectReferenceFrame` + +目标: + +- 承载业务基准点,不再把业务基准混进坐标系层 + +建议职责: + +- `SphereCenterInCanonical` +- `ProjectUpInCanonical` +- 终端安装参考方向 +- 轨道参考面 + +验收: + +- 程序内不再把世界原点直接当球心使用 + +### Task 3. 约束边界:禁止业务代码直接解释宿主坐标 + +目标: + +- 收口“谁可以直接碰 Navisworks 坐标” + +需要收口的入口: + +- 鼠标点击取点 +- `ModelItem.BoundingBox()` +- `Transform3D` +- `Document.UpVector` +- 3D 渲染输入 +- 动画对象位姿输出 + +验收: + +- 新功能主链路中,业务代码不再直接从 `BoundingBox().Center` 开始做业务推导 + +## 4.2 M2 接入终端安装仿真 + +### Task 4. 改造 `PathEditingViewModel` + +文件: + +- [PathEditingViewModel.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/UI/WPF/ViewModels/PathEditingViewModel.cs) + +目标: + +- 终端安装辅助线全部改为基于 Canonical Space + ProjectReferenceFrame + +重点修改点: + +- `BuildAssemblyReferenceLine()` +- `GetAssemblyTerminalAnchorPoint()` +- 与辅助线、终点锚点、参考方向相关的计算 + +必须消除: + +- `Vector3D direction = new Vector3D(centerPoint.X, centerPoint.Y, centerPoint.Z);` +- 直接把世界原点当球心 + +验收: + +- Y-up 项目中,若球心配置正确,辅助线方向与业务预期一致 +- Z-up 项目中,行为保持不退化 + +### Task 5. 明确 UI 和日志的坐标语义 + +目标: + +- 终端安装相关 UI、日志输出不混淆内部坐标和外部坐标 + +建议: + +- 内部计算使用 Canonical +- 对用户显示时,明确是否转回 Navisworks 外部坐标 + +验收: + +- 日志中坐标语义一致,不再出现“内部算的是一种,打印的是另一种” + +## 4.3 M3 接入 Rail 三维姿态 + +### Task 6. 改造 `RailPathPoseHelper` + +文件: + +- [RailPathPoseHelper.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Utils/RailPathPoseHelper.cs) + +目标: + +- Rail 姿态计算统一基于 Canonical Space + +必须消除: + +- `new Vector3D(0, 0, 1)` +- `worldUp = new Vector3D(0, 0, 1)` + +替换方式: + +- 改用适配层提供的 Canonical up +- 或由 `ProjectReferenceFrame` 提供项目参考 up + +验收: + +- Y-up 项目与 Z-up 项目中,Rail 参考方向一致、俯仰/侧倾逻辑一致 + +### Task 7. 清理 Rail 动画输入边界 + +涉及文件: + +- [PathAnimationManager.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/Animation/PathAnimationManager.cs) +- [VirtualObjectManager.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/VirtualObjectManager.cs) +- [ModelItemTransformHelper.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Utils/ModelItemTransformHelper.cs) + +目标: + +- 动画内部只消费 Canonical 姿态 +- 输出到 Navisworks 时再做反向转换 + +验收: + +- Rail 虚拟物体与真实模型在 Y-up / Z-up 项目中的姿态语义一致 +- 真实物体起点贴合和动画第一帧使用同一份固定物理尺寸语义 +- 不再允许从“已旋转后的当前 AABB”重新推导 Rail 物体高度 +- Rail 动画不再直接在业务层手写 `forward/up/offset`,而是复用基础工具 +- `ClashDetective` 三维候选验证必须直接复用 `PathAnimationManager` 主链路恢复,不允许先 `ResetPermanentTransform` 再复用 `PAM` + +### Task 7.2 新增 `RailLocalFrame` + +目标: + +- 将 `Rail` 局部坐标系正式提升为基础框架对象 + +最低要求: + +- `Forward` +- `Normal` +- `Lateral` + +实施要求: + +- 先在纯数学层构建并测试 +- 再由 `RailPathPoseHelper`、动画、通行空间复用 + +验收: + +- 不再由业务代码自己拼切向/法向/侧向 +- `Rail` 的姿态、法向偏移、通行空间统一使用同一套局部坐标系 + +### Task 7.3 新增“参考点 -> 跟踪中心点”偏移解析器 + +目标: + +- 将路径参考点到动画跟踪中心点的偏移,从业务代码中抽离成基础工具 + +实施要求: + +- 先以几何中心为统一动画跟踪点 +- 地面/吊装路径使用宿主 up 语义 +- Rail 路径使用 `RailLocalFrame.Normal` +- 先补单测再接业务 + +验收: + +- `PathAnimationManager` 不再直接散落手写半高偏移 +- `AnimatedObjectTrackedPosition` 明确表示动画跟踪中心点 + +### Task 7.1 明确模型局部轴约定 + +目标: + +- 将“模型本地哪个轴代表 forward/up”从隐含假设提升为显式定义 + +需要覆盖: + +- 虚拟物体资源 +- 终端安装辅助杆资源 +- 真实模型在 `Y-up` / `Z-up` 项目中的默认局部轴约定 + +最低要求: + +- 至少明确 `LocalForwardAxis` +- 至少明确 `LocalUpAxis` + +验收: + +- 不再出现“路径方向和通行空间都正确,但真实模型仍按错误本地 up 站立”的现象 +- `Y-up` 真实模型与 `Z-up` 真实模型都能通过显式局部轴约定接入 Rail 姿态链路 + +## 4.4 M4 接入碰撞恢复和渲染 + +### Task 8. 改造碰撞恢复链路 + +涉及文件: + +- [CollisionSceneHelper.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Utils/CollisionSceneHelper.cs) +- [GenerateCollisionReportCommand.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Commands/GenerateCollisionReportCommand.cs) +- [ClashDetectiveIntegration.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/Collision/ClashDetectiveIntegration.cs) + +目标: + +- 碰撞点恢复、报告截图、碰撞点回看全部使用统一的内部姿态语义 + +验收: + +- Rail 碰撞报告恢复位置和动画实际姿态一致 +- 二维路径不被三维逻辑污染 + +实施提示: + +- 对真实物体,碰撞恢复和动画播放必须共用同一份固定物理尺寸语义 +- 不允许恢复链路偷偷退回“重新读取当前 AABB 再算高度/底面” + +### Task 9. 改造渲染层 + +文件: + +- [PathPointRenderPlugin.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/PathPointRenderPlugin.cs) +- [AssemblyReferencePathManager.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/AssemblyReferencePathManager.cs) + +目标: + +- 辅助线、通行空间、点标记、碰撞点渲染,不再直接写死 `Normal = (0,0,1)` +- 渲染层不仅要改“中心点/偏移量”,还要统一渲染几何的局部轴语义 + +验收: + +- Y-up 项目中,渲染法向和可视化朝向不再依赖世界 Z +- Y-up 项目中,默认通行空间和 `SP` 模式通行空间的高度轴与宿主 up 一致 +- 不再出现“中心点位置正确,但长方体/杆体本体仍按 Z-up 构造”的现象 + +实施提示: + +- 长方体、圆柱体、辅助杆这类几何渲染,必须同时检查: + - 中心点是否正确 + - `right/up/height` 局部轴是否正确 + - `Normal` 是否仍偷用世界 `Z` +- 不能只修改 `ApplyVerticalOffset(...)` 或中心点偏移,而保留旧的 `XY + Z` 轴构造公式 + +### Task 9.1 通行空间与物体贴合语义统一 + +涉及文件: + +- [AnimationControlViewModel.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/UI/WPF/ViewModels/AnimationControlViewModel.cs) +- [PathAnimationManager.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/Core/Animation/PathAnimationManager.cs) + +目标: + +- 通行空间显示、起点贴合、动画帧贴合使用统一尺寸语义 +- Rail 轨上/轨下模式下,贴合面与留缝面保持一致 + +经验约束: + +- `轨下安装`:顶面贴路径,底面留单侧间隙 +- `轨上安装`:底面贴路径,顶面留单侧间隙 +- 真实物体尺寸必须在选择物体时固定下来,并传入动画管理器 + +验收: + +- 起点贴合正确后,动画第一帧不再出现固定间隙 +- 通行空间与真实物体的贴合面语义一致 + +## 5. 已知问题与注意事项 + +### 5.1 当前不应优先展开的模块 + +以下模块虽然也存在坐标系写死问题,但暂不作为本轮主线: + +- [ChannelHeightDetector.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/PathPlanning/ChannelHeightDetector.cs) +- [SlopeAnalyzer.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/PathPlanning/SlopeAnalyzer.cs) +- [OptimizedHeightCalculator.cs](/C:/Users/Tellme/apps/NavisworksTransport-rail-mount-modes/src/PathPlanning/OptimizedHeightCalculator.cs) + +原因: + +- 它们主要服务于自动路径规划 +- 当前项目主线优先级不在这里 + +### 5.2 不能依赖 fallback 掩盖问题 + +实施过程中禁止: + +- 三维路径恢复失败时偷偷退回 `yaw` +- 外部坐标转换失败时偷偷按世界 `Z` 继续算 +- 业务基准点缺失时默认球心为 `(0,0,0)` + +必须让错误显式暴露。 + +### 5.3 不能使用不完整构建产物部署 WPF 插件 + +实施过程中必须区分: + +- 单元测试所需的程序集产物 +- 可用于 Navisworks 运行的完整 WPF 插件产物 + +对于本项目: + +- 仅有 `TransportPlugin.dll` 被复制成功,不代表插件可用 +- 还必须确保 `TransportPlugin.g.resources` 中包含完整的主视图 `.baml` + +最低要求: + +- 主项目构建成功后再部署 +- 如遇异常,应检查关键资源是否存在: + - `LogisticsControlPanel.baml` + - `PathEditingView.baml` + - `AnimationControlView.baml` + - `LayerManagementView.baml` + +否则可能出现: + +- 插件布局恢复失败 +- 手工打开插件窗口即崩溃 + +## 6. 当前实施优先顺序 + +建议实际推进顺序如下: + +1. `HostCoordinateAdapter` +2. `ProjectReferenceFrame` +3. `PathEditingViewModel` +4. `RailPathPoseHelper` +5. `PathAnimationManager` +6. `CollisionSceneHelper` +7. `PathPointRenderPlugin` + +## 7. 完成判定 + +当以下条件同时满足时,可认为本轮非自动路径规划坐标系统一改造达到阶段目标: + +- 终端安装仿真在 Y-up / Z-up 项目中语义一致 +- Rail 三维姿态在 Y-up / Z-up 项目中语义一致 +- 动画播放与碰撞恢复姿态一致 +- 碰撞报告截图与实际动画姿态一致 +- 主链路业务代码中不再直接写死世界原点和世界 `Z` + +## 8. 备注 + +本清单服务于“先稳定非自动路径规划主线”的目标,不代表自动路径规划相关模块不重要。 + +后续如要继续推进全项目坐标系统一,应单独启动自动路径规划相关的第二阶段清理计划。 diff --git a/doc/working/current-engineering-state.md b/doc/working/current-engineering-state.md new file mode 100644 index 0000000..48f90f7 --- /dev/null +++ b/doc/working/current-engineering-state.md @@ -0,0 +1,389 @@ +# 当前工程状态 + +更新时间:2026-04-01 + +## 1. 当前稳定状态 + +- `Rail` 路径主链路已稳定: + - `ZUp` 模型下,轨上/轨下路径的真实物体与虚拟物体起点、动画、终点贴合正确。 + - `YUp` 模型下,终端安装仿真、`Rail` 姿态、真实物体与虚拟物体通行空间、起点与动画主链路已基本跑通。 + - `Rail` “调整角度”窗口中的“平移”模式当前已重新收稳: + - 以终点箱体当前原始位姿为真值; + - 记录终点原位相对路径终点参考点的固定残差; + - 把这份残差整体搬运到路径起点; + - 动画全程保持终点原始姿态; + - 动画结束时应精确回到终点箱体原位。 +- 地面路径在 `YUp` 模型下: + - 虚拟物体起点姿态、动画姿态、转弯姿态已恢复正常。 + - 真实物体地面路径当前已恢复到可用状态: + - 起点落位补偿已接入; + - 逐帧补偿会随当前目标姿态一起旋转,不再使用固定世界补偿矢量; + - 动画结束后不再因恢复链或重复落最后一帧而再次跳动。 + - `Ground + 真实物体` 的逐帧姿态约束已经明确: + - 动画播放阶段只允许绕宿主 `up` 轴转动; + - 不再在播放阶段每帧重建完整三维姿态。 + - `Ground/Hoisting + 真实物体` 的前进轴语义当前已固定: + - 对象级前进轴统一按 `PositiveX` 解释; + - 不再因为路径初始方向更偏 `Z` 就把对象前进轴自动切换成 `PositiveZ`。 +- 当前已禁止地面/吊装路径偷偷退回旧 `yaw` 链路。 +- 吊装路径真实物体的角度调整链路已重新稳定: + - 起点“调整角度”后,预览、生成动画、播放前重置都复用同一份真实起点基姿态; + - 清除并重新选择物体时,旧角度修正必须被清空,不能继承上一次调整状态。 +- 碰撞检测/恢复主链路已稳定: + - `ClashDetective` 三维恢复不能再先 `ResetPermanentTransform`。 + - 碰撞恢复、自动报告、自动截图已重新对齐到动画主链路。 +- 虚拟物体资源问题已确认并修复: + - 旧 `unit_cube.nwc` 局部几何中心不在原点,会导致虚拟物体中心偏差。 + - 新 `unit_cube.nwc` 已替换为几何中心在原点的版本。 +- 旋转适配入口当前稳定分流: + - 虚拟物体继续走 `HostCoordinateAdapter` 的 `Legacy` 入口; + - 真实物体走 `Direct` 入口; + - 两者不能再强行共用同一条旋转转换链。 +- `Rail` 终端安装仿真创建/编辑区当前已完成第一轮工作流拆分: + - 新建 Rail 与编辑选中 Rail 不再共用同一套按钮命令入口; + - 新建区和编辑区有独立的 `找端面 / 选安装点 / 取起点` 可用条件; + - 两个区域都已有明确的“取消装配”退出入口; + - 上方“路径编辑 -> 结束”在 Rail 装配工作流活跃时,也必须能真正退出 Rail 装配流程。 + +## 2. 当前坐标系架构 + +- 外部坐标: + - `Navisworks` 世界坐标,统一视为外部输入坐标。 +- 内部统一坐标: + - `Canonical Space`,固定 `Z-up`。 +- 业务基准层: + - `ProjectReferenceFrame` + - 球心、项目 `up`、默认模型轴约定 +- 模型局部轴约定: + - `ModelAxisConvention` + - 明确 `ForwardAxis / UpAxis` +- `Rail` 局部坐标系: + - `RailLocalFrame` + - `Forward / Normal / Lateral` + +## 3. 当前关键基础工具 + +- `src/Utils/CoordinateSystem/HostCoordinateAdapter.cs` +- `src/Utils/CoordinateSystem/ProjectReferenceFrame.cs` +- `src/Utils/CoordinateSystem/ModelAxisConvention.cs` +- `src/Utils/CoordinateSystem/CanonicalRailPoseBuilder.cs` +- `src/Utils/CoordinateSystem/RailLocalFrame.cs` +- `src/Utils/CoordinateSystem/CanonicalRailOffsetResolver.cs` +- `src/Utils/CoordinateSystem/CanonicalTrackedPositionResolver.cs` +- `src/Utils/CoordinateSystem/CanonicalPlanarPoseBuilder.cs` + +## 4. 当前必须记住的根因与规则 + +### 4.0 真实物体参考姿态与 Fragment默认Up + +- 真实物体不能再默认使用 `ModelItem.Transform` 或包围盒正交框架当“原始姿态”。 +- 当前稳定做法是: + - 先从 fragment 统计得到代表姿态 + - 再用当前文档级 `Fragment默认Up` 解释这组 fragment 参考轴 + - 最终得到当前宿主语义下的真实参考姿态 +- `Fragment默认Up` 的真正用途不是“找一个竖直候选轴”,而是: + - 解释 fragment 参考姿态的原始 `Y/Z` 语义 + - 把 fragment 参考姿态转换成当前宿主坐标语义下可用的真实姿态 +- 当前文档如果 `Fragment默认Up` 设错,会直接导致: + - 真实物体起点姿态错误 + - Ground / Hoisting / Rail 的参考姿态解释错误 + - 后续角度修正、通行空间、路径贴合全部建立在错误姿态上 +- 当前规则: + - 先解释真实物体参考姿态,再做路径对齐 + - 不能跳过这一步,直接拿 fragment 世界轴去猜业务姿态 + - 当前补充规则: + - `Ground/Hoisting` 的真实物体“对象前进轴”与 fragment 参考姿态解释是两层语义; + - fragment 负责解释真实参考姿态; + - 对象前进轴当前在 `Ground/Hoisting` 上固定按 `PositiveX` 语义使用,不再做“按路径方向选最近轴”的自动切换。 + +### 4.1 Rotation / 矩阵语义 + +- `Rotation3D(a, b, c, d)` 的参数顺序是四元数 `x, y, z, w`。 +- 不要再随意在 `System.Numerics` 矩阵、四元数、Navisworks `Rotation3D` 之间猜行列语义。 +- 新的姿态构造应优先复用已经验证过的坐标框架工具,不要临时手拼矩阵。 + +### 4.2 碰撞恢复 + +- 对受 `PathAnimationManager` 控制的对象,`ClashDetective` 验证前绝不能先 `ResetPermanentTransform`。 +- 否则宿主姿态会被清回单位姿态,后续增量恢复会错误地认为“不需要再旋转”。 + +### 4.3 动画跟踪点语义 + +- `AnimatedObjectTrackedPosition` 的唯一语义: + - 当前动画主链路使用的跟踪点位置。 +- `Ground + 真实物体` 当前必须明确区分两类“中心”: + - 业务跟踪点应固定为物体**原始包围盒中心** + - 不是旋转后的实时包围盒中心 +- 实时包围盒中心的用途仅限于: + - 诊断旋转后漂移 + - 计算当帧补偿 + - 验证补偿结果 +- 不允许把实时包围盒中心直接回写成 `Ground` 路径的业务跟踪点定义。 +- 这条规则的直接影响范围包括: + - 起点落位 + - 逐帧补偿 + - 动画日志 + - 碰撞与结果记录语义 +- 如果后续动画跟踪点再变,数据库和碰撞结果语义必须同步更新。 + +### 4.4 虚拟物体资源 + +- 虚拟物体资源的局部几何中心必须在原点。 +- 如果资源局部原点不对,上层姿态和坐标框架再正确,也会表现为固定偏差。 +- 日志里优先看: + - 包围盒中心 + - 目标中心 + - 应用后偏差 +- 不要再用虚拟物体 `Transform` 的即时读回值判断 override 后的真实姿态。 + +### 4.5 地面/吊装路径 + +- 地面/吊装路径已经开始走完整姿态链路,不再允许悄悄退回旧 `yaw` 方案。 +- 如果完整姿态生成失败,应直接暴露错误,而不是 fallback。 +- 当前已知边界: + - 地面路径播放阶段,真实物体的多轴角度修正还没有完全收稳。 + - 目前稳定可用的是:`Y` 轴转动。 + - `X / Z` 轴在起点静态预览或直线段里可能看起来合理,但路径一旦拐弯,逐帧按宿主世界轴重算会让“俯仰/侧倾”语义发生耦合,表现成不符合现场直觉的侧旋。 + - 因此当前阶段,对地面路径应按“只稳定支持 `Y` 轴转动”使用;`X / Z` 轴播放链问题留待后续专项修复。 +- 当前新增稳定规则(2026-03-25): + - `Ground + 真实物体` 的播放阶段不能再每帧应用完整三维姿态; + - 必须以起点基姿态为基线,只叠加宿主 `up` 轴上的单轴旋转。 +- 当前新增稳定规则(2026-03-25): + - `Ground + 真实物体` 的起点目标点语义仍然是路径跟踪点,不允许修改路径点本身; + - 如真实物体起点落位存在固定偏差,应把补偿施加到物体位置,而不是回写路径点或路径跟踪点语义。 +- 当前新增稳定规则(2026-03-25): + - `Ground + 真实物体` 的补偿不是固定世界矢量; + - 起点测得的偏差必须随当前目标姿态一起旋转后,再参与逐帧位置应用。 +- `YUp` 吊装路径创建主链路已补齐到宿主坐标适配架构: + - 提升、水平移动、下降、终点落地都不能再把世界 `Z` 硬编码成“向上”。 + - 终点必须使用用户最后一次点击的地面点,不能回填起点地面高程。 +- 吊装路径相关的当前稳定约束: + - 路径创建、终点补点、路径点修改、斜线正交化、通行空间识别,必须共用同一套“宿主坐标 -> Canonical -> 宿主坐标”的高度语义。 + - `高度过渡点` 这类自动生成点,本质上仍是吊装路径点,不能在渲染或可视化阶段再退回旧 `ZUp` 假设。 + - 通行空间如果遇到“纯垂直段却从前方空中偏出去一截、变成斜的”,优先检查: + - 垂直段识别是否仍在比较 `dz` + - 低点补物体高度是否仍在直接改 `Z` + - 吊装水平参考方向是否仍在固定 `XY` 平面求 +- 吊装路径这轮重构后,相关职责已经收束到: + - `HoistingCoordinateHelper` + - `PathPlanningManager` + - `AerialPathGenerator` + - `PathPointRenderPlugin` +- 当前规则: + - 吊装“向上”只能通过宿主 `up` 语义表达,不能直接写 `point.Z +/- height` + - 通行空间垂直延伸必须沿宿主 down 方向补物体高度 + - 吊装水平参考方向必须先剔除宿主 up 分量,再在宿主水平面内求方向 +- 吊装路径“设为终点直接结束”如果出现: + - 核心路径点数正确,但路径列表/路径点列表仍显示为 `0` + - 切到别的路径再切回来后才恢复正常 + - 这类问题优先检查 `UIStateManager` 的 UI 队列消费,而不是先怀疑路径数据或坐标系。 +- 本次真实根因已经确认: + - `FinishEditing()` 期间会连续追加 `CurrentRouteChanged / PathPointsListUpdated / RouteGenerated` 等 UI 事件。 + - 旧的 `UIStateManager.ProcessQueuedUpdates()` 只消费“当前这一批”队列项,执行过程中新增的后续事件会滞留。 + - 所以路径数据其实已经完成,只是 UI 直到下一次路径切换时才把滞留事件顺带处理掉。 +- 当前修复原则: + - `UIStateManager` 必须持续消费后续批次,直到 UI 队列真正为空。 + - 不要用“手动切换路径”“先选空再选回”“强行补 OnPropertyChanged”这类 UI 和稀泥方式掩盖队列问题。 + - “设为终点直接结束”和“点结束按钮完成路径”都必须走到同样完整的收尾语义: + - 路径数据完成 + - 当前路径切换完成 + - 路径列表与路径点列表同步完成 +- 动画自然结束时,必须先显式应用最后一帧姿态,再进入后续碰撞检测/恢复链路。 + - 否则会出现: + - 播放停下时视觉上离终点还差一点 + - 碰撞检测开始前又被后续姿态应用“往前补一下” +- 当前播放链路已经统一: + - 逐帧播放和动画结束落点都复用同一套姿态应用入口 + - 地面/吊装路径如果缺少完整姿态,应直接报错,不能回退旧 `yaw` +- `Rail / Ground / Hoisting` 终点诊断日志的比较基准必须是: + - 该路径类型真实使用的“期望跟踪点/几何中心” + - 不能再把地面接触点或终点锚点直接拿来和动画跟踪中心比较 + +### 4.6 聚焦 / 视点 + +- `ViewpointHelper` 的聚焦/俯视逻辑不能再把世界 `Z` 硬编码成“上方向”。 +- 在 `YUp` 模型里: + - 相机位置必须沿宿主 `HostUpVector` 偏移 + - 屏幕上方向也必须通过宿主坐标适配得到 +- 如果继续写死: + - `cameraPosition = (x, y, z + distance)` + - `upVector = (0, 1, 0)` + - 那么 `YUp` 模型下聚焦就会跑到侧视图。 +- 当前原则: + - 聚焦输入/输出使用宿主坐标 +- 方向语义通过 `HostCoordinateAdapter` 明确适配 +- 不要在视点辅助逻辑里直接假设宿主一定是 `ZUp` + +### 4.7 Rail 角度修正 + +- `Rail` 的“调整角度”和 `Ground / Hoisting` 不是同一种实现语义。 +- `Ground / Hoisting` 当前语义: + - 在内部 `Canonical` 坐标系里,绕统一 `CanonicalUp` 做水平角度修正。 +- `Rail` 当前正确语义: + - 角度修正必须先作用在构件局部轴约定上,等价于“CAD 原位局部预旋转” + - 然后再进入 `Rail` 的切向/法向姿态求解 + - 不能在物体已经落到起点后,再在宿主空间对最终 pose 补一个世界轴旋转 +- 当前实现链路已经收束到: + - `CanonicalRailPoseBuilder` + - `RailPathPoseHelper` + - `PathAnimationManager` +- 当前规则: + - `Rail 0°` 时,必须严格保持当前稳定基线,不允许改变既有 `Rail` 姿态 + - `Rail` 有角度修正时,对齐到路径切向的是“修正后的 local forward” + - `Rail` 路径切向/法向框架本身不能因为角度修正被改坏 + - `Rail` 通行空间和真实物体姿态必须共享同一套角度语义,不能一个改局部 footprint、一个改世界 pose +- 调试优先级: + 1. 先验证 `0°` 基线是否保持不变 + 2. 再验证修正后 `forward` 是否仍沿 rail 切向 + 3. 最后再看 Navisworks 应用层是否正确按三步增量姿态落位 + +### 4.7.1 Rail 平移模式 + +- `Rail` 的“平移”不是重新按轨上/轨下求一套新的安装姿态。 +- 当前稳定语义: + - 终点箱体当前原始位姿是唯一真值; + - 先计算终点箱体相对“路径终点参考点”的固定残差; + - 再把这份残差搬运到路径起点; + - 动画全程保持这套终点原始姿态和固定残差。 +- 当前规则: + - 不能把“平移模式”误实现成重新按 `RailMountMode` 解目标 pose; + - 不能锁到“参考姿态”而丢掉终点箱体当前真实几何姿态; + - 终点保位误差如果异常,优先检查“残差是相对终点参考点采样,还是误相对起点参考点采样”。 + +### 4.8 Rail 真实物体必须复用统一姿态解释层 + +- `Rail` 不应再单独退回默认 `PositiveX / PositiveY` 轴约定。 +- 当前稳定原则: + - `Ground / Hoisting / Rail` 三类路径的真实物体,必须先共用同一个“真实参考姿态解释层” + - 路径类型的差异只体现在目标路径框架: + - `Ground / Hoisting`:`up = 宿主 up` + - `Rail`:`up = rail 法向 / preferred normal` +- 当前 `Rail` 真实物体稳定链路: + 1. fragment 代表姿态 + 2. `Fragment默认Up` 解释后的真实参考姿态 + 3. `Rail` 路径框架(切向 / 法向) + 4. 宿主 `X/Y/Z` 角度修正 +- 不要再让 `Rail` 真实物体单独维护一套与 `Ground / Hoisting` 不同的对象姿态来源。 + +### 4.9 Rail 真实物体的通行空间与路径偏移 + +- `Rail` 真实物体的通行空间、起点中心偏移、逐帧法向偏移,必须共享“最终姿态”的同一套尺寸语义。 +- 当前稳定规则: + - 不能只根据“宿主轴角度修正”单独投影尺寸 + - 必须同时吃到: + - `Rail` 基姿态 + - 宿主 `X/Y/Z` 角度修正后的最终姿态 +- 否则会出现典型错误: + - 物体本体姿态正确,但起点偏离路径 + - 单轴旋转时通行空间看起来对,双轴旋转后只跟上一个角度 + - `Rail` 真实物体通行空间与物体本体方向不一致 +- 当前消费规则: + - `AnimationControlViewModel.UpdatePassageSpaceVisualization()` 中,真实物体的 `Rail` 和 `Hoisting` 必须分开消费 + - `Rail` 真实物体不能再复用 `Hoisting` 的法向/分段参数语义 + +### 4.10 Rail 终端安装仿真工作流 + +- 当前真实问题已经确认: + - “创建 Rail 路径”区和“Rail 参数”编辑区之前共用同一套装配状态、命令入口和按钮可用条件; + - 导致新建流程会误吃编辑态校验,或在切换路径后残留旧的装配状态。 +- 当前第一轮稳定修复: + - 新建 Rail 与编辑 Rail 已拆成独立命令入口; + - “找端面”在新建 Rail 时不再要求先选中一条现有 Rail 路径; + - 新建区的“选安装点”不再受 `IsRailRouteSelected` 编辑态条件限制; + - 安装点拾取完成后,拾取点、安装点、安装参考面、中心线必须保留可视化,不能在收尾时被一并清掉; + - “隐藏辅助线”必须同时清理: + - 参考杆与锚点 + - 球心到光轴参考点基准线 + - 端面分析可视化 + - 安装点、安装中心线、安装参考面 + - 新建区和编辑区都必须有明确的“取消装配”; + - “路径编辑 -> 结束”在 Rail 装配流程活跃时,也必须能触发同一条取消/清理链路; + - 切换路径时必须自动清空旧的 Rail 装配上下文,不能把旧状态带到下一条路径。 +- 当前仍要记住: + - 这块只是“第一轮工作流拆分”,还不是彻底的数据结构重构; + - 如果后续继续扩 Rail 终端安装仿真,优先方向应是把“创建态上下文”和“编辑态上下文”进一步拆成独立 context,而不是继续在 ViewModel 顶层堆共享字段和布尔分支。 + +### 4.11 吊装路径角度调整 + +- 当前真实根因已经确认: + - 吊装真实物体在“起点调整角度后生成动画”时,如果基姿态缓存不跟着角度修正一起重建,就会出现: + - 动画里没有沿用调整后的角度; + - 甚至被错误立起或只抬高一点的异常现象。 +- 当前稳定规则: + - 吊装路径必须缓存“真实起点基姿态 + 起始 yaw”; + - 起点预览、动画生成、播放前重置,都要复用同一份基线; + - 只要角度修正变化,就必须清掉旧缓存并重新 `MoveObjectToPathStart()`; + - 清除物体或重新选择物体时,角度修正状态必须归零,不允许继承上一轮对象的角度状态。 + +## 5. 当前保留的日志策略 + +- 保留: + - 终点诊断 + - 保存/恢复姿态 + - 关键碰撞恢复 + - 虚拟物体应用后中心/偏差 + - 真实物体地面路径: + - `[路径起点诊断]` + - `[路径起点补偿]` + - `[Ground路径补偿]` +- 已降级或删除: + - 大量重复逐帧宿主姿态轴日志 + - 虚拟物体 `Transform` 即时读回日志(容易误导) + - `Hoisting` 真实物体起点姿态与平面前进轴的刷屏 `Info` 日志,已降为 `Debug` + +## 6. 最近关键提交 + +- `c3b103c` `Add explicit exit for rail assembly workflow` +- `c974469` `Separate rail creation and edit assembly flows` +- `d4c49fc` `Stabilize hoisting pose adjustment flow` +- `cb56737` `Preserve terminal pose when translating rail objects to path start` + +## 7. 当前 Navisworks 变换 API 结论 + +- `ModelItem.Transform` + - 只表示原始设计文件变换; + - 不反映 `OverridePermanentTransform` 后的当前显示姿态。 +- `ModelGeometry.ActiveTransform` + - 是当前几何实际生效的变换; + - 当前项目里,凡是要读“当前姿态/当前位置”,优先使用这一层。 +- `ModelGeometry` + - 关键四层语义: + - `OriginalTransform` + - `PermanentOverrideTransform` + - `PermanentTransform` + - `ActiveTransform` +- `OverridePermanentTransform(...)` + - 官方语义是增量变换; + - 不是“直接设成最终世界姿态”。 +- `ResetPermanentTransform(...)` + - 清掉的是永久增量层; + - 不会修改原始设计文件变换。 + +## 8. 当前还值得继续观察的点 + +- 空轨路径里仍有一个旧 warning: + - `[空轨] 双轨几何中心线提取失败,退回 OBB 主轴中线` +- 这与当前 `YUp` 地面路径问题无关,但后续可以单独处理。 + +## 9. 下一步建议 + +- 继续按“先框架、先测试、后接业务”的方式推进。 +- 新问题优先: + 1. 先看日志 + 2. 若日志不足,先补日志 + 3. 再补针对性的回归测试 + 4. 最后再改业务代码 +- `Rail` 终端安装仿真的下一轮优化优先级: + 1. 把创建态/编辑态从 ViewModel 顶层共享字段继续拆成独立 context + 2. 为“取消装配 / 切换路径 / 中途退出后重新开始”补单元测试或更轻量的状态回归验证 + 3. 再考虑是否需要把创建区与编辑区在 UI 上做更明确的视觉分隔 + +## 10. 当前阶段的工作边界 + +- 不要回到“直接补旧 `yaw` 链路”的方式。 +- 不要再增加隐藏错误的 fallback。 +- 不要再混淆: + - 外部宿主坐标 + - 内部 `Canonical` 坐标 + - 业务参考点 + - 模型局部轴约定 diff --git a/doc/working/test_path_import.json b/doc/working/test_path_import.json deleted file mode 100644 index 63dfbc8..0000000 --- a/doc/working/test_path_import.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "PathPlanningData": { - "version": "1.0", - "generator": "NavisworksTransport", - "timestamp": "2025-11-07T15:30:00", - "ProjectInfo": { - "name": "测试物流路径", - "description": "用于测试JSON导入导出功能", - "units": "meters", - "coordinateSystem": "Global" - }, - "Routes": [ - { - "id": "route001", - "name": "测试路径1", - "description": "从入口到仓库的路径", - "totalLength": 45.6, - "objectLimits": { - "maxLength": 2.0, - "maxWidth": 1.5, - "maxHeight": 2.0, - "safetyMargin": 0.25 - }, - "gridSize": 0.5, - "created": "2025-11-07T10:00:00", - "points": [ - { - "id": "point001", - "name": "起点", - "type": "StartPoint", - "index": 0, - "x": 0.0, - "y": 0.0, - "z": 0.0, - "created": "2025-11-07T10:00:00" - }, - { - "id": "point002", - "name": "路径点1", - "type": "WayPoint", - "index": 1, - "x": 10.0, - "y": 0.0, - "z": 0.0, - "created": "2025-11-07T10:00:01" - }, - { - "id": "point003", - "name": "路径点2", - "type": "WayPoint", - "index": 2, - "x": 20.0, - "y": 5.0, - "z": 0.0, - "created": "2025-11-07T10:00:02" - }, - { - "id": "point004", - "name": "终点", - "type": "EndPoint", - "index": 3, - "x": 30.0, - "y": 10.0, - "z": 0.0, - "created": "2025-11-07T10:00:03" - } - ] - }, - { - "id": "route002", - "name": "测试路径2", - "description": "从仓库到出口的路径", - "totalLength": 32.4, - "objectLimits": { - "maxLength": 2.0, - "maxWidth": 1.5, - "maxHeight": 2.0, - "safetyMargin": 0.25 - }, - "gridSize": 0.5, - "created": "2025-11-07T10:05:00", - "points": [ - { - "id": "point005", - "name": "起点", - "type": "StartPoint", - "index": 0, - "x": 30.0, - "y": 10.0, - "z": 0.0, - "created": "2025-11-07T10:05:00" - }, - { - "id": "point006", - "name": "路径点1", - "type": "WayPoint", - "index": 1, - "x": 25.0, - "y": 8.0, - "z": 0.0, - "created": "2025-11-07T10:05:01" - }, - { - "id": "point007", - "name": "终点", - "type": "EndPoint", - "index": 2, - "x": 20.0, - "y": 5.0, - "z": 0.0, - "created": "2025-11-07T10:05:02" - } - ] - } - ] - } -} diff --git a/resources/TransportRibbon.xaml b/resources/TransportRibbon.xaml new file mode 100644 index 0000000..d0188d7 --- /dev/null +++ b/resources/TransportRibbon.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/resources/TransportRibbon_16.png b/resources/TransportRibbon_16.png new file mode 100644 index 0000000..7d75f30 Binary files /dev/null and b/resources/TransportRibbon_16.png differ diff --git a/resources/TransportRibbon_32.png b/resources/TransportRibbon_32.png new file mode 100644 index 0000000..5118b17 Binary files /dev/null and b/resources/TransportRibbon_32.png differ diff --git a/resources/default_config.toml b/resources/default_config.toml index 96529e8..8825899 100644 --- a/resources/default_config.toml +++ b/resources/default_config.toml @@ -26,6 +26,19 @@ default_path_turn_radius = 2.5 # 圆弧采样步长(米)- 推荐值:0.02-0.1 arc_sampling_step = 0.05 +# 吊装路径自动视角距离(米) +# 默认为近距离低侧视 +hoisting_view_distance_meters = 6.0 + +# 吊装路径自动视角仰角(度) +hoisting_view_elevation_degrees = 30.0 + +# Rail 路径自动视角距离(米) +rail_view_distance_meters = 6.0 + +# Rail 路径自动视角仰角(度) +rail_view_elevation_degrees = 30.0 + [visualization] # 地图边距比例(0-1之间) margin_ratio = 0.1 @@ -65,16 +78,16 @@ speed_limit_meters_per_second = 0.8 width_limit_meters = 3.0 [coordinate_system] -# 坐标系类型 -# 可选值: "AutoDetect", "ZUp", "YUp" -# AutoDetect: 自动检测(推荐) -# ZUp: 强制使用 Z-Up 坐标系(Navisworks 默认) -# YUp: 强制使用 Y-Up 坐标系(常见于 Revit 导出) -type = "AutoDetect" - -# 自定义物流类别 -# 用于扩展内置类别,只需填写类别名称 -# + # 坐标系类型 + # 可选值: "AutoDetect", "ZUp", "YUp" + # AutoDetect: 自动检测(推荐) + # ZUp: 强制使用 Z-Up 坐标系(Navisworks 默认) + # YUp: 强制使用 Y-Up 坐标系(常见于 Revit 导出) + type = "AutoDetect" + + # 自定义物流类别 + # 用于扩展内置类别,只需填写类别名称 + # # 示例: # [[custom_category]] # name = "狭窄通道" diff --git a/resources/unit_cube.nwc b/resources/unit_cube.nwc index e6027ce..81352de 100644 Binary files a/resources/unit_cube.nwc and b/resources/unit_cube.nwc differ diff --git a/resources/unit_cylinder.nwc b/resources/unit_cylinder.nwc new file mode 100644 index 0000000..8fbca10 Binary files /dev/null and b/resources/unit_cylinder.nwc differ diff --git a/resources/unit_cylinder.obj b/resources/unit_cylinder.obj new file mode 100644 index 0000000..3b4e1ca --- /dev/null +++ b/resources/unit_cylinder.obj @@ -0,0 +1,149 @@ +# unit cylinder for assembly reference rod +# dimensions: length 1.0 along +X, diameter 1.0 +# centered at origin +# when Navisworks imports OBJ as centimeters, generated NWC becomes 0.01m base size +o unit_cylinder +v -0.500000 0.000000 0.000000 +v 0.500000 0.000000 0.000000 +v -0.500000 0.500000 0.000000 +v -0.500000 0.482963 0.129410 +v -0.500000 0.433013 0.250000 +v -0.500000 0.353553 0.353553 +v -0.500000 0.250000 0.433013 +v -0.500000 0.129410 0.482963 +v -0.500000 0.000000 0.500000 +v -0.500000 -0.129410 0.482963 +v -0.500000 -0.250000 0.433013 +v -0.500000 -0.353553 0.353553 +v -0.500000 -0.433013 0.250000 +v -0.500000 -0.482963 0.129410 +v -0.500000 -0.500000 0.000000 +v -0.500000 -0.482963 -0.129410 +v -0.500000 -0.433013 -0.250000 +v -0.500000 -0.353553 -0.353553 +v -0.500000 -0.250000 -0.433013 +v -0.500000 -0.129410 -0.482963 +v -0.500000 0.000000 -0.500000 +v -0.500000 0.129410 -0.482963 +v -0.500000 0.250000 -0.433013 +v -0.500000 0.353553 -0.353553 +v -0.500000 0.433013 -0.250000 +v -0.500000 0.482963 -0.129410 +v 0.500000 0.500000 0.000000 +v 0.500000 0.482963 0.129410 +v 0.500000 0.433013 0.250000 +v 0.500000 0.353553 0.353553 +v 0.500000 0.250000 0.433013 +v 0.500000 0.129410 0.482963 +v 0.500000 0.000000 0.500000 +v 0.500000 -0.129410 0.482963 +v 0.500000 -0.250000 0.433013 +v 0.500000 -0.353553 0.353553 +v 0.500000 -0.433013 0.250000 +v 0.500000 -0.482963 0.129410 +v 0.500000 -0.500000 0.000000 +v 0.500000 -0.482963 -0.129410 +v 0.500000 -0.433013 -0.250000 +v 0.500000 -0.353553 -0.353553 +v 0.500000 -0.250000 -0.433013 +v 0.500000 -0.129410 -0.482963 +v 0.500000 0.000000 -0.500000 +v 0.500000 0.129410 -0.482963 +v 0.500000 0.250000 -0.433013 +v 0.500000 0.353553 -0.353553 +v 0.500000 0.433013 -0.250000 +v 0.500000 0.482963 -0.129410 +f 3 26 27 +f 3 27 4 +f 4 27 28 +f 4 28 5 +f 5 28 29 +f 5 29 6 +f 6 29 30 +f 6 30 7 +f 7 30 31 +f 7 31 8 +f 8 31 32 +f 8 32 9 +f 9 32 33 +f 9 33 10 +f 10 33 34 +f 10 34 11 +f 11 34 35 +f 11 35 12 +f 12 35 36 +f 12 36 13 +f 13 36 37 +f 13 37 14 +f 14 37 38 +f 14 38 15 +f 15 38 39 +f 15 39 16 +f 16 39 40 +f 16 40 17 +f 17 40 41 +f 17 41 18 +f 18 41 42 +f 18 42 19 +f 19 42 43 +f 19 43 20 +f 20 43 44 +f 20 44 21 +f 21 44 45 +f 21 45 22 +f 22 45 46 +f 22 46 23 +f 23 46 47 +f 23 47 24 +f 24 47 48 +f 24 48 25 +f 25 48 49 +f 25 49 26 +f 1 4 3 +f 1 5 4 +f 1 6 5 +f 1 7 6 +f 1 8 7 +f 1 9 8 +f 1 10 9 +f 1 11 10 +f 1 12 11 +f 1 13 12 +f 1 14 13 +f 1 15 14 +f 1 16 15 +f 1 17 16 +f 1 18 17 +f 1 19 18 +f 1 20 19 +f 1 21 20 +f 1 22 21 +f 1 23 22 +f 1 24 23 +f 1 25 24 +f 1 26 25 +f 1 3 26 +f 2 27 28 +f 2 28 29 +f 2 29 30 +f 2 30 31 +f 2 31 32 +f 2 32 33 +f 2 33 34 +f 2 34 35 +f 2 35 36 +f 2 36 37 +f 2 37 38 +f 2 38 39 +f 2 39 40 +f 2 40 41 +f 2 41 42 +f 2 42 43 +f 2 43 44 +f 2 44 45 +f 2 45 46 +f 2 46 47 +f 2 47 48 +f 2 48 49 +f 2 49 26 +f 2 26 27 diff --git a/run-ground-virtual-collision-test.bat b/run-ground-virtual-collision-test.bat new file mode 100644 index 0000000..e38e421 --- /dev/null +++ b/run-ground-virtual-collision-test.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-ground-virtual-collision-test.ps1" %* + +if errorlevel 1 exit /b 1 +exit /b 0 diff --git a/run-unit-tests.bat b/run-unit-tests.bat new file mode 100644 index 0000000..64220ca --- /dev/null +++ b/run-unit-tests.bat @@ -0,0 +1,50 @@ +@echo off +setlocal + +echo Building NavisworksTransport.UnitTests... + +set MSBUILD_PATH="C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" +if not exist %MSBUILD_PATH% ( + set MSBUILD_PATH="C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" +) +if not exist %MSBUILD_PATH% ( + echo MSBuild not found. Please install Visual Studio 2022 or Build Tools. + exit /b 1 +) + +set VSTEST_PATH="C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe" +if not exist %VSTEST_PATH% ( + set VSTEST_PATH="C:\Program Files\Microsoft Visual Studio\2022\Professional\Common7\IDE\Extensions\TestPlatform\vstest.console.exe" +) +if not exist %VSTEST_PATH% ( + echo vstest.console.exe not found. Please install Visual Studio 2022 test tools. + exit /b 1 +) + +set TEST_ADAPTER_PATH="packages\MSTest.TestAdapter.3.0.4\build\net462" +if not exist %TEST_ADAPTER_PATH% ( + echo MSTest adapter path not found: %TEST_ADAPTER_PATH% + exit /b 1 +) + +set NAVISWORKS_PATH=C:\Program Files\Autodesk\Navisworks Manage 2026 +if not exist "%NAVISWORKS_PATH%\Autodesk.Navisworks.Api.dll" ( + echo Navisworks API path not found: %NAVISWORKS_PATH% + exit /b 1 +) + +set PATH=%NAVISWORKS_PATH%;%PATH% + +%MSBUILD_PATH% "NavisworksTransport.UnitTests.csproj" /p:Configuration=Release /p:Platform=x64 /verbosity:minimal +if errorlevel 1 ( + echo Unit test build failed! + exit /b 1 +) + +%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% /TestCaseFilter:"TestCategory!=NavisworksIntegration" +if errorlevel 1 ( + echo Unit tests failed! + exit /b 1 +) + +echo Unit tests passed! diff --git a/scripts/Fix-RailPreferredNormals.ps1 b/scripts/Fix-RailPreferredNormals.ps1 new file mode 100644 index 0000000..990809f --- /dev/null +++ b/scripts/Fix-RailPreferredNormals.ps1 @@ -0,0 +1,213 @@ +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [switch]$Recurse +) + +<# +用法示例 + +1. 批量修复当前目录下所有 XML 路径文件 +powershell -ExecutionPolicy Bypass -File "C:\Users\Tellme\apps\NavisworksTransport-rail-mount-modes\scripts\Fix-RailPreferredNormals.ps1" -Path "C:\Users\Tellme\Documents\NavisworksTransport\模型" + +2. 递归修复目录及其子目录中的所有 XML 路径文件 +powershell -ExecutionPolicy Bypass -File "C:\Users\Tellme\apps\NavisworksTransport-rail-mount-modes\scripts\Fix-RailPreferredNormals.ps1" -Path "C:\Users\Tellme\Documents\NavisworksTransport\模型" -Recurse + +3. 只修复单个 XML 路径文件 +powershell -ExecutionPolicy Bypass -File "C:\Users\Tellme\apps\NavisworksTransport-rail-mount-modes\scripts\Fix-RailPreferredNormals.ps1" -Path "C:\Users\Tellme\Documents\NavisworksTransport\模型\副本_人工_0331_133243.xml" + +脚本规则 + +- 只处理 pathType="Rail" 且 railMountMode="OverRail" 的路径 +- 只修复 railPreferredNormalY < 0 的路径 +- 修复方式为将 railPreferredNormalX / Y / Z 三个分量一起取反 +- 原文件会被直接覆盖,同时自动保留一个同名 .bak 备份文件 +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Format-NormalValue { + param( + [Parameter(Mandatory = $true)] + [double]$Value + ) + + return $Value.ToString("0.###", [System.Globalization.CultureInfo]::InvariantCulture) +} + +function Test-ParseableDoubleText { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + + [ref]$Value + ) + + return [double]::TryParse( + $Text, + [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowLeadingSign, + [System.Globalization.CultureInfo]::InvariantCulture, + $Value) +} + +function Update-RouteNode { + param( + [Parameter(Mandatory = $true)] + [System.Xml.XmlElement]$RouteNode, + + [Parameter(Mandatory = $true)] + [string]$SourcePath + ) + + if ($RouteNode.GetAttribute("pathType") -ne "Rail") { + return $false + } + + if ($RouteNode.GetAttribute("railMountMode") -ne "OverRail") { + return $false + } + + $xText = $RouteNode.GetAttribute("railPreferredNormalX") + $yText = $RouteNode.GetAttribute("railPreferredNormalY") + $zText = $RouteNode.GetAttribute("railPreferredNormalZ") + + if ([string]::IsNullOrWhiteSpace($xText) -or + [string]::IsNullOrWhiteSpace($yText) -or + [string]::IsNullOrWhiteSpace($zText)) { + return $false + } + + $x = 0.0 + $y = 0.0 + $z = 0.0 + if (-not (Test-ParseableDoubleText -Text $xText -Value ([ref]$x)) -or + -not (Test-ParseableDoubleText -Text $yText -Value ([ref]$y)) -or + -not (Test-ParseableDoubleText -Text $zText -Value ([ref]$z))) { + throw "文件 '$SourcePath' 中存在无法解析的 railPreferredNormal 值。" + } + + if ($y -ge 0) { + return $false + } + + $RouteNode.SetAttribute("railPreferredNormalX", (Format-NormalValue -Value (-$x))) + $RouteNode.SetAttribute("railPreferredNormalY", (Format-NormalValue -Value (-$y))) + $RouteNode.SetAttribute("railPreferredNormalZ", (Format-NormalValue -Value (-$z))) + return $true +} + +function Save-XmlDocument { + param( + [Parameter(Mandatory = $true)] + [xml]$Document, + + [Parameter(Mandatory = $true)] + [string]$TargetPath + ) + + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Indent = $true + $settings.IndentChars = " " + $settings.Encoding = New-Object System.Text.UTF8Encoding($false) + $settings.NewLineChars = "`r`n" + $settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace + + $writer = [System.Xml.XmlWriter]::Create($TargetPath, $settings) + try { + $Document.Save($writer) + } + finally { + $writer.Dispose() + } +} + +function Update-XmlFile { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath + ) + + [xml]$xml = Get-Content -LiteralPath $FilePath -Raw + $routeNodes = @($xml.SelectNodes("//*[local-name()='Route']")) + if ($null -eq $routeNodes -or $routeNodes.Count -eq 0) { + return [pscustomobject]@{ + FilePath = $FilePath + Modified = $false + RouteCount = 0 + } + } + + $modifiedRouteCount = 0 + foreach ($routeNode in $routeNodes) { + if (Update-RouteNode -RouteNode $routeNode -SourcePath $FilePath) { + $modifiedRouteCount++ + } + } + + if ($modifiedRouteCount -gt 0) { + $backupPath = "$FilePath.bak" + if ($PSCmdlet.ShouldProcess($FilePath, "修复 railPreferredNormal 并写回 XML")) { + if (-not (Test-Path -LiteralPath $backupPath)) { + Copy-Item -LiteralPath $FilePath -Destination $backupPath + } + + Save-XmlDocument -Document $xml -TargetPath $FilePath + } + } + + return [pscustomobject]@{ + FilePath = $FilePath + Modified = ($modifiedRouteCount -gt 0) + RouteCount = $modifiedRouteCount + } +} + +$resolvedPath = Resolve-Path -LiteralPath $Path +$item = Get-Item -LiteralPath $resolvedPath + +$files = @() +if ($item.PSIsContainer) { + $childParams = @{ + LiteralPath = $item.FullName + File = $true + } + if ($Recurse) { + $childParams["Recurse"] = $true + } + + $files = @(Get-ChildItem @childParams | Where-Object { $_.Extension -ieq ".xml" }) +} +else { + $files = @($item) +} + +if ($files.Count -eq 0) { + Write-Host "未找到可处理的 XML 文件。" + exit 0 +} + +$results = @() +foreach ($file in $files) { + $results += Update-XmlFile -FilePath $file.FullName +} + +$results = @($results) +$modifiedFiles = @($results | Where-Object { $_.Modified }) +$modifiedRoutes = 0 +if ($modifiedFiles.Count -gt 0) { + $measure = $modifiedFiles | Measure-Object -Property RouteCount -Sum + if ($null -ne $measure -and $null -ne $measure.Sum) { + $modifiedRoutes = [int]$measure.Sum + } +} + +Write-Host ("扫描文件: {0}" -f $results.Count) +Write-Host ("修改文件: {0}" -f $modifiedFiles.Count) +Write-Host ("修复路径: {0}" -f $modifiedRoutes) + +foreach ($result in $modifiedFiles) { + Write-Host ("已修复: {0} (路径数={1})" -f $result.FilePath, $result.RouteCount) +} diff --git a/scripts/run-ground-virtual-collision-test.ps1 b/scripts/run-ground-virtual-collision-test.ps1 new file mode 100644 index 0000000..d4a48c9 --- /dev/null +++ b/scripts/run-ground-virtual-collision-test.ps1 @@ -0,0 +1,154 @@ +[CmdletBinding()] +param( + [string]$ModelPath, + [int]$ServiceStartupTimeoutSeconds = 90, + [int]$TestTimeoutSeconds = 240 +) + +$ErrorActionPreference = 'Stop' + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDirectory +$startScriptPath = Join-Path $scriptDirectory 'start-navisworks.ps1' +$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json' +$testApiBaseUrl = 'http://127.0.0.1:18777' + +function Resolve-DefaultModelPath { + if (-not (Test-Path $defaultsFilePath)) { + return $null + } + + $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json + $defaultModelPath = [string]$defaults.defaultModelPath + if ([string]::IsNullOrWhiteSpace($defaultModelPath)) { + return $null + } + + return [System.IO.Path]::GetFullPath($defaultModelPath) +} + +function Wait-TestApiReady { + param( + [string]$BaseUrl, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + try { + $pingResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/ping') -TimeoutSec 10 + if ($pingResponse.ok) { + return + } + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds 1 + } + + if ([string]::IsNullOrWhiteSpace($lastError)) { + throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。" + } + + throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError" +} + +function Wait-GroundRouteReady { + param( + [string]$BaseUrl, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + try { + $routesResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/routes') -TimeoutSec 10 + if ($routesResponse.ok -and $routesResponse.data -and $routesResponse.data.totalRouteCount -gt 0) { + $groundRoute = $routesResponse.data.suggestedAutoTestRoutes.ground + if (-not [string]::IsNullOrWhiteSpace([string]$groundRoute)) { + return [string]$groundRoute + } + } + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds 1 + } + + if ([string]::IsNullOrWhiteSpace($lastError)) { + throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。" + } + + throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError" +} + +function Save-JsonResult { + param( + [object]$Payload + ) + + $logRoot = Join-Path $env:ProgramData 'Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\test-automation' + New-Item -ItemType Directory -Path $logRoot -Force | Out-Null + + $fileName = 'ground-virtual-collision-test-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.json' + $outputPath = Join-Path $logRoot $fileName + $json = $Payload | ConvertTo-Json -Depth 16 + [System.IO.File]::WriteAllText($outputPath, $json, [System.Text.UTF8Encoding]::new($false)) + return $outputPath +} + +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = Resolve-DefaultModelPath +} + +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = [System.IO.Path]::GetFullPath($ModelPath) + if (-not (Test-Path $ModelPath)) { + throw "模型文件不存在: $ModelPath" + } +} + +Write-Host '启动 Navisworks...' +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + & $startScriptPath +} +else { + & $startScriptPath $ModelPath +} + +Write-Host '等待测试 HTTP 服务就绪...' +Wait-TestApiReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds + +Write-Host '等待默认地面测试路径就绪...' +$groundRouteName = Wait-GroundRouteReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds + +Write-Host ('切换到默认地面测试路径: {0}' -f $groundRouteName) +$selectRouteResponse = Invoke-RestMethod -Method Post -Uri ($testApiBaseUrl + '/api/test/select-default-route?pathType=Ground') -TimeoutSec 15 +if (-not $selectRouteResponse.ok) { + throw '切换默认地面测试路径失败。' +} + +Write-Host '执行地面虚拟物体碰撞测试...' +$requestUrl = $testApiBaseUrl + "/api/test/run-ground-collision-test?useVirtualObject=true&timeoutSeconds=$TestTimeoutSeconds" +$testResult = Invoke-RestMethod -Method Post -Uri $requestUrl -TimeoutSec ($TestTimeoutSeconds + 30) + +$outputFilePath = Save-JsonResult -Payload $testResult + +Write-Host ('结果已保存: {0}' -f $outputFilePath) +if ($testResult.ok -and $testResult.data) { + Write-Host ('路径: {0}' -f $testResult.data.route.name) + Write-Host ('动画状态: {0}' -f $testResult.data.animation.currentState) + Write-Host ('检测记录ID: {0}' -f $testResult.data.animation.detectionRecordId) + if ($testResult.data.report) { + Write-Host ('碰撞数: {0}' -f $testResult.data.report.totalCollisions) + Write-Host ('截图数: {0}' -f $testResult.data.report.screenshotCount) + } +} diff --git a/scripts/start-navisworks.ps1 b/scripts/start-navisworks.ps1 new file mode 100644 index 0000000..6a150b5 --- /dev/null +++ b/scripts/start-navisworks.ps1 @@ -0,0 +1,167 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$ModelPath +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDirectory +$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json' + +function Resolve-RoamerPath { + $candidates = New-Object System.Collections.Generic.List[string] + $appPathKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe' + ) + + foreach ($key in $appPathKeys) { + try { + $value = (Get-ItemProperty -Path $key -ErrorAction Stop).'(default)' + if (-not [string]::IsNullOrWhiteSpace($value)) { + $candidates.Add($value) + } + } + catch { + } + } + + $wellKnownPaths = @( + (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Manage 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Manage 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Simulate 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Simulate 2026\Roamer.exe') + ) | Where-Object { $_ } + + foreach ($path in $wellKnownPaths) { + $candidates.Add($path) + } + + foreach ($candidate in $candidates | Select-Object -Unique) { + if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + + throw '找不到 Roamer.exe。请确认 Navisworks Manage 2026 已安装。' +} + +function Resolve-DefaultModelPath { + if (-not (Test-Path $defaultsFilePath)) { + return $null + } + + try { + $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json + $defaultModelPath = [string]$defaults.defaultModelPath + if ([string]::IsNullOrWhiteSpace($defaultModelPath)) { + return $null + } + + $resolvedPath = [System.IO.Path]::GetFullPath($defaultModelPath) + if (-not (Test-Path $resolvedPath)) { + throw "默认模型文件不存在: $resolvedPath" + } + + return $resolvedPath + } + catch { + throw "读取默认测试配置失败: $($_.Exception.Message)" + } +} + +function Dismiss-RecoveryPromptIfPresent { + param( + [int]$TimeoutSeconds = 20 + ) + + Add-Type -AssemblyName System.Windows.Forms | Out-Null + $shell = New-Object -ComObject WScript.Shell + $windowTitles = @( + '是否从自动保存重新载入?', + '是否从自动保存重新载入?' + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + foreach ($windowTitle in $windowTitles) { + try { + if ($shell.AppActivate($windowTitle)) { + Start-Sleep -Milliseconds 300 + [System.Windows.Forms.SendKeys]::SendWait('%N') + Write-Host '已自动选择“否”以跳过自动保存恢复窗口。' + return + } + } + catch { + } + } + + Start-Sleep -Milliseconds 500 + } +} + +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = Resolve-DefaultModelPath +} + +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = [System.IO.Path]::GetFullPath($ModelPath) + if (-not (Test-Path $ModelPath)) { + throw "模型文件不存在: $ModelPath" + } +} + +$roamerPath = Resolve-RoamerPath +$existingProcess = Get-Process Roamer -ErrorAction SilentlyContinue | Select-Object -First 1 +$env:TRANSPORTPLUGIN_TEST_AUTOMATION = '1' + +if ($existingProcess) { + Write-Host ("Navisworks 已在运行,PID={0}" -f $existingProcess.Id) + if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + Start-Process -FilePath $roamerPath -ArgumentList @($ModelPath) | Out-Null + Write-Host ("已请求 Navisworks 打开模型: {0}" -f $ModelPath) + } + + Dismiss-RecoveryPromptIfPresent + + Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。' + exit 0 +} + +$arguments = @() +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $arguments += $ModelPath +} + +$startProcessParams = @{ + FilePath = $roamerPath + PassThru = $true +} + +if ($arguments.Count -gt 0) { + $startProcessParams.ArgumentList = $arguments +} + +$process = Start-Process @startProcessParams +Write-Host ("Navisworks 启动中,PID={0}" -f $process.Id) + +$deadline = (Get-Date).AddSeconds(30) +do { + Start-Sleep -Milliseconds 500 + $running = Get-Process -Id $process.Id -ErrorAction SilentlyContinue +} while (-not $running -and (Get-Date) -lt $deadline) + +if (-not $running) { + throw 'Navisworks 启动超时,30 秒内未检测到 Roamer.exe 进程。' +} + +Dismiss-RecoveryPromptIfPresent + +Write-Host ("Navisworks 已启动: {0}" -f $roamerPath) +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + Write-Host ("已打开模型参数: {0}" -f $ModelPath) +} + +Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。' diff --git a/scripts/test-automation.defaults.json b/scripts/test-automation.defaults.json new file mode 100644 index 0000000..b5d5620 --- /dev/null +++ b/scripts/test-automation.defaults.json @@ -0,0 +1,4 @@ +{ + "defaultModelPath": "C:\\Users\\Tellme\\Documents\\NavisworksTransport\\模型\\Floor2_mobile_yup.nwd", + "defaultRouteNames": [] +} diff --git a/src/Commands/GenerateCollisionReportCommand.cs b/src/Commands/GenerateCollisionReportCommand.cs index 68cd8d8..6b737a8 100644 --- a/src/Commands/GenerateCollisionReportCommand.cs +++ b/src/Commands/GenerateCollisionReportCommand.cs @@ -9,6 +9,7 @@ using Autodesk.Navisworks.Api.Clash; using NavisworksTransport.Core; using NavisworksTransport.Core.Animation; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; using NavisworksTransport.UI.WPF.Views; namespace NavisworksTransport.Commands @@ -362,16 +363,16 @@ namespace NavisworksTransport.Commands { UpdateProgress(92, "生成默认路径截图..."); - // 记录截图前虚拟物体实际位置和朝向 + // 记录截图前虚拟物体位置和动画管理器状态。 + // 不读取 ModelItem.Transform 的“实际朝向”,因为 override 后该值不可靠,容易误导排查。 var virtualObject = VirtualObjectManager.Instance.CurrentVirtualObject; if (virtualObject != null) { - var bounds = virtualObject.BoundingBox(); - var actualYaw = ModelItemTransformHelper.GetYawFromTransform(virtualObject.Transform); var pam = PathAnimationManager.GetInstance(); - LogManager.Info(string.Format("[默认截图前] 虚拟物体实际位置: ({0:F2},{1:F2},{2:F2}), 实际朝向: {3:F2}°, PAM记录朝向: {4:F2}°", - bounds.Center.X, bounds.Center.Y, bounds.Min.Z, - actualYaw * 180 / Math.PI, pam.CurrentYaw * 180 / Math.PI)); + var position = GetAnimatedObjectReportedPosition(virtualObject, pam); + LogManager.Info(string.Format("[默认截图前] 虚拟物体位置: ({0:F2},{1:F2},{2:F2}), PAM记录朝向: {3:F2}°, customRotation={4}", + position.X, position.Y, position.Z, + pam.CurrentYaw * 180 / Math.PI, pam.HasTrackedRotation)); } else { @@ -393,16 +394,15 @@ namespace NavisworksTransport.Commands "overview" ); - // 记录截图后虚拟物体实际位置和朝向 + // 记录截图后虚拟物体位置和动画管理器状态。 virtualObject = VirtualObjectManager.Instance.CurrentVirtualObject; if (virtualObject != null) { - var bounds = virtualObject.BoundingBox(); - var actualYaw = ModelItemTransformHelper.GetYawFromTransform(virtualObject.Transform); var pam = PathAnimationManager.GetInstance(); - LogManager.Info(string.Format("[默认截图后] 虚拟物体实际位置: ({0:F2},{1:F2},{2:F2}), 实际朝向: {3:F2}°, PAM记录朝向: {4:F2}°", - bounds.Center.X, bounds.Center.Y, bounds.Min.Z, - actualYaw * 180 / Math.PI, pam.CurrentYaw * 180 / Math.PI)); + var position = GetAnimatedObjectReportedPosition(virtualObject, pam); + LogManager.Info(string.Format("[默认截图后] 虚拟物体位置: ({0:F2},{1:F2},{2:F2}), PAM记录朝向: {3:F2}°, customRotation={4}", + position.X, position.Y, position.Z, + pam.CurrentYaw * 180 / Math.PI, pam.HasTrackedRotation)); } if (screenshotPath != null) @@ -471,8 +471,11 @@ namespace NavisworksTransport.Commands int successCount = 0; // 保存动画物体原始状态(用于截图后恢复) + // 对受 PathAnimationManager 控制的对象,直接复用与 ClashDetective 相同的保存/恢复主链路, + // 避免报告生成额外引入第二套状态语义。 ModelItem animatedObject = null; ModelItemTransformHelper.ObjectStateSnapshot savedObjectState = null; + bool restoreViaAnimationManager = false; // 找到第一个有位置信息的碰撞来获取动画物体 foreach (var c in collisionData.AllCollisions) @@ -480,7 +483,18 @@ namespace NavisworksTransport.Commands if (c.Item1 != null && c.HasPositionInfo) { animatedObject = c.Item1; - savedObjectState = CollisionSceneHelper.SaveAnimatedObjectState(animatedObject); + var pam = PathAnimationManager.GetInstance(); + restoreViaAnimationManager = pam != null && pam.ControlsAnimatedObject(animatedObject); + + if (restoreViaAnimationManager) + { + pam.SaveObjectState(animatedObject); + LogManager.Info(string.Format("已通过动画主链路保存物体状态: {0}", animatedObject.DisplayName)); + } + else + { + savedObjectState = CollisionSceneHelper.SaveAnimatedObjectState(animatedObject); + } break; } } @@ -544,7 +558,19 @@ namespace NavisworksTransport.Commands } // 恢复动画物体到原始状态 - CollisionSceneHelper.RestoreAnimatedObjectState(animatedObject, savedObjectState); + if (animatedObject != null) + { + var pam = PathAnimationManager.GetInstance(); + if (restoreViaAnimationManager && pam != null && pam.ControlsAnimatedObject(animatedObject)) + { + pam.RestoreAnimatedObjectState(animatedObject); + LogManager.Debug(string.Format("已通过动画主链路恢复物体状态: {0}", animatedObject.DisplayName)); + } + else + { + CollisionSceneHelper.RestoreAnimatedObjectState(animatedObject, savedObjectState); + } + } LogManager.Info(string.Format("已生成 {0}/{1} 个碰撞对视角截图", successCount, collisionData.AllCollisions.Count)); } @@ -576,19 +602,21 @@ namespace NavisworksTransport.Commands ShowReport(result); } - // 🔥 自动导出HTML报告 - try + if (!_parameters.ShowDialog) { - string htmlPath = Utils.CollisionReportHtmlGenerator.AutoSaveHtmlReport(result); - if (!string.IsNullOrEmpty(htmlPath)) + try { - LogManager.Info($"HTML报告已自动导出: {htmlPath}"); + string htmlPath = Utils.CollisionReportHtmlGenerator.AutoSaveHtmlReport(result); + if (!string.IsNullOrEmpty(htmlPath)) + { + LogManager.Info($"HTML报告已自动导出: {htmlPath}"); + } + } + catch (Exception ex) + { + LogManager.Error($"自动导出HTML报告失败: {ex.Message}"); + // HTML导出失败不影响报告生成 } - } - catch (Exception ex) - { - LogManager.Error($"自动导出HTML报告失败: {ex.Message}"); - // HTML导出失败不影响报告生成 } } catch (OperationCanceledException) @@ -610,6 +638,22 @@ namespace NavisworksTransport.Commands return PathPlanningResult.Success(result, message); } + private static Point3D GetAnimatedObjectReportedPosition(ModelItem animatedObject, PathAnimationManager pam) + { + if (animatedObject == null) + { + return new Point3D(0, 0, 0); + } + + if (pam != null && pam.ControlsAnimatedObject(animatedObject)) + { + return pam.GetObjectCurrentPosition(animatedObject).Position; + } + + var bounds = animatedObject.BoundingBox(); + return bounds?.Center ?? new Point3D(0, 0, 0); + } + /// @@ -1097,4 +1141,4 @@ namespace NavisworksTransport.Commands } } } -} \ No newline at end of file +} diff --git a/src/Commands/TransportRibbonHandler.cs b/src/Commands/TransportRibbonHandler.cs new file mode 100644 index 0000000..95278f6 --- /dev/null +++ b/src/Commands/TransportRibbonHandler.cs @@ -0,0 +1,79 @@ +using System; +using Autodesk.Navisworks.Api.Plugins; +using NavisworksTransport.Utils; + +namespace NavisworksTransport.Commands +{ + [Plugin("TransportRibbonHandler", "NavisworksTransport", + DisplayName = "物流路径规划工具栏", + ToolTip = "物流路径规划工具栏命令")] + [RibbonLayout("TransportRibbon.xaml")] + [RibbonTab("ID_TransportTab", DisplayName = "物流路径规划")] + [Command("OpenLogisticsPanel", + DisplayName = "物流路径规划", + ToolTip = "打开物流路径规划面板", + LoadForCanExecute = true)] + public class TransportRibbonHandler : CommandHandlerPlugin + { + private const string MainDockPanePluginKey = "NavisworksTransport.Main.Tian"; + private const string OpenLogisticsPanelCommandId = "OpenLogisticsPanel"; + private const string TransportRibbonTabId = "ID_TransportTab"; + + public override int ExecuteCommand(string commandId, params string[] parameters) + { + try + { + if (!string.Equals(commandId, OpenLogisticsPanelCommandId, StringComparison.OrdinalIgnoreCase)) + { + LogManager.Warning($"[Ribbon] 未知命令: {commandId}"); + return 1; + } + + var pluginRecord = Autodesk.Navisworks.Api.Application.Plugins.FindPlugin(MainDockPanePluginKey) as DockPanePluginRecord; + if (pluginRecord == null) + { + throw new InvalidOperationException($"未找到物流路径规划面板插件: {MainDockPanePluginKey}"); + } + + var dockPane = pluginRecord.LoadPlugin() as DockPanePlugin; + if (dockPane == null) + { + throw new InvalidOperationException("物流路径规划面板加载失败"); + } + + dockPane.Visible = true; + dockPane.ActivatePane(); + LogManager.Info("[Ribbon] 已通过正式工具栏打开物流路径规划面板"); + return 0; + } + catch (Exception ex) + { + LogManager.Error($"[Ribbon] 执行命令失败: {ex.Message}", ex); + return 1; + } + } + + public override CommandState CanExecuteCommand(string commandId) + { + if (string.Equals(commandId, OpenLogisticsPanelCommandId, StringComparison.OrdinalIgnoreCase)) + { + return new CommandState + { + IsEnabled = true, + IsVisible = true + }; + } + + return new CommandState + { + IsEnabled = false, + IsVisible = false + }; + } + + public override bool CanExecuteRibbonTab(string ribbonTabId) + { + return string.Equals(ribbonTabId, TransportRibbonTabId, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Core/Animation/PathAnimationManager.cs b/src/Core/Animation/PathAnimationManager.cs index f9de754..3a32c93 100644 --- a/src/Core/Animation/PathAnimationManager.cs +++ b/src/Core/Animation/PathAnimationManager.cs @@ -1,13 +1,18 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; using System.Text; +using System.Windows; using System.Windows.Threading; using Autodesk.Navisworks.Api; using Autodesk.Navisworks.Api.Clash; +using ComApiBridge = Autodesk.Navisworks.Api.ComApi.ComApiBridge; using NavisworksTransport.Core.Config; using NavisworksTransport.Core.Spatial; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; using NavisApplication = Autodesk.Navisworks.Api.Application; namespace NavisworksTransport.Core.Animation @@ -72,6 +77,9 @@ namespace NavisworksTransport.Core.Animation public double Progress { get; set; } // 进度(0-1) public Point3D Position { get; set; } // 该帧的位置 public double YawRadians { get; set; } // 绕Z轴的偏航角(弧度) + public double? PlanarHostYawRadians { get; set; } // 平面真实物体逐帧宿主平面角 + public Rotation3D Rotation { get; set; } // 可选的完整三维姿态 + public bool HasCustomRotation { get; set; } // 是否使用完整三维姿态 public List Collisions { get; set; } // 该帧的碰撞结果 public bool HasCollision => Collisions?.Count > 0; @@ -79,24 +87,53 @@ namespace NavisworksTransport.Core.Animation { Collisions = new List(); YawRadians = 0.0; + PlanarHostYawRadians = null; + Rotation = Rotation3D.Identity; + HasCustomRotation = false; } } + internal enum AnimatedObjectMode + { + None, + RealObject, + VirtualObject + } + public class PathAnimationManager { private static PathAnimationManager _instance; private static readonly Dictionary _animationHashToRecordId = new Dictionary(); // 动画配置哈希到检测记录ID的映射 + private static readonly HashSet _realObjectFragmentUpMismatchHintsShown = new HashSet(StringComparer.OrdinalIgnoreCase); private ModelItem _animatedObject; - private bool _isVirtualObject = false; // 是否使用虚拟物体 + private AnimatedObjectMode _animatedObjectMode = AnimatedObjectMode.None; private double _virtualObjectLength = 0; // 虚拟物体长度(模型单位) private double _virtualObjectWidth = 0; // 虚拟物体宽度(模型单位) private double _virtualObjectHeight = 0; // 虚拟物体高度(模型单位) + private double _realObjectLength = 0; // 真实物体长度(模型单位,固定物理尺寸) + private double _realObjectWidth = 0; // 真实物体宽度(模型单位,固定物理尺寸) + private double _realObjectHeight = 0; // 真实物体高度(模型单位,固定物理尺寸) + private Quaternion _realObjectReferenceRotation = Quaternion.Identity; + private Vector3 _realObjectReferenceAxisX = Vector3.UnitX; + private Vector3 _realObjectReferenceAxisY = Vector3.UnitY; + private Vector3 _realObjectReferenceAxisZ = Vector3.UnitZ; + private LocalAxisDirection _realObjectReferenceHostWorldXAxisLocalAxis = LocalAxisDirection.PositiveX; + private LocalAxisDirection _realObjectReferenceHostWorldYAxisLocalAxis = LocalAxisDirection.PositiveY; + private LocalAxisDirection _realObjectReferenceHostWorldZAxisLocalAxis = LocalAxisDirection.PositiveZ; + private LocalAxisDirection _realObjectReferenceHostSemanticXAxisLocalAxis = LocalAxisDirection.PositiveX; + private LocalAxisDirection _realObjectReferenceHostSemanticYAxisLocalAxis = LocalAxisDirection.PositiveY; + private LocalAxisDirection _realObjectReferenceHostSemanticZAxisLocalAxis = LocalAxisDirection.PositiveZ; + private LocalAxisDirection _realObjectReferenceHostUpLocalAxis = LocalAxisDirection.PositiveY; + private bool _hasRealObjectReferenceRotation = false; + private LocalAxisDirection _realObjectPlanarSelectedForwardAxis = LocalAxisDirection.PositiveX; + private bool _hasRealObjectPlanarSelectedForwardAxis = false; private List _pathPoints; private List _manualCollisionTargets = new List(); private bool _manualCollisionOverrideEnabled = false; // === 角度修正 === - private double _objectRotationCorrection = 0.0; // 物体角度修正值(度,顺时针) + private LocalEulerRotationCorrection _objectRotationCorrection = LocalEulerRotationCorrection.Zero; // 物体绕宿主 X/Y/Z 轴的角度修正 + private double _groundPathObjectLiftHeight; // 地面路径物体额外提升高度(模型单位) // === 碰撞排除列表缓存管理(从LogisticsAnimationManager迁移)=== private ModelItem _currentCachedAnimationObject; @@ -152,10 +189,17 @@ namespace NavisworksTransport.Core.Animation private Transform3D _originalTransform; private Point3D _originalCenter; // 存储部件的原始中心位置 - private Point3D _currentPosition; // 存储部件的当前位置 + private Point3D _trackedPosition; // 动画管理器内部业务跟踪点,不等于宿主即时读回的实时包围盒中心 private AnimationState _currentState = AnimationState.Idle; private double _pausedProgress = 0.0; // 暂停时的进度(0-1之间) private double _currentYaw = 0.0; // 当前偏航角(弧度) + private Rotation3D _trackedRotation = Rotation3D.Identity; // 动画管理器内部跟踪的完整姿态 + private bool _hasTrackedRotation = false; // 当前是否使用完整姿态 + private Rotation3D _groundRealObjectBaseRotation = Rotation3D.Identity; + private double _groundRealObjectBaseYaw = 0.0; + private bool _hasGroundRealObjectBasePose = false; + private Vector3D _railPreservedPoseTrackedCenterOffset = new Vector3D(0, 0, 0); + private bool _hasRailPreservedPoseTrackedCenterOffset = false; // TimeLiner 集成 private TimeLinerIntegrationManager _timeLinerManager; @@ -167,7 +211,18 @@ namespace NavisworksTransport.Core.Animation // === 物体状态保存和恢复 === private Point3D _savedObjectPosition; private double _savedObjectYaw; + private Rotation3D _savedObjectRotation = Rotation3D.Identity; + private bool _savedObjectHasCustomRotation = false; private bool _hasSavedObjectState = false; + private ObjectStartPlacementMode _objectStartPlacementMode = ObjectStartPlacementMode.AlignToPathPose; + private Rotation3D _pathPreservedPoseRotation = Rotation3D.Identity; + private bool _hasPathPreservedPoseRotation = false; + + private bool IsVirtualObjectMode => _animatedObjectMode == AnimatedObjectMode.VirtualObject; + private bool IsRealObjectMode => _animatedObjectMode == AnimatedObjectMode.RealObject; + + private ModelItem CurrentControlledObject => + IsVirtualObjectMode ? VirtualObjectManager.Instance.CurrentVirtualObject : _animatedObject; /// /// 当前正在进行动画的对象 @@ -384,7 +439,7 @@ namespace NavisworksTransport.Core.Animation // 🔥 支持虚拟物体的恢复 ModelItem objectToRestore = null; - bool isVirtual = _isVirtualObject; + bool isVirtual = IsVirtualObjectMode; if (isVirtual) { @@ -412,16 +467,23 @@ namespace NavisworksTransport.Core.Animation // 2. 重置内部跟踪变量(同步到原始状态) // 注意:ResetPermanentTransform 后物体的 Transform 属性会自动变回原始值 - _currentYaw = ModelItemTransformHelper.GetYawFromTransform(objectToRestore.Transform); + if (isVirtual) + { + _currentYaw = ModelItemTransformHelper.GetYawFromTransform(objectToRestore.Transform); + _trackedRotation = objectToRestore.Transform.Factor().Rotation; + _hasTrackedRotation = true; + } + else + { + SyncTrackedRotationToObjectReference(objectToRestore, isVirtualObject: false); + } - var originalBoundingBox = objectToRestore.BoundingBox(); - _currentPosition = new Point3D( - originalBoundingBox.Center.X, - originalBoundingBox.Center.Y, - originalBoundingBox.Min.Z - ); + var originalBoundingBox = objectToRestore.BoundingBox(); + _trackedPosition = ResolveInitialBusinessTrackedPosition(objectToRestore); string objectName = isVirtual ? "虚拟物体" : objectToRestore.DisplayName; + _railPreservedPoseTrackedCenterOffset = new Vector3D(0, 0, 0); + _hasRailPreservedPoseTrackedCenterOffset = false; LogManager.Info($"[归位] {objectName} 已彻底恢复到原始位置, yaw={_currentYaw:F3}"); } catch (Exception ex) @@ -447,7 +509,18 @@ namespace NavisworksTransport.Core.Animation public void SetAnimatedObject(ModelItem animatedObject) { _animatedObject = animatedObject; - _isVirtualObject = (animatedObject == null); + _animatedObjectMode = animatedObject == null + ? AnimatedObjectMode.None + : AnimatedObjectMode.RealObject; + ResetRealObjectReferenceRotation(); + + if (animatedObject != null) + { + _originalTransform = animatedObject.Transform; + _originalCenter = animatedObject.BoundingBox().Center; + _trackedPosition = ResolveInitialBusinessTrackedPosition(animatedObject); + SyncTrackedRotationToObjectReference(animatedObject, isVirtualObject: false); + } } /// @@ -456,12 +529,30 @@ namespace NavisworksTransport.Core.Animation /// public void SetVirtualObjectParameters(bool isVirtualObject, double length, double width, double height) { - _isVirtualObject = isVirtualObject; + _animatedObjectMode = isVirtualObject + ? AnimatedObjectMode.VirtualObject + : (_animatedObject != null ? AnimatedObjectMode.RealObject : AnimatedObjectMode.None); + if (isVirtualObject) + { + ResetRealObjectReferenceRotation(); + } _virtualObjectLength = length; // 模型单位 _virtualObjectWidth = width; // 模型单位 _virtualObjectHeight = height; // 模型单位 } + /// + /// 设置真实物体的固定物理尺寸(模型单位)。 + /// 一旦动画开始生成,后续 Rail 帧和起点贴合应使用同一份尺寸语义, + /// 不能再从已经旋转后的当前 AABB 重新推导高度。 + /// + public void SetRealObjectDimensions(double length, double width, double height) + { + _realObjectLength = length; + _realObjectWidth = width; + _realObjectHeight = height; + } + /// /// 设置动画参数(批处理专用) /// @@ -558,6 +649,8 @@ namespace NavisworksTransport.Core.Animation /// 是否成功移动 public bool MoveObjectToPathStart(ModelItem animatedObject = null, List pathPoints = null) { + _objectStartPlacementMode = ObjectStartPlacementMode.AlignToPathPose; + // 前置检查:必须有路径 if (_route == null) { @@ -569,8 +662,8 @@ namespace NavisworksTransport.Core.Animation { // 🔥 重要:先恢复物体到原始状态(CAD位置和原始朝向),确保每次计算都从相同的状态开始 // 这样可以避免之前旋转导致的包围盒尺寸计算不准确的问题 - // 注意:也要处理虚拟物体(_isVirtualObject为true时_animatedObject可能为null) - if (_animatedObject != null || _isVirtualObject) + // 注意:也要处理虚拟物体(当前控制对象可能不在 _animatedObject 中) + if (CurrentControlledObject != null || IsVirtualObjectMode) { RestoreObjectToCADPosition(); LogManager.Info($"[移动到起点] 已恢复物体到原始状态, _currentYaw={_currentYaw * 180 / Math.PI:F2}°"); @@ -580,12 +673,29 @@ namespace NavisworksTransport.Core.Animation if (animatedObject != null) { _animatedObject = animatedObject; + bool isVirtualObject = + VirtualObjectManager.Instance.IsVirtualObjectActive && + ReferenceEquals(animatedObject, VirtualObjectManager.Instance.CurrentVirtualObject); + _animatedObjectMode = isVirtualObject + ? AnimatedObjectMode.VirtualObject + : AnimatedObjectMode.RealObject; + ResetRealObjectReferenceRotation(); _originalTransform = animatedObject.Transform; _originalCenter = animatedObject.BoundingBox().Center; - _currentPosition = new Point3D(_originalCenter.X, _originalCenter.Y, animatedObject.BoundingBox().Min.Z); + _trackedPosition = ResolveInitialBusinessTrackedPosition(animatedObject); + if (isVirtualObject) + { + _currentYaw = ModelItemTransformHelper.GetYawFromTransform(_originalTransform); + _trackedRotation = _originalTransform.Factor().Rotation; + _hasTrackedRotation = true; + } + else + { + SyncTrackedRotationToObjectReference(animatedObject, isVirtualObject: false); + } - // 统一逻辑:从当前 Transform 中提取实际朝向(无论虚拟物体还是普通物体) - _currentYaw = ModelItemTransformHelper.GetYawFromTransform(_originalTransform); + LogManager.Info( + $"[移动到起点] 更新动画对象内部状态: 对象={animatedObject.DisplayName}, 虚拟物体={IsVirtualObjectMode}"); } if (pathPoints != null) @@ -600,61 +710,171 @@ namespace NavisworksTransport.Core.Animation return false; } - // 计算朝向(使用前两个路径点的方向) - double pathDirectionYaw = Math.Atan2(_pathPoints[1].Y - _pathPoints[0].Y, _pathPoints[1].X - _pathPoints[0].X); - double yaw; + bool shouldUsePlanarPureIncrementStart = + IsRealObjectMode && + (_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting); - LogManager.Info($"[移动到起点] 路径方向yaw: {pathDirectionYaw * 180 / Math.PI:F2}度, 角度修正: {_objectRotationCorrection:F1}度"); - - // 根据路径类型调整朝向 - if (_route.PathType == PathType.Hoisting) + if (shouldUsePlanarPureIncrementStart) { - // 吊装路径:使用水平吊运方向(与通行空间方向一致) - // 从第2个路径点(提升点)到第3个路径点(平移终点)的水平方向 - yaw = Math.Atan2(_pathPoints[2].Y - _pathPoints[1].Y, _pathPoints[2].X - _pathPoints[1].X); - LogManager.Debug($"[移动到起点] 吊装路径使用水平吊运方向: {yaw * 180 / Math.PI:F2}度"); - } - else - { - // 地面路径和空轨路径:直接使用路径方向 - yaw = pathDirectionYaw; - LogManager.Debug($"[移动到起点] 地面/空轨路径使用路径方向: {yaw * 180 / Math.PI:F2}度"); - } - - // 应用角度修正值(顺时针,转换为弧度) - if (_objectRotationCorrection != 0.0) - { - double correctionRad = _objectRotationCorrection * Math.PI / 180.0; - yaw += correctionRad; - LogManager.Debug($"[移动到起点] 应用角度修正: {_objectRotationCorrection:F1}°, 修正后yaw: {yaw * 180 / Math.PI:F2}度"); + LogManager.Info($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体起点改用纯增量姿态主链"); } // 根据路径类型调整起点位置 - Point3D startPosition = _pathPoints[0]; + Point3D pathStartPoint = _pathPoints[0]; + Point3D startPosition = pathStartPoint; if (_route.PathType == PathType.Hoisting) { - // 吊装路径:第一个路径点(起吊点)是地面位置,物体底面应该在这里 - // 不需要向下移动物体高度 - LogManager.Debug($"[移动到起点] 吊装路径:起吊点是地面位置,物体底面Z={startPosition.Z:F2}"); + // 吊装路径:第一个路径点(起吊点)是地面位置,动画跟踪点统一使用几何中心 + startPosition = ResolveGroundTrackedCenter(startPosition, GetAnimatedObjectGroundContactHeight()); + LogManager.Debug($"[移动到起点] 吊装路径:起吊点已转换为物体中心=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})"); } else if (_route.PathType == PathType.Rail) { - // 空轨路径:路径点是悬挂点,物体悬挂在下方 - double objectHeight = _animatedObject.BoundingBox().Max.Z - _animatedObject.BoundingBox().Min.Z; - startPosition = new Point3D(startPosition.X, startPosition.Y, startPosition.Z - objectHeight); - LogManager.Debug($"[移动到起点] 空轨路径调整: 悬挂点Z={_pathPoints[0].Z:F2}, 物体底面Z={startPosition.Z:F2}, 物体高度={objectHeight:F2}"); + Point3D previousPoint = _pathPoints[0]; + Point3D nextPoint = _pathPoints.Count > 1 ? _pathPoints[1] : _pathPoints[0]; + double objectHeight = GetAnimatedObjectRailNormalExtent(previousPoint, _pathPoints[0], nextPoint); + if (TryResolveRailPreservedPoseTrackedCenter(startPosition, previousPoint, nextPoint, objectHeight, out Point3D preservedTrackedCenter)) + { + startPosition = preservedTrackedCenter; + LogManager.Debug( + $"[移动到起点] Rail保持姿态补偿: 参考点=({_pathPoints[0].X:F2},{_pathPoints[0].Y:F2},{_pathPoints[0].Z:F2}), " + + $"补偿后中心=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})"); + } + else + { + startPosition = RailPathPoseHelper.ResolveObjectSpaceCenterPosition(_route, startPosition, previousPoint, nextPoint, objectHeight); + LogManager.Debug($"[移动到起点] Rail路径调整: 参考点=({_pathPoints[0].X:F2},{_pathPoints[0].Y:F2},{_pathPoints[0].Z:F2}), 物体中心=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2}), 物体高度={objectHeight:F2}, 安装={_route.RailMountMode}, 参考面={(PathRoute.IsTopReferenceFaceForMountMode(_route.RailMountMode) ? "顶面参考点" : "底面参考点")}"); + } + + if (TryCreateRailPathRotation( + previousPoint, + _pathPoints[0], + nextPoint, + out var railRotation)) + { + var railLinearTransform = new Transform3D(railRotation).Linear; + LogManager.Info( + $"[移动到起点] Rail旋转姿态: " + + $"X=({railLinearTransform.Get(0, 0):F4},{railLinearTransform.Get(1, 0):F4},{railLinearTransform.Get(2, 0):F4}), " + + $"Y=({railLinearTransform.Get(0, 1):F4},{railLinearTransform.Get(1, 1):F4},{railLinearTransform.Get(2, 1):F4}), " + + $"Z=({railLinearTransform.Get(0, 2):F4},{railLinearTransform.Get(1, 2):F4},{railLinearTransform.Get(2, 2):F4})"); + + ApplyFullPoseUpdate(startPosition, railRotation); + LogManager.Info("[移动到起点] Rail路径已应用完整三维旋转姿态"); + return true; + } } else { - // 地面路径:points[0] 是地面位置,物体底面应该在这里 - LogManager.Debug($"[移动到起点] 地面路径: pos=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})"); + // 地面路径:路径点在接触面上,动画跟踪点统一使用几何中心 + startPosition = ResolveGroundTrackedCenter(startPosition, GetAnimatedObjectGroundContactHeight()); + startPosition = ApplyGroundPathObjectLiftOffset(startPosition); + LogManager.Debug($"[移动到起点] 地面路径中心点=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})"); } - // 使用 UpdateObjectPosition 统一处理移动和旋转 - UpdateObjectPosition(startPosition, yaw); + LogManager.Info( + $"[路径起点诊断] 路径point0=({pathStartPoint.X:F3},{pathStartPoint.Y:F3},{pathStartPoint.Z:F3}), " + + $"起点目标trackedPoint=({startPosition.X:F3},{startPosition.Y:F3},{startPosition.Z:F3}), 路径类型={_route.PathType.GetDisplayName()}"); + + if (shouldUsePlanarPureIncrementStart && + TryApplyPlanarRealObjectStartIncrementalTransform(_route.PathType, startPosition, out double planarTargetYawRadians)) + { + LogManager.Info($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体已应用纯增量旋转+平移"); + + var startAppliedPoint = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject); + LogManager.Info( + $"[路径起点诊断] 起点应用后实际包围盒中心=({startAppliedPoint.X:F3},{startAppliedPoint.Y:F3},{startAppliedPoint.Z:F3}), " + + $"相对目标偏差=({startAppliedPoint.X - startPosition.X:F3},{startAppliedPoint.Y - startPosition.Y:F3},{startAppliedPoint.Z - startPosition.Z:F3})"); + + var startOffset = new Vector3D( + startPosition.X - startAppliedPoint.X, + startPosition.Y - startAppliedPoint.Y, + startPosition.Z - startAppliedPoint.Z); + double startOffsetLength = Math.Sqrt( + startOffset.X * startOffset.X + + 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}°, " + + $"已记录基姿态={_hasGroundRealObjectBasePose} (纯增量链)"); + } + else + { + _hasGroundRealObjectBasePose = false; + } + } + else if (shouldUsePlanarPureIncrementStart) + { + LogManager.Error($"[移动到起点] {_route.PathType.GetDisplayName()} 真实物体纯增量起点失败,已禁止退回旧姿态链。"); + return false; + } + else if (_route.PathType != PathType.Rail && + TryCreatePlanarPathRotationAtStart(out var planarRotation)) + { + ApplyFullPoseUpdate(startPosition, planarRotation); + LogManager.Info("[移动到起点] 地面/吊装路径已应用完整三维姿态"); + + var startAppliedPoint = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject); + LogManager.Info( + $"[路径起点诊断] 起点应用后实际包围盒中心=({startAppliedPoint.X:F3},{startAppliedPoint.Y:F3},{startAppliedPoint.Z:F3}), " + + $"相对目标偏差=({startAppliedPoint.X - startPosition.X:F3},{startAppliedPoint.Y - startPosition.Y:F3},{startAppliedPoint.Z - startPosition.Z:F3})"); + + // Ground 起点不再进行二次补偿;业务跟踪点语义由 _trackedPosition 主链负责 + if (IsRealObjectMode && _route.PathType == PathType.Ground) + { + var startOffset = new Vector3D( + startPosition.X - startAppliedPoint.X, + startPosition.Y - startAppliedPoint.Y, + startPosition.Z - startAppliedPoint.Z); + double startOffsetLength = Math.Sqrt( + startOffset.X * startOffset.X + + 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}°, " + + $"已记录基姿态={_hasGroundRealObjectBasePose} (旋转偏移由逐帧方法内部处理)"); + } + else if (IsRealObjectMode && _route.PathType == PathType.Hoisting) + { + } + else + { + _hasGroundRealObjectBasePose = false; + } + } + else if (_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) + { + LogManager.Error("[移动到起点] 平面路径未能生成完整姿态,已禁止退回旧 yaw 链路"); + return false; + } + else if (_route.PathType == PathType.Rail) + { + LogManager.Error("[移动到起点] Rail路径未能生成完整三维姿态,已禁止退回旧 yaw 链路"); + return false; + } string pathTypeName = _route.PathType.GetDisplayName(); - LogManager.Info($"物体已初始化到路径起点并对齐朝向: pos=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2}), yaw={yaw:F3}rad, 路径类型={pathTypeName}"); + LogManager.Info($"物体已初始化到路径起点并对齐朝向: pos=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2}), 路径类型={pathTypeName}"); return true; } catch (Exception ex) @@ -664,6 +884,115 @@ namespace NavisworksTransport.Core.Animation } } + public bool MoveObjectToPathStartPreservingInitialPose(ModelItem animatedObject = null, List pathPoints = null) + { + _objectStartPlacementMode = ObjectStartPlacementMode.PreserveInitialPose; + + if (_route == null) + { + LogManager.Error("[平移到起点] 路径为空,无法移动物体到路径起点"); + return false; + } + + try + { + if (CurrentControlledObject != null || IsVirtualObjectMode) + { + RestoreObjectToCADPosition(); + LogManager.Info($"[平移到起点] 已恢复物体到原始状态, _currentYaw={_currentYaw * 180 / Math.PI:F2}°"); + } + + if (animatedObject != null) + { + _animatedObject = animatedObject; + bool isVirtualObject = + VirtualObjectManager.Instance.IsVirtualObjectActive && + ReferenceEquals(animatedObject, VirtualObjectManager.Instance.CurrentVirtualObject); + _animatedObjectMode = isVirtualObject + ? AnimatedObjectMode.VirtualObject + : AnimatedObjectMode.RealObject; + ResetRealObjectReferenceRotation(); + _originalTransform = animatedObject.Transform; + _originalCenter = animatedObject.BoundingBox().Center; + _trackedPosition = ResolveInitialBusinessTrackedPosition(animatedObject); + if (isVirtualObject) + { + _currentYaw = ModelItemTransformHelper.GetYawFromTransform(_originalTransform); + _trackedRotation = _originalTransform.Factor().Rotation; + _hasTrackedRotation = true; + } + else + { + SyncTrackedRotationToObjectReference(animatedObject, isVirtualObject: false); + } + } + + if (pathPoints != null) + { + _pathPoints = pathPoints; + } + + if (_pathPoints == null || _pathPoints.Count < 2) + { + LogManager.Warning("[平移到起点] 没有可用的路径点"); + return false; + } + + Point3D pathStartPoint = _pathPoints[0]; + Point3D startPosition = pathStartPoint; + if (_route.PathType == PathType.Hoisting) + { + startPosition = ResolveGroundTrackedCenter(startPosition, GetAnimatedObjectGroundContactHeight()); + } + else if (_route.PathType == PathType.Rail) + { + Point3D previousPoint = _pathPoints[0]; + Point3D nextPoint = _pathPoints.Count > 1 ? _pathPoints[1] : _pathPoints[0]; + double objectHeight = GetAnimatedObjectRailNormalExtent(previousPoint, _pathPoints[0], nextPoint); + + // Rail 平移模式不是重新求一套“轨上/轨下安装姿态”, + // 而是以终点处真实箱体当前位姿为真值, + // 记录其相对终点路径参考点的固定残差,再整体搬运到起点。 + Point3D terminalReferencePoint = _pathPoints[_pathPoints.Count - 1]; + Point3D terminalPreviousPoint = _pathPoints.Count > 1 ? _pathPoints[_pathPoints.Count - 2] : terminalReferencePoint; + Point3D terminalNextPoint = terminalReferencePoint; + CaptureRailPreservedPoseTrackedCenterOffset( + terminalPreviousPoint, + terminalReferencePoint, + terminalNextPoint, + objectHeight); + + if (!TryResolveRailPreservedPoseTrackedCenter(startPosition, previousPoint, nextPoint, objectHeight, out Point3D preservedTrackedCenter)) + { + preservedTrackedCenter = RailPathPoseHelper.ResolveObjectSpaceCenterPosition(_route, startPosition, previousPoint, nextPoint, objectHeight); + } + + startPosition = preservedTrackedCenter; + } + else + { + startPosition = ResolveGroundTrackedCenter(startPosition, GetAnimatedObjectGroundContactHeight()); + startPosition = ApplyGroundPathObjectLiftOffset(startPosition); + } + + ApplyTrackedTranslationOnly(startPosition); + SyncTrackedRotationToDisplayedPose(CurrentControlledObject ?? _animatedObject); + _hasGroundRealObjectBasePose = false; + + var startAppliedPoint = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject); + LogManager.Info( + $"[平移到起点] 已按终点原始位姿残差搬运到起点: 路径point0=({pathStartPoint.X:F3},{pathStartPoint.Y:F3},{pathStartPoint.Z:F3}), " + + $"目标trackedPoint=({startPosition.X:F3},{startPosition.Y:F3},{startPosition.Z:F3}), " + + $"实际包围盒中心=({startAppliedPoint.X:F3},{startAppliedPoint.Y:F3},{startAppliedPoint.Z:F3}), 路径类型={_route.PathType.GetDisplayName()}"); + return true; + } + catch (Exception ex) + { + LogManager.Error($"保持初始位姿平移到路径起点失败: {ex.Message}"); + return false; + } + } + /// /// 预计算所有动画帧和碰撞信息 /// @@ -677,7 +1006,21 @@ namespace NavisworksTransport.Core.Animation if (_animatedObject != null && _route != null && _route.Points != null && _route.Points.Count > 0) { var pathPoints = _route.Points.Select(p => p.Position).ToList(); - MoveObjectToPathStart(_animatedObject, pathPoints); + MoveObjectToPathStartUsingCurrentPlacementMode(_animatedObject, pathPoints); + if (ShouldPreservePathRotationForFrames( + _route.PathType, + _objectStartPlacementMode, + _hasTrackedRotation)) + { + _pathPreservedPoseRotation = _trackedRotation; + _hasPathPreservedPoseRotation = true; + LogManager.Info($"[预计算] {_route.PathType.GetDisplayName()}平移模式已锁定起点姿态为整段动画旋转基线"); + } + else + { + _pathPreservedPoseRotation = Rotation3D.Identity; + _hasPathPreservedPoseRotation = false; + } LogManager.Info("[预计算] 物体已移动到路径起点"); } @@ -806,14 +1149,22 @@ namespace NavisworksTransport.Core.Animation } // 3. 生成每一帧 - double distancePerFrameInModelUnits = totalLengthInModelUnits / totalFrames; + double distancePerFrameInModelUnits = totalFrames > 1 + ? totalLengthInModelUnits / (totalFrames - 1) + : 0.0; LogManager.Info($"总帧数: {totalFrames}, 总长度: {totalLengthInModelUnits / metersToModelUnits:F2}米, 每帧移动距离: {distancePerFrameInModelUnits / metersToModelUnits:F4}米"); for (int i = 0; i < totalFrames; i++) { - double targetDistance = i * distancePerFrameInModelUnits; // 按距离采样(模型单位),不是按进度 + double targetDistance = i * distancePerFrameInModelUnits; // 按距离采样(模型单位),最后一帧应精确到终点 + if (targetDistance > totalLengthInModelUnits) + { + targetDistance = totalLengthInModelUnits; + } Point3D framePosition; + Point3D previousFramePoint = Point3D.Origin; + Point3D nextFramePoint = Point3D.Origin; double yawRadians; if (isAerialPath) @@ -832,6 +1183,8 @@ namespace NavisworksTransport.Core.Animation // 线性插值 Point3D p1 = allSampledPoints[segmentIndex]; Point3D p2 = allSampledPoints[segmentIndex + 1]; + previousFramePoint = p1; + nextFramePoint = p2; framePosition = new Point3D( p1.X + segmentProgress * (p2.X - p1.X), p1.Y + segmentProgress * (p2.Y - p1.Y), @@ -848,7 +1201,9 @@ namespace NavisworksTransport.Core.Animation } // 🔥 空中路径:根据路径类型和线段索引调整物体位置 - double objectHeight = _animatedObject.BoundingBox().Max.Z - _animatedObject.BoundingBox().Min.Z; + double objectHeight = _route.PathType == PathType.Rail + ? GetAnimatedObjectRailNormalExtent(p1, framePosition, p2) + : GetAnimatedObjectGroundContactHeight(); if (_route.PathType == PathType.Hoisting) { @@ -871,35 +1226,35 @@ namespace NavisworksTransport.Core.Animation if (segmentIndex == firstSegment) { - // 起吊段:从地面逐渐上升到悬挂点-物体高度 - // 进度0时(地面):物体底面在地面,不向下移动 - // 进度1时(悬挂点):物体顶面在悬挂点,物体底面=悬挂点-物体高度 - double heightOffset = segmentProgress * objectHeight; - framePosition = new Point3D(framePosition.X, framePosition.Y, framePosition.Z - heightOffset); - LogManager.Debug($"[吊装路径-起吊段] 进度={segmentProgress:F2}, 高度偏移={heightOffset:F2}, 物体底面Z={framePosition.Z:F2}"); + 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 = new Point3D(framePosition.X, framePosition.Y, framePosition.Z - objectHeight); - LogManager.Debug($"[吊装路径-平移段] 线段{segmentIndex}, 进度={segmentProgress:F2}, 物体底面Z={framePosition.Z:F2}"); + // 平移段(中间的所有线段):参考点位于顶部悬挂点 + framePosition = ResolveHoistingTrackedCenter(framePosition, objectHeight, 1.0); + LogManager.Debug($"[吊装路径-平移段] 线段{segmentIndex}, 进度={segmentProgress:F2}, 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})"); } else if (segmentIndex == lastSegment) { - // 下降段:从悬挂点-物体高度逐渐下降到地面 - // 进度0时(悬挂点):物体顶面在悬挂点,物体底面=悬挂点-物体高度 - // 进度1时(地面):物体底面在地面,不向下移动 - double heightOffset = (1 - segmentProgress) * objectHeight; - framePosition = new Point3D(framePosition.X, framePosition.Y, framePosition.Z - heightOffset); - LogManager.Debug($"[吊装路径-下降段] 进度={segmentProgress:F2}, 高度偏移={heightOffset:F2}, 物体底面Z={framePosition.Z:F2}"); + 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) { - // 空轨路径:路径点是悬挂点,物体悬挂在下方 - // 物体顶面应该在路径点,所以物体底面 = 路径点 - 物体高度 - framePosition = new Point3D(framePosition.X, framePosition.Y, framePosition.Z - objectHeight); - LogManager.Debug($"[空轨路径] 调整物体位置: 悬挂点Z={framePosition.Z + objectHeight:F2}, 物体底面Z={framePosition.Z:F2}"); + if (TryResolveRailPreservedPoseTrackedCenter(framePosition, p1, p2, objectHeight, out Point3D preservedTrackedCenter)) + { + framePosition = preservedTrackedCenter; + LogManager.Debug( + $"[Rail路径] 保持姿态补偿: 参考点=({p1.X:F2},{p1.Y:F2},{p1.Z:F2})->({p2.X:F2},{p2.Y:F2},{p2.Z:F2}), " + + $"物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2})"); + } + else + { + framePosition = RailPathPoseHelper.ResolveObjectSpaceCenterPosition(_route, framePosition, p1, p2, objectHeight); + LogManager.Debug($"[Rail路径] 调整物体位置: 参考点=({p1.X:F2},{p1.Y:F2},{p1.Z:F2})->({p2.X:F2},{p2.Y:F2},{p2.Z:F2}), 物体中心=({framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2}), 安装={_route.RailMountMode}, 参考面={(PathRoute.IsTopReferenceFaceForMountMode(_route.RailMountMode) ? "顶面参考点" : "底面参考点")}"); + } } } else @@ -915,17 +1270,53 @@ namespace NavisworksTransport.Core.Animation double accumulatedLength = segmentLengths.Take(edgeIndex).Sum(); double edgeProgress = (targetDistance - accumulatedLength) / edge.PhysicalLength; + Point3D sampledPreviousPoint; + Point3D sampledNextPoint; + // 在边内插值位置 if (edge.SegmentType == PathSegmentType.Straight) { - framePosition = InterpolateOnStraightEdge(edge, edgeProgress); + int sampleCount = edge.SampledPoints.Count; + int pointIndex = (int)(edgeProgress * (sampleCount - 1)); + pointIndex = Math.Max(0, Math.Min(pointIndex, sampleCount - 1)); + + framePosition = edge.SampledPoints[pointIndex]; + sampledPreviousPoint = edge.SampledPoints[Math.Max(0, pointIndex - 1)]; + sampledNextPoint = edge.SampledPoints[Math.Min(sampleCount - 1, pointIndex + 1)]; } else // Arc { - framePosition = InterpolateOnArcEdge(edge, edgeProgress); + int sampleCount = edge.SampledPoints.Count; + int pointIndex = (int)(edgeProgress * (sampleCount - 1)); + pointIndex = Math.Max(0, Math.Min(pointIndex, sampleCount - 1)); + + framePosition = edge.SampledPoints[pointIndex]; + sampledPreviousPoint = edge.SampledPoints[Math.Max(0, pointIndex - 1)]; + sampledNextPoint = edge.SampledPoints[Math.Min(sampleCount - 1, pointIndex + 1)]; } + framePosition = ResolveGroundTrackedCenter(framePosition, GetAnimatedObjectGroundContactHeight()); + framePosition = ApplyGroundPathObjectLiftOffset(framePosition); + yawRadians = ComputeYawOnPath(i, allSampledPoints, edgeIndex, edgeProgress); + previousFramePoint = ApplyGroundPathObjectLiftOffset( + ResolveGroundTrackedCenter(sampledPreviousPoint, GetAnimatedObjectGroundContactHeight())); + nextFramePoint = ApplyGroundPathObjectLiftOffset( + ResolveGroundTrackedCenter(sampledNextPoint, GetAnimatedObjectGroundContactHeight())); + + if ((_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) && + i > 0 && + (Math.Abs(previousFramePoint.X - nextFramePoint.X) > 1e-6 || + Math.Abs(previousFramePoint.Y - nextFramePoint.Y) > 1e-6 || + Math.Abs(previousFramePoint.Z - nextFramePoint.Z) > 1e-6)) + { + LogManager.Debug( + $"[平面转弯诊断] 帧={i}, edge={edgeIndex}, " + + $"prev=({previousFramePoint.X:F3},{previousFramePoint.Y:F3},{previousFramePoint.Z:F3}), " + + $"cur=({framePosition.X:F3},{framePosition.Y:F3},{framePosition.Z:F3}), " + + $"next=({nextFramePoint.X:F3},{nextFramePoint.Y:F3},{nextFramePoint.Z:F3}), " + + $"yaw={yawRadians * 180.0 / Math.PI:F2}°"); + } } // 创建帧并检测碰撞 @@ -938,6 +1329,77 @@ namespace NavisworksTransport.Core.Animation Collisions = new List() }; + if (ShouldUsePlanarRealObjectPureIncrementFrames(_route.PathType, IsRealObjectMode)) + { + double planarHostYawRadians = yawRadians; + if (_route.PathType == PathType.Ground && + !TryResolveGroundHostPlanarYawFromFramePoints( + previousFramePoint, + framePosition, + nextFramePoint, + CoordinateSystemManager.Instance.ResolvedType, + out planarHostYawRadians)) + { + throw new InvalidOperationException( + $"{_route.PathType.GetDisplayName()}真实物体第 {i} 帧未能解析宿主平面目标角。"); + } + + if (!TryResolveRecordedPlanarRealObjectFrameRotation( + _route.PathType, + previousFramePoint, + framePosition, + nextFramePoint, + out var recordedRotation)) + { + throw new InvalidOperationException( + $"{_route.PathType.GetDisplayName()}真实物体第 {i} 帧未能记录完整姿态。"); + } + + ApplyRecordedPlanarRealObjectFramePose( + frame, + planarHostYawRadians, + recordedRotation); + } + else if (ShouldPreservePathRotationForFrames( + _route.PathType, + _objectStartPlacementMode, + _hasPathPreservedPoseRotation)) + { + frame.Rotation = _pathPreservedPoseRotation; + frame.HasCustomRotation = true; + } + else if (_route.PathType == PathType.Rail && + TryCreateRailPathRotation( + previousFramePoint, + framePosition, + nextFramePoint, + out var railRotation)) + { + frame.Rotation = railRotation; + frame.HasCustomRotation = true; + } + else if ((_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) && + TryCreatePlanarPathRotationForFrame(previousFramePoint, framePosition, nextFramePoint, out var planarRotation)) + { + if (_route.PathType == PathType.Ground && + IsRealObjectMode && + TryCreateGroundRealObjectConstrainedRotation(previousFramePoint, framePosition, nextFramePoint, out var constrainedRotation)) + { + frame.Rotation = constrainedRotation; + frame.HasCustomRotation = true; + } + else + { + frame.Rotation = planarRotation; + frame.HasCustomRotation = true; + } + } + else if (_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) + { + throw new InvalidOperationException( + $"平面路径第 {i} 帧未能生成完整姿态,已禁止退回旧 yaw 链路。"); + } + // 记录第一帧的角度(用于调试) if (i == 0) { @@ -957,19 +1419,19 @@ namespace NavisworksTransport.Core.Animation // 计算偏移量(当前包围盒中心到framePosition的偏移) var offsetX = framePosition.X - originalCenter.X; var offsetY = framePosition.Y - originalCenter.Y; - var offsetZ = framePosition.Z - currentObjectBoundingBox.Min.Z; // Z方向:framePosition是底面,originalCenter是中心 + var offsetZ = framePosition.Z - originalCenter.Z; - // 创建新的包围盒(将当前包围盒移动到framePosition) + // 创建新的包围盒(将当前包围盒中心移动到framePosition) var virtualBoundingBox = new BoundingBox3D( new Point3D( currentObjectBoundingBox.Min.X + offsetX, currentObjectBoundingBox.Min.Y + offsetY, - framePosition.Z // 底面Z坐标 + currentObjectBoundingBox.Min.Z + offsetZ ), new Point3D( currentObjectBoundingBox.Max.X + offsetX, currentObjectBoundingBox.Max.Y + offsetY, - framePosition.Z + (currentObjectBoundingBox.Max.Z - currentObjectBoundingBox.Min.Z) // 保持高度 + currentObjectBoundingBox.Max.Z + offsetZ ) ); @@ -1026,6 +1488,8 @@ namespace NavisworksTransport.Core.Animation LogManager.Debug($"移动物体位置: {framePosition.X:F2},{framePosition.Y:F2},{framePosition.Z:F2}"); LogManager.Debug($"被撞物体位置:{GetObjectPosition(collider).X:F2},{GetObjectPosition(collider).Y:F2},{GetObjectPosition(collider).Z:F2}"); + double actualYawRadians = ResolveAnimationFramePlaybackYawRadians(frame); + var collisionResult = new CollisionResult { ClashGuid = Guid.NewGuid(), @@ -1040,22 +1504,27 @@ namespace NavisworksTransport.Core.Animation (virtualBoundingBox.Min.Y + virtualBoundingBox.Max.Y + colliderBox.Min.Y + colliderBox.Max.Y) / 4, (virtualBoundingBox.Min.Z + virtualBoundingBox.Max.Z + colliderBox.Min.Z + colliderBox.Max.Z) / 4 ), - // 🔥 重要:记录虚拟物体的包围盒中心,而不是底面中心 - // 原因: - // 1. framePosition是路径点,代表物体在地面上的位置(底面中心) - // 2. ClashDetective移动逻辑使用包围盒中心进行定位 - // 3. 如果记录底面中心,ClashDetective会把包围盒中心移动到底面位置,导致物体下沉半个高度 - // 4. 记录包围盒中心可以确保ClashDetective移动后的位置与预计算时的虚拟物体位置一致 - Item1Position = new Point3D( + // AnimatedObjectTrackedPosition 统一定义为动画主链路的跟踪点位置。 + // 当前动画主链路已统一使用几何中心作为跟踪点,因此所有路径都直接记录 framePosition。 + AnimatedObjectTrackedPosition = new Point3D( framePosition.X, framePosition.Y, - framePosition.Z + (virtualBoundingBox.Max.Z - virtualBoundingBox.Min.Z) / 2 - ), + framePosition.Z), Item2Position = GetObjectPosition(collider), - Item1YawRadians = yawRadians, // 记录运动物体朝向 + AnimatedObjectTrackedYawRadians = actualYawRadians, // 记录动画跟踪点对应的运动物体实际播放朝向 + AnimatedObjectTrackedRotation = frame.HasCustomRotation ? frame.Rotation : Rotation3D.Identity, + AnimatedObjectHasTrackedRotation = frame.HasCustomRotation, HasPositionInfo = true }; + LogManager.Debug( + $"[保存碰撞结果] 帧={i}, " + + $"位置语义=动画跟踪中心点, " + + $"原始Yaw={yawRadians * 180.0 / Math.PI:F2}°, " + + $"实际Yaw={actualYawRadians * 180.0 / Math.PI:F2}°, " + + $"customRotation={frame.HasCustomRotation}, " + + $"保存位置=({collisionResult.AnimatedObjectTrackedPosition.X:F3}, {collisionResult.AnimatedObjectTrackedPosition.Y:F3}, {collisionResult.AnimatedObjectTrackedPosition.Z:F3})"); + frame.Collisions.Add(collisionResult); _allCollisionResults.Add(collisionResult); } @@ -1068,7 +1537,16 @@ namespace NavisworksTransport.Core.Animation var framesWithCollision = _animationFrames.Count(f => f.HasCollision); var totalCollisions = _animationFrames.Sum(f => f.Collisions.Count); // 检查碰撞结果是否包含位置信息 - var collisionsWithPosition = _allCollisionResults.Count(c => c.HasPositionInfo && c.Item1Position != null); + var collisionsWithPosition = _allCollisionResults.Count(c => c.HasPositionInfo && c.AnimatedObjectTrackedPosition != null); + + if (_route.PathType == PathType.Rail && _animationFrames.Count > 0) + { + AnimationFrame firstRailFrame = _animationFrames[0]; + AnimationFrame lastRailFrame = _animationFrames[_animationFrames.Count - 1]; + LogManager.Info( + $"[Rail帧诊断] 首帧跟踪中心=({firstRailFrame.Position.X:F3},{firstRailFrame.Position.Y:F3},{firstRailFrame.Position.Z:F3}), " + + $"末帧跟踪中心=({lastRailFrame.Position.X:F3},{lastRailFrame.Position.Y:F3},{lastRailFrame.Position.Z:F3}), 总帧数={_animationFrames.Count}"); + } LogManager.Info($"=== 预计算完成 ==="); LogManager.Info($"总帧数: {_animationFrames.Count}"); @@ -1294,20 +1772,53 @@ namespace NavisworksTransport.Core.Animation if (_animationFrames != null && _animationFrames.Count > 0) { var firstFrame = _animationFrames[0]; - var currentPos = _animatedObject.BoundingBox().Center; - var distanceToStart = Math.Sqrt( - Math.Pow(currentPos.X - firstFrame.Position.X, 2) + - Math.Pow(currentPos.Y - firstFrame.Position.Y, 2) - ); - - // 如果距离起点超过0.1米,或者朝向不一致,需要重置 - var yawDiff = Math.Abs(_currentYaw - firstFrame.YawRadians); - if (yawDiff > Math.PI) yawDiff = 2 * Math.PI - yawDiff; // 处理角度环绕 - - if (distanceToStart > 0.1 || yawDiff > 0.01) + + double distanceToStart; + bool needsReset; + string mismatchSummary; + + if (firstFrame.HasCustomRotation) { - LogManager.Info($"[动画开始] 物体不在起点,重置到起点: 距离={distanceToStart:F2}m, 角度差={yawDiff * 180 / Math.PI:F2}°"); - MoveObjectToPathStart(); + var dx = _trackedPosition.X - firstFrame.Position.X; + var dy = _trackedPosition.Y - firstFrame.Position.Y; + var dz = _trackedPosition.Z - firstFrame.Position.Z; + distanceToStart = Math.Sqrt(dx * dx + dy * dy + dz * dz); + + var currentLinear = new Transform3D(_trackedRotation).Linear; + var targetLinear = new Transform3D(firstFrame.Rotation).Linear; + double rotationDiff = + Math.Abs(currentLinear.Get(0, 0) - targetLinear.Get(0, 0)) + + Math.Abs(currentLinear.Get(0, 1) - targetLinear.Get(0, 1)) + + Math.Abs(currentLinear.Get(0, 2) - targetLinear.Get(0, 2)) + + Math.Abs(currentLinear.Get(1, 0) - targetLinear.Get(1, 0)) + + Math.Abs(currentLinear.Get(1, 1) - targetLinear.Get(1, 1)) + + Math.Abs(currentLinear.Get(1, 2) - targetLinear.Get(1, 2)) + + Math.Abs(currentLinear.Get(2, 0) - targetLinear.Get(2, 0)) + + Math.Abs(currentLinear.Get(2, 1) - targetLinear.Get(2, 1)) + + Math.Abs(currentLinear.Get(2, 2) - targetLinear.Get(2, 2)); + + needsReset = distanceToStart > 0.1 || rotationDiff > 0.01; + mismatchSummary = $"距离={distanceToStart:F2}m, 三维姿态差={rotationDiff:F4}"; + } + else + { + var currentPos = _animatedObject.BoundingBox().Center; + distanceToStart = Math.Sqrt( + Math.Pow(currentPos.X - firstFrame.Position.X, 2) + + Math.Pow(currentPos.Y - firstFrame.Position.Y, 2) + ); + + var yawDiff = Math.Abs(_currentYaw - ResolveAnimationFramePlaybackYawRadians(firstFrame)); + if (yawDiff > Math.PI) yawDiff = 2 * Math.PI - yawDiff; + + needsReset = distanceToStart > 0.1 || yawDiff > 0.01; + mismatchSummary = $"距离={distanceToStart:F2}m, 角度差={yawDiff * 180 / Math.PI:F2}°"; + } + + if (needsReset) + { + LogManager.Info($"[动画开始] 物体不在起点,重置到起点: {mismatchSummary}"); + MoveObjectToPathStartUsingCurrentPlacementMode(); } } @@ -1367,19 +1878,20 @@ namespace NavisworksTransport.Core.Animation _currentFrameIndex = 0; // 重置当前帧索引,确保从第一帧开始 _pausedProgress = 0.0; // 重置暂停进度 - // 初始化动画状态时不再强行覆盖 _currentYaw 为第一帧的 yaw - // 这样在 UpdateObjectPosition(firstFrame.Position, firstFrame.YawRadians) 时 - // deltaYaw = firstFrame.YawRadians - 物体初始Yaw,从而产生旋转增量让物体转向 + // 初始化动画状态时不再强行覆盖 _currentYaw 为第一帧的 yaw, + // 由记录姿态应用链自行解释第一帧姿态。 if (_animationFrames != null && _animationFrames.Count > 0) { var firstFrame = _animationFrames[0]; - LogManager.Debug($"[动画开始] _currentYaw之前={_currentYaw * 180 / Math.PI:F2}度, _isVirtualObject={_isVirtualObject}"); + LogManager.Debug($"[动画开始] _currentYaw之前={_currentYaw * 180 / Math.PI:F2}度, 模式={_animatedObjectMode}"); // 🔥 确保物体在起点位置和朝向 // 注意:MoveObjectToPathStart 已在 StartAnimation 中调用,这里只是日志记录 - LogManager.Debug($"[动画开始] 物体应在起点: pos=({firstFrame.Position.X:F2},{firstFrame.Position.Y:F2}), yaw={firstFrame.YawRadians:F3}rad"); + LogManager.Debug( + $"[动画开始] 物体应在起点: pos=({firstFrame.Position.X:F2},{firstFrame.Position.Y:F2}), " + + $"yaw={ResolveAnimationFramePlaybackYawRadians(firstFrame):F3}rad"); } else { @@ -1598,6 +2110,33 @@ namespace NavisworksTransport.Core.Animation // 重置暂停进度 _pausedProgress = 0.0; + // 显式落到最后一帧,避免播放结束时视觉上停在倒数第二帧, + // 但后续碰撞链又按最终姿态重新摆正,造成“结束后又往前窜一点”。 + if (_animationFrames != null && _animationFrames.Count > 0) + { + int lastFrameIndex = _animationFrames.Count - 1; + if (_currentFrameIndex != lastFrameIndex) + { + _currentFrameIndex = lastFrameIndex; + ApplyRecordedPose(_animationFrames[_currentFrameIndex], _currentFrameIndex); + LogManager.Info("[动画结束] 当前尚未到最后一帧,已补落最终姿态"); + } + else + { + LogManager.Info("[动画结束] 当前已在最后一帧,保持真实结束位置,不再重复落位"); + } + + ProgressChanged?.Invoke(this, 100.0); + try + { + NavisApplication.ActiveDocument?.ActiveView?.RequestDelayedRedraw(ViewRedrawRequests.All); + } + catch (Exception redrawEx) + { + LogManager.Warning($"动画结束时请求最终重绘失败: {redrawEx.Message}"); + } + } + // 使用预计算的所有碰撞结果进行高亮显示(需要去重以避免重复高亮) var allCollisionResults = _allCollisionResults .GroupBy(c => new { c.Item1, c.Item2 }) @@ -1606,9 +2145,7 @@ namespace NavisworksTransport.Core.Animation // 动画自然结束时保持物体在最终位置(不移回起点) LogManager.Info("动画播放自然结束,物体保持在最终位置"); - - // 直接设置为完成状态,避免中间状态切换 - SetState(AnimationState.Finished); + LogEndAlignmentDiagnostics(); // 清除所有高亮(包括物体和碰撞) ModelHighlightHelper.ClearAllHighlights(); @@ -1618,12 +2155,6 @@ namespace NavisworksTransport.Core.Animation var actualTotalFrames = _animationFrames?.Count ?? 0; LogManager.Info($"动画播放完成,总帧数: {actualTotalFrames}, 平均FPS: {_actualFPS:F1}"); - // 更新 TimeLiner 任务状态 - if (_timeLinerManager != null && !string.IsNullOrEmpty(_currentTaskId)) - { - _timeLinerManager.UpdateTaskProgress(_currentTaskId, 1.0, AnimationState.Finished); - } - // 🔥 检查是否使用历史记录,如果是则跳过ClashDetective测试 if (_isUsingHistoryRecord) { @@ -1647,7 +2178,7 @@ namespace NavisworksTransport.Core.Animation _detectionTolerance, _currentRouteId, _animatedObject, - _isVirtualObject, + IsVirtualObjectMode, _animationFrameRate, _animationDuration, _virtualObjectLength, @@ -1672,6 +2203,16 @@ namespace NavisworksTransport.Core.Animation LogManager.Info("碰撞检测被取消"); } } + + // 后处理全部完成后再进入 Finished。 + // 否则 UI 会在碰撞汇总仍在运行时启动报告生成,和当前物体恢复链路并发,导致结束后姿态被再次改写。 + SetState(AnimationState.Finished); + + // 更新 TimeLiner 任务状态 + if (_timeLinerManager != null && !string.IsNullOrEmpty(_currentTaskId)) + { + _timeLinerManager.UpdateTaskProgress(_currentTaskId, 1.0, AnimationState.Finished); + } } catch (Exception ex) { @@ -1679,6 +2220,106 @@ namespace NavisworksTransport.Core.Animation } } + /// + /// 输出路径动画终点对齐诊断。 + /// 只做日志,不改变业务行为,用于确认: + /// 1. 路径终点锚点 + /// 2. 最后一帧动画跟踪点 + /// 3. 动画结束时的实际跟踪点 + /// 4. 沿路径前进方向的终点偏差 + /// + private void LogEndAlignmentDiagnostics() + { + try + { + if (_route == null || _pathPoints == null || _pathPoints.Count < 2 || _animationFrames == null || _animationFrames.Count == 0) + { + return; + } + + Point3D terminalAnchorPoint = _pathPoints[_pathPoints.Count - 1]; + Point3D previousAnchorPoint = _pathPoints[_pathPoints.Count - 2]; + var lastFrame = _animationFrames[_animationFrames.Count - 1]; + Point3D finalTrackedPoint = _trackedPosition; + + Point3D expectedTrackedEndPoint; + if (_route.PathType == PathType.Rail) + { + double objectHeight = GetAnimatedObjectRailNormalExtent(previousAnchorPoint, terminalAnchorPoint, terminalAnchorPoint); + if (!TryResolveRailPreservedPoseTrackedCenter(terminalAnchorPoint, previousAnchorPoint, terminalAnchorPoint, objectHeight, out expectedTrackedEndPoint)) + { + expectedTrackedEndPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + _route, + terminalAnchorPoint, + previousAnchorPoint, + terminalAnchorPoint, + objectHeight); + } + } + else if (_route.PathType == PathType.Hoisting) + { + expectedTrackedEndPoint = ResolveHoistingTrackedCenter( + terminalAnchorPoint, + GetAnimatedObjectGroundContactHeight(), + 0.0); + } + else + { + expectedTrackedEndPoint = ResolveGroundTrackedCenter( + terminalAnchorPoint, + GetAnimatedObjectGroundContactHeight()); + } + + Vector3D forward = new Vector3D( + terminalAnchorPoint.X - previousAnchorPoint.X, + terminalAnchorPoint.Y - previousAnchorPoint.Y, + terminalAnchorPoint.Z - previousAnchorPoint.Z); + + double forwardLength = Math.Sqrt(forward.X * forward.X + forward.Y * forward.Y + forward.Z * forward.Z); + if (forwardLength < 1e-9) + { + return; + } + + Vector3D normalizedForward = new Vector3D( + forward.X / forwardLength, + forward.Y / forwardLength, + forward.Z / forwardLength); + + Vector3D frameDelta = new Vector3D( + lastFrame.Position.X - expectedTrackedEndPoint.X, + lastFrame.Position.Y - expectedTrackedEndPoint.Y, + lastFrame.Position.Z - expectedTrackedEndPoint.Z); + Vector3D finalDelta = new Vector3D( + finalTrackedPoint.X - expectedTrackedEndPoint.X, + finalTrackedPoint.Y - expectedTrackedEndPoint.Y, + finalTrackedPoint.Z - expectedTrackedEndPoint.Z); + + double frameForwardDelta = Dot(frameDelta, normalizedForward); + double finalForwardDelta = Dot(finalDelta, normalizedForward); + + string pathLabel = _route.PathType.GetDisplayName(); + LogManager.Info( + $"[{pathLabel}终点诊断] 终点锚点=({terminalAnchorPoint.X:F3},{terminalAnchorPoint.Y:F3},{terminalAnchorPoint.Z:F3}), " + + $"期望跟踪点=({expectedTrackedEndPoint.X:F3},{expectedTrackedEndPoint.Y:F3},{expectedTrackedEndPoint.Z:F3}), " + + $"最后一帧跟踪点=({lastFrame.Position.X:F3},{lastFrame.Position.Y:F3},{lastFrame.Position.Z:F3}), " + + $"动画结束跟踪点=({finalTrackedPoint.X:F3},{finalTrackedPoint.Y:F3},{finalTrackedPoint.Z:F3})"); + LogManager.Info( + $"[{pathLabel}终点诊断] 前进方向=({normalizedForward.X:F4},{normalizedForward.Y:F4},{normalizedForward.Z:F4}), " + + $"最后一帧偏差=({frameDelta.X:F3},{frameDelta.Y:F3},{frameDelta.Z:F3}), 沿前进轴={frameForwardDelta:F3}, " + + $"结束偏差=({finalDelta.X:F3},{finalDelta.Y:F3},{finalDelta.Z:F3}), 沿前进轴={finalForwardDelta:F3}"); + } + catch (Exception ex) + { + LogManager.Warning($"[终点诊断] 输出失败: {ex.Message}"); + } + } + + private static double Dot(Vector3D a, Vector3D b) + { + return a.X * b.X + a.Y * b.Y + a.Z * b.Z; + } + /// /// 暂停动画 /// @@ -2041,9 +2682,10 @@ namespace NavisworksTransport.Core.Animation } /// - /// 更新对象位置和朝向(支持绕物体中心旋转) + /// 仅按业务跟踪点执行平移,不改变当前姿态语义。 + /// PreserveInitialPose 起点搬运等链路应显式使用此入口,而不是借用旧 yaw 链。 /// - private void UpdateObjectPosition(Point3D newPosition, double newYaw = double.NaN) + private void ApplyTrackedTranslationOnly(Point3D newPosition) { try { @@ -2052,59 +2694,19 @@ namespace NavisworksTransport.Core.Animation // 计算平移和旋转的增量 var deltaPos = new Vector3D( - newPosition.X - _currentPosition.X, - newPosition.Y - _currentPosition.Y, - newPosition.Z - _currentPosition.Z + newPosition.X - _trackedPosition.X, + newPosition.Y - _trackedPosition.Y, + newPosition.Z - _trackedPosition.Z ); - Transform3D incrementalTransform; - - if (!double.IsNaN(newYaw)) - { - // 有旋转:需要实现"绕物体当前位置自转" - // 由于Transform3DComponents.Rotation总是绕世界原点旋转 - // 我们需要手动计算旋转导致的位置偏移,并补偿 - - double deltaYaw = newYaw - _currentYaw; - //LogManager.Debug($"[UpdateObjectPosition] 当前yaw={_currentYaw * 180 / Math.PI:F2}度, 目标yaw={newYaw * 180 / Math.PI:F2}度, 旋转增量deltaYaw={deltaYaw * 180 / Math.PI:F2}度"); - - // 计算绕当前位置旋转的等效变换: - // 1. 如果绕原点旋转deltaYaw,当前位置_currentPosition会移动到哪里? - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = _currentPosition.X * cos - _currentPosition.Y * sin; - double rotatedY = _currentPosition.X * sin + _currentPosition.Y * cos; - - // 2. 但我们希望物体绕自己旋转,位置移动到newPosition - // 所以需要的平移 = newPosition - (旋转后的位置) - var compensatedTranslation = new Vector3D( - newPosition.X - rotatedX, - newPosition.Y - rotatedY, - newPosition.Z - _currentPosition.Z // Z保持deltaPos - ); - - // 3. 组合:先旋转(绕原点),再平移(补偿+目标位置) - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = compensatedTranslation; - - incrementalTransform = components.Combine(); - - _currentYaw = newYaw; - } - else - { - // 纯平移 - incrementalTransform = Transform3D.CreateTranslation(deltaPos); - LogManager.Debug($"[UpdateObjectPosition] 纯平移: ({deltaPos.X:F2},{deltaPos.Y:F2},{deltaPos.Z:F2})"); - } + Transform3D incrementalTransform = Transform3D.CreateTranslation(deltaPos); + LogManager.Debug($"[TrackedTranslation] 纯平移: ({deltaPos.X:F2},{deltaPos.Y:F2},{deltaPos.Z:F2})"); // 应用增量变换(false = 增量模式) doc.Models.OverridePermanentTransform(modelItems, incrementalTransform, false); - // 更新当前位置 - _currentPosition = newPosition; + // 更新跟踪位置 + _trackedPosition = newPosition; } catch (Exception ex) { @@ -2112,6 +2714,136 @@ namespace NavisworksTransport.Core.Animation } } + /// + /// 使用完整三维姿态更新对象位置。 + /// 当前先用于 Rail 路径,避免影响现有地面/吊装路径的 yaw 流程。 + /// + private void ApplyFullPoseUpdate(Point3D newPosition, Rotation3D newRotation) + { + try + { + ModelItem controlledObject = CurrentControlledObject; + bool isRailRealObject = _route?.PathType == PathType.Rail && IsRealObjectMode && controlledObject != null; + if (controlledObject == null) + { + return; + } + + if (IsVirtualObjectMode) + { + VirtualObjectManager.Instance.MoveVirtualObject(newPosition, newRotation); + _trackedPosition = newPosition; + _trackedRotation = newRotation; + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(newRotation); + + if (_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) + { + LogHostActualPoseAxes(CurrentControlledObject, "[平面姿态应用后宿主姿态]", false); + } + + return; + } + + Point3D currentPositionForTransform = _trackedPosition; + Rotation3D currentRotation; + Rotation3D appliedTargetRotation = newRotation; + Point3D appliedTargetPosition = newPosition; + if (_hasTrackedRotation) + { + currentRotation = _trackedRotation; + } + else if (IsRealObjectMode && _hasRealObjectReferenceRotation) + { + currentRotation = CoordinateSystemManager.Instance + .CreateHostAdapter() + .FromHostQuaternionDirect(_realObjectReferenceRotation); + } + else + { + currentRotation = controlledObject.Transform.Factor().Rotation; + } + + try + { + var actualHostPosition = GetLiveBoundingBoxCenter(controlledObject); + if (!(IsRealObjectMode && _route?.PathType == PathType.Ground)) + { + currentPositionForTransform = actualHostPosition; + } + LogManager.Info( + $"[动画姿态入口] {controlledObject.DisplayName} 宿主即时读回点=({actualHostPosition.X:F3},{actualHostPosition.Y:F3},{actualHostPosition.Z:F3})"); + + if (IsRealObjectMode && + (_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) && + ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out var actualGeometryRotation)) + { + var trackedLinear = new Transform3D(currentRotation).Linear; + var actualLinear = new Transform3D(actualGeometryRotation).Linear; + LogManager.Info( + $"[平面姿态基线诊断] {controlledObject.DisplayName} 跟踪旋转: " + + $"X=({trackedLinear.Get(0, 0):F4},{trackedLinear.Get(1, 0):F4},{trackedLinear.Get(2, 0):F4}), " + + $"Y=({trackedLinear.Get(0, 1):F4},{trackedLinear.Get(1, 1):F4},{trackedLinear.Get(2, 1):F4}), " + + $"Z=({trackedLinear.Get(0, 2):F4},{trackedLinear.Get(1, 2):F4},{trackedLinear.Get(2, 2):F4})"); + LogManager.Info( + $"[平面姿态基线诊断] {controlledObject.DisplayName} 实际几何旋转: " + + $"X=({actualLinear.Get(0, 0):F4},{actualLinear.Get(1, 0):F4},{actualLinear.Get(2, 0):F4}), " + + $"Y=({actualLinear.Get(0, 1):F4},{actualLinear.Get(1, 1):F4},{actualLinear.Get(2, 1):F4}), " + + $"Z=({actualLinear.Get(0, 2):F4},{actualLinear.Get(1, 2):F4},{actualLinear.Get(2, 2):F4})"); + } + } + catch (Exception ex) + { + LogManager.Warning($"[动画姿态入口] 读取宿主实际状态失败: {ex.Message}"); + } + + var currentLinear = new Transform3D(currentRotation).Linear; + var targetLinear = new Transform3D(appliedTargetRotation).Linear; + LogManager.Info( + $"[动画姿态入口] {controlledObject.DisplayName} 跟踪点=({_trackedPosition.X:F3},{_trackedPosition.Y:F3},{_trackedPosition.Z:F3}), " + + $"目标点=({appliedTargetPosition.X:F3},{appliedTargetPosition.Y:F3},{appliedTargetPosition.Z:F3})"); + LogManager.Info( + $"[动画姿态入口] {controlledObject.DisplayName} 跟踪姿态: " + + $"X=({currentLinear.Get(0, 0):F4},{currentLinear.Get(1, 0):F4},{currentLinear.Get(2, 0):F4}), " + + $"Y=({currentLinear.Get(0, 1):F4},{currentLinear.Get(1, 1):F4},{currentLinear.Get(2, 1):F4}), " + + $"Z=({currentLinear.Get(0, 2):F4},{currentLinear.Get(1, 2):F4},{currentLinear.Get(2, 2):F4})"); + LogManager.Info( + $"[动画姿态入口] {controlledObject.DisplayName} 目标姿态: " + + $"X=({targetLinear.Get(0, 0):F4},{targetLinear.Get(1, 0):F4},{targetLinear.Get(2, 0):F4}), " + + $"Y=({targetLinear.Get(0, 1):F4},{targetLinear.Get(1, 1):F4},{targetLinear.Get(2, 1):F4}), " + + $"Z=({targetLinear.Get(0, 2):F4},{targetLinear.Get(1, 2):F4},{targetLinear.Get(2, 2):F4})"); + + ModelItemTransformHelper.MoveItemIncrementallyToPositionAndRotation( + controlledObject, + currentPositionForTransform, + currentRotation, + appliedTargetPosition, + appliedTargetRotation); + + _trackedPosition = appliedTargetPosition; + _trackedRotation = appliedTargetRotation; + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(appliedTargetRotation); + + if (isRailRealObject) + { + Point3D hostActualAfter = GetLiveBoundingBoxCenter(controlledObject); + LogManager.Debug( + $"[Rail姿态应用] 宿主最终跟踪中心=({hostActualAfter.X:F3},{hostActualAfter.Y:F3},{hostActualAfter.Z:F3}), " + + $"目标跟踪中心=({newPosition.X:F3},{newPosition.Y:F3},{newPosition.Z:F3}), " + + $"偏差=({hostActualAfter.X - newPosition.X:F3},{hostActualAfter.Y - newPosition.Y:F3},{hostActualAfter.Z - newPosition.Z:F3})"); + } + else if (_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) + { + LogHostActualPoseAxes(CurrentControlledObject, "[平面姿态应用后宿主姿态]", false); + } + } + catch (Exception ex) + { + LogManager.Error($"使用完整姿态更新对象位置失败: {ex.Message}"); + } + } + /// /// 获取文档单位信息(用于日志记录) /// @@ -2201,12 +2933,7 @@ namespace NavisworksTransport.Core.Animation return (null, 0); } - var bbox = obj.BoundingBox(); - var position = new Point3D( - bbox.Center.X, - bbox.Center.Y, - bbox.Min.Z - ); + var position = _trackedPosition; // 🔥 关键:使用 _currentYaw(实际当前朝向),而不是 Transform 的 CAD 原始朝向 // Transform 返回的是 CAD 设计时的原始朝向,不是动画后的实际朝向 return (position, _currentYaw); @@ -2228,8 +2955,20 @@ namespace NavisworksTransport.Core.Animation var (position, yaw) = GetObjectCurrentPosition(obj); _savedObjectPosition = position; _savedObjectYaw = yaw; + _savedObjectRotation = _trackedRotation; + _savedObjectHasCustomRotation = _hasTrackedRotation; _hasSavedObjectState = true; - LogManager.Info($"已保存物体状态: pos=({position.X:F2},{position.Y:F2},{position.Z:F2}), yaw={yaw * 180 / Math.PI:F2}度"); + LogManager.Info($"已保存物体状态: pos=({position.X:F2},{position.Y:F2},{position.Z:F2}), yaw={yaw * 180 / Math.PI:F2}度, customRotation={_savedObjectHasCustomRotation}"); + if (_savedObjectHasCustomRotation) + { + var linear = new Transform3D(_savedObjectRotation).Linear; + LogManager.Info( + $"[PAM保存姿态] {obj.DisplayName} 保存目标姿态: " + + $"位置=({_savedObjectPosition.X:F3},{_savedObjectPosition.Y:F3},{_savedObjectPosition.Z:F3}), " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"); + } } catch (Exception ex) { @@ -2260,16 +2999,30 @@ namespace NavisworksTransport.Core.Animation var originalAnimatedObject = _animatedObject; _animatedObject = obj; - var bbox = obj.BoundingBox(); - _currentPosition = new Point3D(bbox.Center.X, bbox.Center.Y, bbox.Min.Z); - // 使用保存的朝向作为当前朝向,避免UpdateObjectPosition计算不必要的大角度差 - // 这样能确保物体直接从当前动画位置平滑恢复到保存位置,不会突然转动 - _currentYaw = _savedObjectYaw; + LogHostActualPoseAxes(obj, "[PAM恢复前宿主姿态]", false); - UpdateObjectPosition(_savedObjectPosition, _savedObjectYaw); + if (_savedObjectHasCustomRotation) + { + var linear = new Transform3D(_savedObjectRotation).Linear; + LogManager.Debug( + $"[PAM恢复姿态] {obj.DisplayName} 恢复目标姿态: " + + $"位置=({_savedObjectPosition.X:F3},{_savedObjectPosition.Y:F3},{_savedObjectPosition.Z:F3}), " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"); + } + + ApplyResolvedObjectPose( + _savedObjectPosition, + _savedObjectRotation, + _savedObjectHasCustomRotation, + -1, + planarHostYawRadians: _savedObjectYaw); + + LogHostActualPoseAxes(obj, "[PAM恢复后宿主姿态]", false); _animatedObject = originalAnimatedObject; - LogManager.Info($"已恢复物体状态: pos=({_savedObjectPosition.X:F2},{_savedObjectPosition.Y:F2},{_savedObjectPosition.Z:F2}), yaw={_savedObjectYaw * 180 / Math.PI:F2}度"); + LogManager.Debug($"已恢复物体状态: pos=({_savedObjectPosition.X:F2},{_savedObjectPosition.Y:F2},{_savedObjectPosition.Z:F2}), yaw={_savedObjectYaw * 180 / Math.PI:F2}度, customRotation={_savedObjectHasCustomRotation}"); } catch (Exception ex) { @@ -2277,6 +3030,112 @@ namespace NavisworksTransport.Core.Animation } } + /// + /// 判断给定对象是否由当前动画管理器控制。 + /// 对虚拟物体,判断当前激活的虚拟物体;对真实模型,判断当前动画对象引用。 + /// + public bool ControlsAnimatedObject(ModelItem obj) + { + if (obj == null) + { + return false; + } + + if (IsVirtualObjectMode) + { + return VirtualObjectManager.Instance.IsVirtualObjectActive && + ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, obj); + } + + return ReferenceEquals(_animatedObject, obj); + } + + /// + /// 使用当前动画链路将动画对象移动到指定姿态。 + /// 适用于碰撞点定位、报告截图等场景,确保与动画播放使用同一套位置/姿态应用语义。 + /// + public void MoveAnimatedObjectToPose( + ModelItem obj, + Point3D targetPosition, + double targetYawRadians, + Rotation3D targetRotation, + bool hasCustomRotation) + { + if (obj == null) + { + LogManager.Warning("MoveAnimatedObjectToPose: 对象为空"); + return; + } + + if (!ControlsAnimatedObject(obj)) + { + LogManager.Warning($"MoveAnimatedObjectToPose: 对象不受当前动画管理器控制: {obj.DisplayName}"); + return; + } + + try + { + var originalAnimatedObject = _animatedObject; + if (!IsVirtualObjectMode) + { + _animatedObject = obj; + } + + LogHostActualPoseAxes(obj, "[动画姿态复用前宿主姿态]", false); + + ApplyResolvedObjectPose( + targetPosition, + targetRotation, + hasCustomRotation, + -1, + planarHostYawRadians: targetYawRadians); + + LogHostActualPoseAxes(obj, "[动画姿态复用后宿主姿态]", false); + + _animatedObject = originalAnimatedObject; + + LogManager.Debug( + $"[动画姿态复用] {obj.DisplayName} 已移动到目标姿态: " + + $"位置=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), customRotation={hasCustomRotation}"); + } + catch (Exception ex) + { + LogManager.Error($"MoveAnimatedObjectToPose: 应用目标姿态失败: {ex.Message}", ex); + throw; + } + } + + private void LogHostActualPoseAxes(ModelItem obj, string prefix, bool important) + { + if (obj == null) + { + return; + } + + try + { + Transform3D transform = obj.Transform; + Matrix3 linear = transform.Linear; + string message = + $"{prefix} {obj.DisplayName}: " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"; + if (important) + { + LogManager.Info(message); + } + else + { + LogManager.Debug(message); + } + } + catch (Exception ex) + { + LogManager.Warning($"{prefix} 读取失败: {ex.Message}"); + } + } + /// /// 获取播放方向(1=正向,-1=反向) /// @@ -2341,9 +3200,9 @@ namespace NavisworksTransport.Core.Animation _currentFrameIndex = frameIndex; - // 更新对象位置和朝向 + // 更新对象位置和朝向,复用动画主播放与恢复使用的同一入口。 var frameData = _animationFrames[_currentFrameIndex]; - UpdateObjectPosition(frameData.Position, frameData.YawRadians); + ApplyRecordedPose(frameData, _currentFrameIndex); // 更新碰撞高亮 UpdateCollisionHighlightFromFrame(); @@ -2518,7 +3377,7 @@ namespace NavisworksTransport.Core.Animation // 清空对象引用 _animatedObject = null; _pathPoints?.Clear(); - _currentPosition = new Point3D(0, 0, 0); + _trackedPosition = new Point3D(0, 0, 0); _originalCenter = new Point3D(0, 0, 0); LogManager.Info("[PathAnimationManager] 对象引用已清理"); @@ -2720,6 +3579,83 @@ namespace NavisworksTransport.Core.Animation /// 用于保存物体当前状态(因为Transform返回的是CAD原始值) /// public double CurrentYaw => _currentYaw; + public Rotation3D TrackedRotation => _trackedRotation; + public bool HasTrackedRotation => _hasTrackedRotation; + + /// + /// 获取当前动画物体高度(模型单位) + /// + private double GetAnimatedObjectHeight() + { + if (IsVirtualObjectMode) + { + return _virtualObjectHeight; + } + + if (_realObjectHeight > 0.0) + { + return _realObjectHeight; + } + + if (_animatedObject == null) + { + throw new InvalidOperationException("动画对象为空,无法获取物体高度"); + } + + var boundingBox = _animatedObject.BoundingBox(); + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + return ModelItemTransformHelper.GetHostHeight(boundingBox, adapter); + } + + /// + /// 获取当前姿态下,地面/吊装路径用于“接触点 -> 几何中心”换算的有效法线尺寸(模型单位)。 + /// 与通行空间使用的旋转后法线尺寸保持一致,避免物体旋转后仍按原始高度抬升中心。 + /// + private double GetAnimatedObjectGroundContactHeight() + { + double rawHeight = GetAnimatedObjectHeight(); + if (_objectRotationCorrection.IsZero) + { + return rawHeight; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ModelAxisConvention axisConvention; + double forwardSize; + double sideSize; + double upSize; + + if (IsVirtualObjectMode) + { + axisConvention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); + forwardSize = _virtualObjectLength; + sideSize = _virtualObjectWidth; + upSize = _virtualObjectHeight; + } + else if (IsRealObjectMode && _realObjectLength > 0.0 && _realObjectWidth > 0.0 && _realObjectHeight > 0.0) + { + axisConvention = ModelAxisConvention.CreateDefaultForHost(adapter.HostType); + forwardSize = _realObjectLength; + sideSize = _realObjectWidth; + upSize = _realObjectHeight; + } + else + { + return rawHeight; + } + + Quaternion correctionQuaternion = IsVirtualObjectMode + ? adapter.CreateCanonicalRotationCorrection(_objectRotationCorrection) + : adapter.CreateHostRotationCorrection(_objectRotationCorrection); + var projectedExtents = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + axisConvention, + forwardSize, + sideSize, + upSize, + correctionQuaternion); + + return projectedExtents.upExtent; + } /// /// 获取当前碰撞检测精度 @@ -2792,7 +3728,7 @@ namespace NavisworksTransport.Core.Animation _pathName = pathName; _currentRouteId = routeId; - _isVirtualObject = isVirtualObject; // 设置是否使用虚拟物体 + _animatedObjectMode = isVirtualObject ? AnimatedObjectMode.VirtualObject : AnimatedObjectMode.RealObject; _virtualObjectLength = virtualObjectLength; // 模型单位 _virtualObjectWidth = virtualObjectWidth; // 模型单位 _virtualObjectHeight = virtualObjectHeight; // 模型单位 @@ -2805,16 +3741,16 @@ namespace NavisworksTransport.Core.Animation { _originalTransform = animatedObject.Transform; _originalCenter = animatedObject.BoundingBox().Center; - _currentPosition = new Point3D(_originalCenter.X, _originalCenter.Y, animatedObject.BoundingBox().Min.Z); + _trackedPosition = ResolveInitialBusinessTrackedPosition(animatedObject); // 保持当前的 _currentYaw(因为物体可能已经被 MoveObjectToPathStart 旋转) // 不要从 Transform 中提取,因为 Transform 返回的是原始值,不是当前值 - LogManager.Debug($"[CreateAnimation] 保持_currentYaw={_currentYaw * 180 / Math.PI:F2}度不变, _isVirtualObject={_isVirtualObject}"); + LogManager.Debug($"[CreateAnimation] 保持_currentYaw={_currentYaw * 180 / Math.PI:F2}度不变, 模式={_animatedObjectMode}"); } // 设置动画参数并预计算动画帧 // 注意:物体应该在调用CreateAnimation之前已经通过MoveObjectToPathStart旋转到起点 - LogManager.Debug($"[CreateAnimation开始] _currentYaw之前={_currentYaw * 180 / Math.PI:F2}度, _isVirtualObject={_isVirtualObject}"); + LogManager.Debug($"[CreateAnimation开始] _currentYaw之前={_currentYaw * 180 / Math.PI:F2}度, 模式={_animatedObjectMode}"); SetupAnimation(animatedObject, durationSeconds, _route); LogManager.Debug($"[CreateAnimation结束] _currentYaw之后={_currentYaw * 180 / Math.PI:F2}度"); // 设置动画状态为Ready(动画已生成,可以播放) @@ -2822,12 +3758,1645 @@ namespace NavisworksTransport.Core.Animation LogManager.Info($"[CreateAnimation] 动画已创建,状态设置为Ready"); } - /// - /// 设置物体角度修正值(度,顺时针) - /// - /// 角度修正值(度) - public void SetObjectRotationCorrection(double rotationCorrection) + private Point3D GetLiveBoundingBoxCenter(ModelItem item) { + if (item == null) + { + return new Point3D(0, 0, 0); + } + + var bounds = item.BoundingBox(); + return bounds?.Center ?? new Point3D(0, 0, 0); + } + + private Point3D ResolveInitialBusinessTrackedPosition(ModelItem item) + { + if (item == null) + { + return new Point3D(0, 0, 0); + } + + if (ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + _route?.PathType ?? PathType.Ground, + IsRealObjectMode) && + _originalCenter != null) + { + return _originalCenter; + } + + return GetLiveBoundingBoxCenter(item); + } + + private Point3D ResolveGroundTrackedCenter(Point3D groundContactPoint, double objectHeight) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePoint = ToNumerics(adapter.ToCanonicalPoint(groundContactPoint)); + var canonicalCenter = CanonicalTrackedPositionResolver.ResolveGroundTrackedCenter( + canonicalReferencePoint, + HostCoordinateAdapter.CanonicalUpVector3, + objectHeight); + return adapter.FromCanonicalPoint(new Point3D(canonicalCenter.X, canonicalCenter.Y, canonicalCenter.Z)); + } + + private Point3D ResolveHoistingTrackedCenter(Point3D referencePoint, double objectHeight, double referenceContactFactor) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePoint = ToNumerics(adapter.ToCanonicalPoint(referencePoint)); + var canonicalCenter = CanonicalTrackedPositionResolver.ResolveCenterFromContactReference( + canonicalReferencePoint, + HostCoordinateAdapter.CanonicalUpVector3, + objectHeight, + referenceContactFactor); + return adapter.FromCanonicalPoint(new Point3D(canonicalCenter.X, canonicalCenter.Y, canonicalCenter.Z)); + } + + private bool TryResolveRailPreservedPoseTrackedCenter( + Point3D referencePoint, + Point3D previousPoint, + Point3D nextPoint, + double objectHeight, + out Point3D trackedCenter) + { + trackedCenter = referencePoint; + + if (!IsRealObjectMode || + _route?.PathType != PathType.Rail || + _objectStartPlacementMode != ObjectStartPlacementMode.PreserveInitialPose || + !_hasRailPreservedPoseTrackedCenterOffset) + { + return false; + } + + trackedCenter = RailPathPoseHelper.ResolvePreservedTrackedCenterPosition( + _route, + referencePoint, + previousPoint, + nextPoint, + objectHeight, + _railPreservedPoseTrackedCenterOffset); + return true; + } + + private void CaptureRailPreservedPoseTrackedCenterOffset( + Point3D previousPoint, + Point3D referencePoint, + Point3D nextPoint, + double objectHeight) + { + _railPreservedPoseTrackedCenterOffset = new Vector3D(0, 0, 0); + _hasRailPreservedPoseTrackedCenterOffset = false; + + if (!IsRealObjectMode || + _route?.PathType != PathType.Rail || + _objectStartPlacementMode != ObjectStartPlacementMode.PreserveInitialPose || + CurrentControlledObject == null) + { + return; + } + + Point3D currentTrackedCenter = GetLiveBoundingBoxCenter(CurrentControlledObject ?? _animatedObject); + Point3D semanticTrackedCenter = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + _route, + referencePoint, + previousPoint, + nextPoint, + objectHeight); + _railPreservedPoseTrackedCenterOffset = RailPathPoseHelper.CalculatePreservedTrackedCenterOffset( + _route, + currentTrackedCenter, + referencePoint, + previousPoint, + nextPoint, + objectHeight); + _hasRailPreservedPoseTrackedCenterOffset = true; + + LogManager.Info( + $"[Rail终点原位残差] 当前跟踪中心=({currentTrackedCenter.X:F3},{currentTrackedCenter.Y:F3},{currentTrackedCenter.Z:F3}), " + + $"终点路径参考对应中心=({semanticTrackedCenter.X:F3},{semanticTrackedCenter.Y:F3},{semanticTrackedCenter.Z:F3}), " + + $"固定偏移=({_railPreservedPoseTrackedCenterOffset.X:F3},{_railPreservedPoseTrackedCenterOffset.Y:F3},{_railPreservedPoseTrackedCenterOffset.Z:F3})"); + } + + internal static Rotation3D ResolvePreservedPoseRotation( + bool preferActualGeometryRotation, + bool hasActualGeometryRotation, + Rotation3D actualGeometryRotation, + Rotation3D fallbackRotation) + { + return preferActualGeometryRotation && hasActualGeometryRotation + ? actualGeometryRotation + : fallbackRotation; + } + + internal static bool ShouldPreservePathRotationForFrames( + PathType pathType, + ObjectStartPlacementMode placementMode, + bool hasPreservedRotation) + { + if (!hasPreservedRotation || placementMode != ObjectStartPlacementMode.PreserveInitialPose) + { + return false; + } + + return pathType == PathType.Rail || pathType == PathType.Hoisting; + } + + internal static bool ShouldAllowFragmentPlanarFallback(PathType pathType) + { + return pathType != PathType.Hoisting && pathType != PathType.Ground; + } + + internal static bool ShouldUseReferenceBasedRealObjectPlanarPose(PathType pathType) + { + return pathType != PathType.Ground && pathType != PathType.Hoisting; + } + + internal static bool ShouldUsePlanarRealObjectPureIncrementFrames(PathType pathType, bool isRealObjectMode) + { + return isRealObjectMode && + (pathType == PathType.Ground || pathType == PathType.Hoisting); + } + + internal static void ApplyRecordedPlanarRealObjectFramePose( + AnimationFrame frame, + double planarHostYawRadians, + Rotation3D recordedRotation) + { + if (frame == null) + { + throw new ArgumentNullException(nameof(frame)); + } + + if (recordedRotation == null) + { + throw new ArgumentNullException(nameof(recordedRotation)); + } + + frame.PlanarHostYawRadians = planarHostYawRadians; + frame.Rotation = recordedRotation; + frame.HasCustomRotation = true; + } + + internal static bool TryResolveDisplayedPlanarYawFromRotation( + Rotation3D rotation, + CoordinateSystemType hostType, + out double yawRadians) + { + yawRadians = 0.0; + if (rotation == null) + { + return false; + } + + Matrix3 linear = new Transform3D(rotation).Linear; + Vector3 hostForward = new Vector3( + (float)linear.Get(0, 0), + (float)linear.Get(1, 0), + (float)linear.Get(2, 0)); + + return PathTargetFrameResolver.TryResolvePlanarHostYaw(hostForward, hostType, out yawRadians); + } + + internal static double ResolvePlanarHostUpCorrectionRadians( + LocalEulerRotationCorrection correction, + CoordinateSystemType hostType) + { + double degrees = hostType == CoordinateSystemType.YUp + ? correction.YDegrees + : correction.ZDegrees; + return degrees * Math.PI / 180.0; + } + + internal static bool TryResolveGroundHostPlanarYawFromFramePoints( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + CoordinateSystemType hostType, + out double yawRadians) + { + yawRadians = 0.0; + + Vector3 hostForward = new Vector3( + (float)(nextPoint.X - previousPoint.X), + (float)(nextPoint.Y - previousPoint.Y), + (float)(nextPoint.Z - previousPoint.Z)); + if (hostForward.LengthSquared() < 1e-6f) + { + hostForward = new Vector3( + (float)(nextPoint.X - currentPoint.X), + (float)(nextPoint.Y - currentPoint.Y), + (float)(nextPoint.Z - currentPoint.Z)); + } + + if (hostForward.LengthSquared() < 1e-6f) + { + return false; + } + + return PathTargetFrameResolver.TryResolvePlanarHostYaw(hostForward, hostType, out yawRadians); + } + + internal static Rotation3D ResolveCurrentRotationBaseline( + PathType pathType, + bool isRealObjectMode, + bool hasTrackedRotation, + Rotation3D trackedRotation, + bool hasReferenceRotation, + Rotation3D referenceRotation, + Rotation3D transformRotation, + bool hasActualGeometryRotation, + Rotation3D actualGeometryRotation) + { + if (isRealObjectMode && + pathType == PathType.Hoisting && + hasActualGeometryRotation) + { + return actualGeometryRotation; + } + + if (hasTrackedRotation) + { + return trackedRotation; + } + + if (isRealObjectMode && hasReferenceRotation) + { + return referenceRotation; + } + + return transformRotation; + } + + internal static bool ShouldUseRealObjectOverrideRotation(bool isRealObjectMode) + { + return isRealObjectMode; + } + + internal static bool ShouldUseOriginalBoundingBoxCenterForBusinessTrackedPosition( + PathType pathType, + bool isRealObjectMode) + { + return isRealObjectMode && pathType == PathType.Ground; + } + + private void SyncTrackedRotationToDisplayedPose(ModelItem sourceObject) + { + if (sourceObject == null) + { + return; + } + + Rotation3D actualGeometryRotation = Rotation3D.Identity; + bool hasActualGeometryRotation = + IsRealObjectMode && + ModelItemTransformHelper.TryGetCurrentGeometryRotation(sourceObject, out actualGeometryRotation); + + Rotation3D resolvedRotation = ResolvePreservedPoseRotation( + preferActualGeometryRotation: IsRealObjectMode, + hasActualGeometryRotation: hasActualGeometryRotation, + actualGeometryRotation: actualGeometryRotation, + fallbackRotation: _trackedRotation); + + _trackedRotation = resolvedRotation; + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(resolvedRotation); + + var linear = new Transform3D(resolvedRotation).Linear; + LogManager.Info( + $"[平移保持终点原始姿态] {sourceObject.DisplayName} 锁定姿态: " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4}), " + + $"来源={(hasActualGeometryRotation ? "实际几何姿态" : "跟踪姿态")}"); + } + + private bool TryResolvePlanarRealObjectBaseYaw(PathType pathType, out double yawRadians) + { + yawRadians = 0.0; + if (_route?.PathType != pathType || + !IsRealObjectMode || + _pathPoints == null || + _pathPoints.Count < 2) + { + return false; + } + + var hostType = CoordinateSystemManager.Instance.ResolvedType; + var hostPoints = new List(_pathPoints.Count); + for (int i = 0; i < _pathPoints.Count; i++) + { + hostPoints.Add(new Vector3((float)_pathPoints[i].X, (float)_pathPoints[i].Y, (float)_pathPoints[i].Z)); + } + + return PathTargetFrameResolver.TryResolvePlanarStartHostYaw(pathType, hostPoints, hostType, out yawRadians); + } + + private bool TryCreateGroundRealObjectConstrainedRotation( + Point3D previousFramePoint, + Point3D currentFramePoint, + Point3D nextFramePoint, + out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + if (!IsRealObjectMode || + _route?.PathType != PathType.Ground || + !_hasGroundRealObjectBasePose) + { + return false; + } + + if (!PathTargetFrameResolver.TryCreatePlanarHostFrame( + new Vector3( + (float)(nextFramePoint.X - previousFramePoint.X), + (float)(nextFramePoint.Y - previousFramePoint.Y), + (float)(nextFramePoint.Z - previousFramePoint.Z)), + CoordinateSystemManager.Instance.ResolvedType, + out var currentFrame)) + { + return false; + } + + if (!PathTargetFrameResolver.TryResolvePlanarHostYaw( + currentFrame.Forward, + CoordinateSystemManager.Instance.ResolvedType, + out double targetYawRadians)) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 hostUp = Vector3.Normalize(adapter.HostUpVector3); + double deltaYaw = NormalizeRadians(targetYawRadians - _groundRealObjectBaseYaw); + Quaternion deltaQuaternion = Quaternion.CreateFromAxisAngle(hostUp, (float)deltaYaw); + Quaternion baseQuaternion = Rotation3DToHostQuaternion(_groundRealObjectBaseRotation); + Quaternion targetQuaternion = Quaternion.Normalize(deltaQuaternion * baseQuaternion); + rotation = adapter.FromHostQuaternionDirect(targetQuaternion); + return true; + } + + private bool TryCreateGroundRealObjectConstrainedRotationFromHostForward( + Vector3 hostForward, + out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + if (!IsRealObjectMode || + _route?.PathType != PathType.Ground || + !_hasGroundRealObjectBasePose) + { + return false; + } + + if (!PathTargetFrameResolver.TryCreatePlanarHostFrame( + hostForward, + CoordinateSystemManager.Instance.ResolvedType, + out var currentFrame)) + { + return false; + } + + if (!PathTargetFrameResolver.TryResolvePlanarHostYaw( + currentFrame.Forward, + CoordinateSystemManager.Instance.ResolvedType, + out double targetYawRadians)) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 hostUp = Vector3.Normalize(adapter.HostUpVector3); + double deltaYaw = NormalizeRadians(targetYawRadians - _groundRealObjectBaseYaw); + Quaternion deltaQuaternion = Quaternion.CreateFromAxisAngle(hostUp, (float)deltaYaw); + Quaternion baseQuaternion = Rotation3DToHostQuaternion(_groundRealObjectBaseRotation); + Quaternion targetQuaternion = Quaternion.Normalize(deltaQuaternion * baseQuaternion); + rotation = adapter.FromHostQuaternionDirect(targetQuaternion); + return true; + } + + private bool TryResolveRecordedPlanarRealObjectFrameRotation( + PathType pathType, + Point3D previousFramePoint, + Point3D currentFramePoint, + Point3D nextFramePoint, + out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + if (!IsRealObjectMode) + { + return false; + } + + if (pathType == PathType.Ground) + { + return TryCreateGroundRealObjectConstrainedRotation( + previousFramePoint, + currentFramePoint, + nextFramePoint, + out rotation); + } + + if (pathType == PathType.Hoisting) + { + Vector3 hostForward = new Vector3( + (float)(nextFramePoint.X - previousFramePoint.X), + (float)(nextFramePoint.Y - previousFramePoint.Y), + (float)(nextFramePoint.Z - previousFramePoint.Z)); + + if (hostForward.LengthSquared() <= 1e-6f) + { + return false; + } + + return TryResolveHoistingActualPose( + hostForward, + out rotation, + out _, + out _, + out _, + out _); + } + + return false; + } + + private bool TryResolveHoistingActualPose( + Vector3 hostForward, + out Rotation3D rotation, + out Quaternion baselineQuaternion, + out LocalAxisDirection selectedAxisDirection, + out Vector3 selectedAxisLocal, + out Vector3 projectedForward) + { + rotation = Rotation3D.Identity; + baselineQuaternion = Quaternion.Identity; + selectedAxisDirection = LocalAxisDirection.PositiveX; + selectedAxisLocal = Vector3.UnitX; + projectedForward = Vector3.Zero; + if (!TryResolveHoistingStartBaselinePose( + hostForward, + out baselineQuaternion, + out selectedAxisDirection, + out selectedAxisLocal, + out projectedForward)) + { + rotation = Rotation3D.Identity; + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Quaternion finalHostQuaternion = adapter.ComposeHostQuaternion( + baselineQuaternion, + _objectRotationCorrection); + rotation = adapter.FromHostQuaternionDirect(finalHostQuaternion); + + Matrix4x4 hostBaselineLocalAxesLinear = Matrix4x4.CreateFromQuaternion(baselineQuaternion); + Matrix4x4 hostComposedLocalAxesLinear = Matrix4x4.CreateFromQuaternion(finalHostQuaternion); + + LogManager.Debug( + $"[真实物体平面前进轴] Hoisting 已改用实际几何姿态基线,选中对象轴={selectedAxisDirection}。"); + LogManager.Debug( + $"[真实物体起点姿态] 选中参考轴=({selectedAxisLocal.X:F3},{selectedAxisLocal.Y:F3},{selectedAxisLocal.Z:F3}), " + + $"投影前进=({projectedForward.X:F3},{projectedForward.Y:F3},{projectedForward.Z:F3}), " + + $"宿主修正={_objectRotationCorrection}, 本地修正=X=0.0°,Y=0.0°,Z=0.0°, " + + $"hostBaselineLocalXAxis=({hostBaselineLocalAxesLinear.M11:F4},{hostBaselineLocalAxesLinear.M21:F4},{hostBaselineLocalAxesLinear.M31:F4}), " + + $"hostBaselineLocalYAxis=({hostBaselineLocalAxesLinear.M12:F4},{hostBaselineLocalAxesLinear.M22:F4},{hostBaselineLocalAxesLinear.M32:F4}), " + + $"hostBaselineLocalZAxis=({hostBaselineLocalAxesLinear.M13:F4},{hostBaselineLocalAxesLinear.M23:F4},{hostBaselineLocalAxesLinear.M33:F4}), " + + $"hostComposedLocalXAxis=({hostComposedLocalAxesLinear.M11:F4},{hostComposedLocalAxesLinear.M21:F4},{hostComposedLocalAxesLinear.M31:F4}), " + + $"hostComposedLocalYAxis=({hostComposedLocalAxesLinear.M12:F4},{hostComposedLocalAxesLinear.M22:F4},{hostComposedLocalAxesLinear.M32:F4}), " + + $"hostComposedLocalZAxis=({hostComposedLocalAxesLinear.M13:F4},{hostComposedLocalAxesLinear.M23:F4},{hostComposedLocalAxesLinear.M33:F4})"); + return true; + } + + private bool TryResolveHoistingStartBaselinePose( + Vector3 hostForward, + out Quaternion baselineQuaternion, + out LocalAxisDirection selectedAxisDirection, + out Vector3 selectedAxisLocal, + out Vector3 projectedForward) + { + baselineQuaternion = Quaternion.Identity; + selectedAxisDirection = _hasRealObjectPlanarSelectedForwardAxis + ? _realObjectPlanarSelectedForwardAxis + : LocalAxisDirection.PositiveX; + selectedAxisLocal = Vector3.UnitX; + projectedForward = Vector3.Zero; + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 hostUp = adapter.HostType == CoordinateSystemType.YUp ? Vector3.UnitY : Vector3.UnitZ; + Vector3 normalizedHostForward = hostForward.LengthSquared() > 1e-6f + ? Vector3.Normalize(hostForward) + : Vector3.UnitX; + Vector3 planarProjectedForward = normalizedHostForward - Vector3.Dot(normalizedHostForward, hostUp) * hostUp; + projectedForward = planarProjectedForward.LengthSquared() > 1e-6f + ? Vector3.Normalize(planarProjectedForward) + : Vector3.UnitX; + Rotation3D sourceRotation; + if (_animatedObject == null || + !ModelItemTransformHelper.TryGetCurrentGeometryRotation(_animatedObject, out sourceRotation)) + { + return false; + } + + Quaternion sourceQuaternion = Rotation3DToHostQuaternion(sourceRotation); + return HoistingRealObjectPoseHelper.TryCreateRotationFromActualPose( + sourceQuaternion, + projectedForward, + hostUp, + out baselineQuaternion, + out selectedAxisDirection, + out selectedAxisLocal, + out projectedForward); + } + + private static Quaternion Rotation3DToHostQuaternion(Rotation3D rotation) + { + var linear = new Transform3D(rotation).Linear; + var matrix = new Matrix4x4( + (float)linear.Get(0, 0), (float)linear.Get(0, 1), (float)linear.Get(0, 2), 0f, + (float)linear.Get(1, 0), (float)linear.Get(1, 1), (float)linear.Get(1, 2), 0f, + (float)linear.Get(2, 0), (float)linear.Get(2, 1), (float)linear.Get(2, 2), 0f, + 0f, 0f, 0f, 1f); + return Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(matrix)); + } + + private bool TryResolveGroundRealObjectBaseYaw(out double yawRadians) + { + yawRadians = 0.0; + if (_route?.PathType != PathType.Ground || + !IsRealObjectMode || + _pathPoints == null || + _pathPoints.Count < 2) + { + return false; + } + + var hostType = CoordinateSystemManager.Instance.ResolvedType; + var hostPoints = new List(_pathPoints.Count); + for (int i = 0; i < _pathPoints.Count; i++) + { + hostPoints.Add(new Vector3((float)_pathPoints[i].X, (float)_pathPoints[i].Y, (float)_pathPoints[i].Z)); + } + + return PathTargetFrameResolver.TryResolvePlanarStartHostYaw(_route.PathType, hostPoints, hostType, out yawRadians); + } + + private bool TryApplyPlanarRealObjectStartIncrementalTransform( + PathType pathType, + Point3D targetTrackedPosition, + out double targetYawRadians) + { + targetYawRadians = 0.0; + + if (!IsRealObjectMode || + (pathType != PathType.Ground && pathType != PathType.Hoisting) || + _route?.PathType != pathType || + CurrentControlledObject == null || + _pathPoints == null || + _pathPoints.Count < 2) + { + return false; + } + + if (!PathTargetFrameResolver.TryResolvePlanarStartHostForward( + pathType, + _pathPoints, + out var hostForward)) + { + LogManager.Error($"[{pathType.GetDisplayName()}纯增量起点] 无法解析路径起始方向。"); + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (!PathTargetFrameResolver.TryResolvePlanarHostYaw(hostForward, adapter.HostType, out targetYawRadians)) + { + LogManager.Error($"[{pathType.GetDisplayName()}纯增量起点] 无法从路径方向解析宿主平面角。"); + return false; + } + + ApplyPlanarNonUpAxisCorrectionsAtTrackedPosition(pathType, _trackedPosition, adapter.HostType); + + double upAxisCorrectionRadians = ResolvePlanarHostUpCorrectionRadians( + _objectRotationCorrection, + adapter.HostType); + targetYawRadians = NormalizeRadians(targetYawRadians + upAxisCorrectionRadians); + double currentYawRadians = _currentYaw; + double deltaYawRadians = NormalizeRadians(targetYawRadians - currentYawRadians); + LogManager.Info( + $"[{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})"); + + ModelItemTransformHelper.MoveItemIncrementallyByAxisRotationAndTranslation( + CurrentControlledObject, + _trackedPosition, + adapter.HostUpVector3, + deltaYawRadians, + targetTrackedPosition); + + _trackedPosition = targetTrackedPosition; + + if (ModelItemTransformHelper.TryGetCurrentGeometryRotation(CurrentControlledObject, out var appliedRotation)) + { + _trackedRotation = appliedRotation; + _hasTrackedRotation = true; + } + + _currentYaw = targetYawRadians; + LogHostActualPoseAxes(CurrentControlledObject, $"[{pathType.GetDisplayName()}纯增量起点应用后宿主姿态]", false); + return true; + } + + private void ApplyPlanarNonUpAxisCorrectionsAtTrackedPosition( + PathType pathType, + Point3D trackedPosition, + CoordinateSystemType hostType) + { + if (CurrentControlledObject == null || _objectRotationCorrection.IsZero) + { + return; + } + + var correctionSteps = new List<(string AxisName, Vector3 HostAxis, double AngleRadians)>(); + correctionSteps.Add(("X", Vector3.UnitX, _objectRotationCorrection.XDegrees * Math.PI / 180.0)); + + if (hostType == CoordinateSystemType.YUp) + { + correctionSteps.Add(("Z", Vector3.UnitZ, _objectRotationCorrection.ZDegrees * Math.PI / 180.0)); + } + else + { + correctionSteps.Add(("Y", Vector3.UnitY, _objectRotationCorrection.YDegrees * Math.PI / 180.0)); + } + + foreach (var step in correctionSteps) + { + if (Math.Abs(step.AngleRadians) < 1e-9) + { + continue; + } + + LogManager.Info( + $"[{pathType.GetDisplayName()}纯增量起点角度修正] 宿主{step.AxisName}轴增量={step.AngleRadians * 180.0 / Math.PI:F2}°, " + + $"跟踪点=({trackedPosition.X:F3},{trackedPosition.Y:F3},{trackedPosition.Z:F3})"); + + ModelItemTransformHelper.MoveItemIncrementallyByAxisRotationAndTranslation( + CurrentControlledObject, + trackedPosition, + step.HostAxis, + step.AngleRadians, + trackedPosition); + } + } + + private static double NormalizeRadians(double angle) + { + while (angle > Math.PI) + { + angle -= 2.0 * Math.PI; + } + + while (angle < -Math.PI) + { + angle += 2.0 * Math.PI; + } + + return angle; + } + + private static Vector3 ToNumerics(Point3D point) + { + return new Vector3((float)point.X, (float)point.Y, (float)point.Z); + } + + private bool TryGetCurrentRailModelAxisConvention( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out ModelAxisConvention convention) + { + convention = null; + + if (IsVirtualObjectMode) + { + convention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); + return true; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (IsRealObjectMode && _hasRealObjectReferenceRotation) + { + if (PathTargetFrameResolver.TryCreateRailHostFrame(_route, previousPoint, currentPoint, nextPoint, out var targetFrame) && + RealObjectRailAxisConventionResolver.TryResolve( + _realObjectReferenceAxisX, + _realObjectReferenceAxisY, + _realObjectReferenceAxisZ, + _realObjectReferenceHostUpLocalAxis, + targetFrame.Forward, + adapter.HostType, + out convention, + out var selectedForwardAxis, + out var selectedForwardWorldAxis, + out var selectedUpAxis, + out var selectedUpWorldAxis)) + { + LogManager.Debug( + $"[Rail姿态修正] Host={adapter.HostType}, 虚拟物体={IsVirtualObjectMode}, " + + $"真实物体参考姿态生效, Forward={convention.ForwardAxis}, Up={convention.UpAxis}, " + + $"选中Forward轴={selectedForwardAxis}, 选中Forward世界轴=({selectedForwardWorldAxis.X:F4},{selectedForwardWorldAxis.Y:F4},{selectedForwardWorldAxis.Z:F4}), " + + $"选中Up轴={selectedUpAxis}, 选中Up世界轴=({selectedUpWorldAxis.X:F4},{selectedUpWorldAxis.Y:F4},{selectedUpWorldAxis.Z:F4}), " + + $"宿主Up对应本地轴={_realObjectReferenceHostUpLocalAxis}, " + + $"目标Forward=({targetFrame.Forward.X:F4},{targetFrame.Forward.Y:F4},{targetFrame.Forward.Z:F4}), " + + $"目标Up=({targetFrame.Up.X:F4},{targetFrame.Up.Y:F4},{targetFrame.Up.Z:F4})"); + return true; + } + } + + convention = ModelAxisConvention.CreateDefaultForHost(adapter.HostType); + LogManager.Debug( + $"[Rail姿态修正] Host={adapter.HostType}, 虚拟物体={IsVirtualObjectMode}, " + + $"Forward={convention.ForwardAxis}, Up={convention.UpAxis}, 来源=默认轴约定"); + return true; + } + + private static Vector3 ResolveReferenceWorldAxisForLocalDirection( + LocalAxisDirection axis, + Vector3 referenceAxisX, + Vector3 referenceAxisY, + Vector3 referenceAxisZ) + { + switch (axis) + { + case LocalAxisDirection.PositiveX: return Vector3.Normalize(referenceAxisX); + case LocalAxisDirection.NegativeX: return Vector3.Normalize(-referenceAxisX); + case LocalAxisDirection.PositiveY: return Vector3.Normalize(referenceAxisY); + case LocalAxisDirection.NegativeY: return Vector3.Normalize(-referenceAxisY); + case LocalAxisDirection.PositiveZ: return Vector3.Normalize(referenceAxisZ); + case LocalAxisDirection.NegativeZ: return Vector3.Normalize(-referenceAxisZ); + default: + throw new ArgumentOutOfRangeException(nameof(axis), axis, null); + } + } + + private bool TryCalculateCurrentRealObjectRailProjectedExtents( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out ModelAxisConvention convention, + out (double forwardExtent, double sideExtent, double upExtent) extents) + { + convention = null; + extents = (0.0, 0.0, 0.0); + + if (!IsRealObjectMode || + !_hasRealObjectReferenceRotation || + _realObjectLength <= 0.0 || + _realObjectWidth <= 0.0 || + _realObjectHeight <= 0.0) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (!PathTargetFrameResolver.TryCreateRailHostFrame(_route, previousPoint, currentPoint, nextPoint, out var targetFrame)) + { + return false; + } + + if (!TryGetCurrentRailModelAxisConvention(previousPoint, currentPoint, nextPoint, out var railConvention)) + { + return false; + } + + if (!RailPathPoseHelper.TryCreateRailRotation( + _route, + previousPoint, + currentPoint, + nextPoint, + railConvention, + LocalEulerRotationCorrection.Zero, + out var baselineRotation)) + { + return false; + } + + Quaternion baselineHostQuaternion = new Quaternion( + (float)baselineRotation.A, + (float)baselineRotation.B, + (float)baselineRotation.C, + (float)baselineRotation.D); + LocalEulerRotationCorrection localCorrection = ResolveRealObjectLocalRotationCorrection(); + Quaternion finalHostQuaternion = adapter.ComposeHostQuaternion( + baselineHostQuaternion, + localCorrection); + + return RealObjectRailExtentResolver.TryResolveProjectedSemanticExtents( + _realObjectReferenceAxisX, + _realObjectReferenceAxisY, + _realObjectReferenceAxisZ, + _realObjectReferenceHostUpLocalAxis, + targetFrame.Forward, + adapter.HostType, + _realObjectLength, + _realObjectWidth, + _realObjectHeight, + baselineHostQuaternion, + finalHostQuaternion, + out convention, + out extents); + } + + private bool TryCalculateCurrentRealObjectPlanarProjectedExtents( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out ModelAxisConvention convention, + out (double forwardExtent, double sideExtent, double upExtent) extents) + { + convention = null; + extents = (0.0, 0.0, 0.0); + + bool requiresReferenceRotation = _route?.PathType != PathType.Hoisting; + if (!IsRealObjectMode || + (requiresReferenceRotation && !_hasRealObjectReferenceRotation) || + _realObjectLength <= 0.0 || + _realObjectWidth <= 0.0 || + _realObjectHeight <= 0.0) + { + return false; + } + + Vector3 hostForward = ToNumerics(nextPoint) - ToNumerics(previousPoint); + if (hostForward.LengthSquared() < 1e-6f) + { + hostForward = ToNumerics(nextPoint) - ToNumerics(currentPoint); + } + + return TryCalculateCurrentRealObjectPlanarProjectedExtents( + hostForward, + out convention, + out extents); + } + + private bool TryCalculateCurrentRealObjectPlanarProjectedExtents( + Vector3 hostForward, + out ModelAxisConvention convention, + out (double forwardExtent, double sideExtent, double upExtent) extents) + { + convention = null; + extents = (0.0, 0.0, 0.0); + + bool requiresReferenceRotation = ShouldUseReferenceBasedRealObjectPlanarPose(_route?.PathType ?? PathType.Ground); + if (!IsRealObjectMode || + (requiresReferenceRotation && !_hasRealObjectReferenceRotation) || + _realObjectLength <= 0.0 || + _realObjectWidth <= 0.0 || + _realObjectHeight <= 0.0) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (!PathTargetFrameResolver.TryCreatePlanarHostFrame(hostForward, adapter.HostType, out var targetFrame)) + { + return false; + } + + if (_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) + { + convention = ModelAxisConvention.CreateDefaultForHost(adapter.HostType); + extents = RotatedObjectExtentHelper.CalculateGroundSemanticExtents( + adapter.HostType, + _realObjectLength, + _realObjectWidth, + _realObjectHeight, + _objectRotationCorrection); + LogManager.Debug( + $"[{_route.PathType.GetDisplayName()}通行空间尺寸] 使用宿主语义尺寸: " + + $"Forward={extents.forwardExtent:F3}, Side={extents.sideExtent:F3}, Up={extents.upExtent:F3}"); + return true; + } + + if (!TryCreateRealObjectPlanarPoseSolution( + targetFrame.Forward, + targetFrame.Up, + out var solution)) + { + return false; + } + + LocalAxisDirection semanticUpAxis = + adapter.HostType == CoordinateSystemType.YUp + ? LocalAxisDirection.PositiveY + : LocalAxisDirection.PositiveZ; + convention = new ModelAxisConvention(solution.SelectedReferenceAxisDirection, semanticUpAxis); + + Quaternion finalHostQuaternion = adapter.ComposeHostQuaternion( + solution.BaselineRotation, + ResolveRealObjectLocalRotationCorrection()); + extents = RealObjectProjectedExtentResolver.CalculateProjectedSemanticExtents( + convention, + _realObjectLength, + _realObjectWidth, + _realObjectHeight, + solution.BaselineRotation, + finalHostQuaternion, + targetFrame.Forward, + targetFrame.Up); + return true; + } + + public bool TryGetCurrentRouteRealObjectPlanarProjectedExtents( + out double forwardExtent, + out double sideExtent, + out double upExtent) + { + forwardExtent = 0.0; + sideExtent = 0.0; + upExtent = 0.0; + + if (_route == null || + (_route.PathType != PathType.Ground && _route.PathType != PathType.Hoisting) || + _pathPoints == null || + _pathPoints.Count == 0) + { + return false; + } + + if (!PathTargetFrameResolver.TryResolvePlanarStartHostForward( + _route.PathType, + _pathPoints, + out var hostForward)) + { + return false; + } + + if (!TryCalculateCurrentRealObjectPlanarProjectedExtents( + hostForward, + out _, + out var extents)) + { + return false; + } + + forwardExtent = extents.forwardExtent; + sideExtent = extents.sideExtent; + upExtent = extents.upExtent; + return true; + } + + public bool TryGetCurrentRouteRealObjectRailProjectedExtents( + out double forwardExtent, + out double sideExtent, + out double upExtent) + { + forwardExtent = 0.0; + sideExtent = 0.0; + upExtent = 0.0; + + if (_route == null || _route.PathType != PathType.Rail || _pathPoints == null || _pathPoints.Count == 0) + { + return false; + } + + Point3D previousPoint = _pathPoints[0]; + Point3D currentPoint = _pathPoints[0]; + Point3D nextPoint = _pathPoints.Count > 1 ? _pathPoints[1] : _pathPoints[0]; + + if (!TryCalculateCurrentRealObjectRailProjectedExtents( + previousPoint, + currentPoint, + nextPoint, + out _, + out var extents)) + { + return false; + } + + forwardExtent = extents.forwardExtent; + sideExtent = extents.sideExtent; + upExtent = extents.upExtent; + return true; + } + + private bool TryCreateRailPathRotation( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + if (!TryGetCurrentRailModelAxisConvention(previousPoint, currentPoint, nextPoint, out var railConvention)) + { + return false; + } + + if (IsRealObjectMode) + { + if (!RailPathPoseHelper.TryCreateRailRotation( + _route, + previousPoint, + currentPoint, + nextPoint, + railConvention, + LocalEulerRotationCorrection.Zero, + out var baselineRotation)) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Quaternion baselineHostQuaternion = new Quaternion( + (float)baselineRotation.A, + (float)baselineRotation.B, + (float)baselineRotation.C, + (float)baselineRotation.D); + LocalEulerRotationCorrection localCorrection = ResolveRealObjectLocalRotationCorrection(); + Quaternion composedHostQuaternion = adapter.ComposeHostQuaternion( + baselineHostQuaternion, + localCorrection); + rotation = adapter.FromHostQuaternionDirect(composedHostQuaternion); + + Matrix4x4 hostBaselineLocalAxesLinear = Matrix4x4.CreateFromQuaternion(baselineHostQuaternion); + Matrix4x4 hostComposedLocalAxesLinear = Matrix4x4.CreateFromQuaternion(composedHostQuaternion); + LogManager.Debug( + $"[Rail真实物体角度修正] hostBaselineLocalXAxis=({hostBaselineLocalAxesLinear.M11:F4},{hostBaselineLocalAxesLinear.M21:F4},{hostBaselineLocalAxesLinear.M31:F4}), " + + $"hostBaselineLocalYAxis=({hostBaselineLocalAxesLinear.M12:F4},{hostBaselineLocalAxesLinear.M22:F4},{hostBaselineLocalAxesLinear.M32:F4}), " + + $"hostBaselineLocalZAxis=({hostBaselineLocalAxesLinear.M13:F4},{hostBaselineLocalAxesLinear.M23:F4},{hostBaselineLocalAxesLinear.M33:F4}), " + + $"宿主修正={_objectRotationCorrection}, 本地修正={localCorrection}, " + + $"hostComposedLocalXAxis=({hostComposedLocalAxesLinear.M11:F4},{hostComposedLocalAxesLinear.M21:F4},{hostComposedLocalAxesLinear.M31:F4}), " + + $"hostComposedLocalYAxis=({hostComposedLocalAxesLinear.M12:F4},{hostComposedLocalAxesLinear.M22:F4},{hostComposedLocalAxesLinear.M32:F4}), " + + $"hostComposedLocalZAxis=({hostComposedLocalAxesLinear.M13:F4},{hostComposedLocalAxesLinear.M23:F4},{hostComposedLocalAxesLinear.M33:F4})"); + return true; + } + + return RailPathPoseHelper.TryCreateRailRotation( + _route, + previousPoint, + currentPoint, + nextPoint, + railConvention, + _objectRotationCorrection, + out rotation); + } + + private ModelAxisConvention GetCurrentModelAxisConvention() + { + if (IsVirtualObjectMode) + { + return ModelAxisConvention.CreateVirtualObjectAssetConvention(); + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + return ModelAxisConvention.CreateDefaultForHost(adapter.HostType); + } + + private bool TryCreatePlanarPathRotationAtStart(out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + if (_pathPoints == null || _pathPoints.Count < 2) + { + return false; + } + + if (PathTargetFrameResolver.TryResolvePlanarStartHostForward( + _route?.PathType ?? PathType.Ground, + _pathPoints, + out var hostForward)) + { + return TryCreatePlanarPathRotationFromHostForward( + new Vector3D(hostForward.X, hostForward.Y, hostForward.Z), + out rotation); + } + + return false; + } + + private bool TryCreatePlanarPathRotationForFrame(Point3D previousPoint, Point3D currentPoint, Point3D nextPoint, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + if (_route?.PathType == PathType.Hoisting) + { + if (_pathPoints == null || _pathPoints.Count < 3) + { + return false; + } + + return TryCreatePlanarPathRotationFromHostForward( + new Vector3D( + _pathPoints[2].X - _pathPoints[1].X, + _pathPoints[2].Y - _pathPoints[1].Y, + _pathPoints[2].Z - _pathPoints[1].Z), + out rotation); + } + + return TryCreatePlanarPathRotationFromHostPoints(previousPoint, currentPoint, nextPoint, out rotation); + } + + private bool TryCreatePlanarPathRotationFromHostPoints(Point3D previousPoint, Point3D currentPoint, Point3D nextPoint, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (IsRealObjectMode) + { + Vector3 hostForward = ToNumerics(nextPoint) - ToNumerics(previousPoint); + if (hostForward.LengthSquared() < 1e-6f) + { + hostForward = ToNumerics(nextPoint) - ToNumerics(currentPoint); + } + + return TryCreateRealObjectPlanarRotationFromHostForward(hostForward, out rotation); + } + + var convention = GetCurrentModelAxisConvention(); + Vector3 canonicalPrevious = ToNumerics(adapter.ToCanonicalPoint(previousPoint)); + Vector3 canonicalCurrent = ToNumerics(adapter.ToCanonicalPoint(currentPoint)); + Vector3 canonicalNext = ToNumerics(adapter.ToCanonicalPoint(nextPoint)); + + Vector3 forward = canonicalNext - canonicalPrevious; + if (forward.LengthSquared() < 1e-6f) + { + forward = canonicalNext - canonicalCurrent; + } + + Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(_objectRotationCorrection); + if (!CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + forward, + HostCoordinateAdapter.CanonicalUpVector3, + convention, + correctionQuaternion, + out var quaternion)) + { + return false; + } + + rotation = adapter.FromCanonicalRotation(quaternion); + return true; + } + + private double GetAnimatedObjectRailNormalExtent( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint) + { + if (TryCalculateCurrentRealObjectRailProjectedExtents( + previousPoint, + currentPoint, + nextPoint, + out var convention, + out var extents)) + { + LogManager.Debug( + $"[Rail法向尺寸] 真实物体有效尺寸: Forward={extents.forwardExtent:F3}, Side={extents.sideExtent:F3}, Up={extents.upExtent:F3}, " + + $"ForwardAxis={convention.ForwardAxis}, UpAxis={convention.UpAxis}, 角度={_objectRotationCorrection}"); + return extents.upExtent; + } + + return GetAnimatedObjectHeight(); + } + + private bool TryCreatePlanarPathRotationFromHostForward(Vector3D hostForward, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (IsRealObjectMode) + { + return TryCreateRealObjectPlanarRotationFromHostForward( + new Vector3((float)hostForward.X, (float)hostForward.Y, (float)hostForward.Z), + out rotation); + } + + var convention = GetCurrentModelAxisConvention(); + Vector3 canonicalForward = adapter.ToCanonicalVector3(new Vector3((float)hostForward.X, (float)hostForward.Y, (float)hostForward.Z)); + Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(_objectRotationCorrection); + if (!CanonicalPlanarPoseBuilder.TryCreateQuaternionFromForward( + canonicalForward, + HostCoordinateAdapter.CanonicalUpVector3, + convention, + correctionQuaternion, + out var quaternion)) + { + return false; + } + + rotation = adapter.FromCanonicalRotation(quaternion); + return true; + } + + + private bool TryCreateRealObjectPlanarRotationFromHostForward(Vector3 hostForward, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + + if (_route?.PathType == PathType.Hoisting && + TryCreateHoistingRealObjectRotationFromActualPose(hostForward, out rotation)) + { + return true; + } + + if (_route?.PathType == PathType.Hoisting) + { + LogManager.Error("[吊装真实物体姿态] 无法基于实际几何姿态生成起点姿态,已禁止回退到 fragment 参考姿态。"); + return false; + } + + if (!PathTargetFrameResolver.TryCreatePlanarHostFrame( + hostForward, + CoordinateSystemManager.Instance.CreateHostAdapter().HostType, + out var targetFrame)) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if (_route?.PathType == PathType.Ground) + { + Rotation3D actualRotation = Rotation3D.Identity; + bool hasActualRotation = + _animatedObject != null && + ModelItemTransformHelper.TryGetCurrentGeometryRotation(_animatedObject, out actualRotation); + + if (!hasActualRotation && _hasTrackedRotation) + { + actualRotation = _trackedRotation; + hasActualRotation = true; + } + + if (!hasActualRotation) + { + LogManager.Error("[真实物体起点姿态] Ground 无法读取当前实际几何姿态,禁止回退到 reference-based pose。"); + return false; + } + + if (!TryResolveDisplayedPlanarYawFromRotation(actualRotation, adapter.HostType, out double currentYawRadians)) + { + LogManager.Error("[真实物体起点姿态] Ground 无法从当前显示姿态解析宿主平面角。"); + return false; + } + + if (!PathTargetFrameResolver.TryResolvePlanarHostYaw(hostForward, adapter.HostType, out double targetYawRadians)) + { + LogManager.Error("[真实物体起点姿态] Ground 无法从路径方向解析宿主平面角。"); + return false; + } + + Quaternion targetQuaternion = HoistingRealObjectPoseHelper.CreateRotationFromPlanarBasePose( + Rotation3DToHostQuaternion(actualRotation), + currentYawRadians, + targetYawRadians, + adapter.HostUpVector3); + rotation = adapter.FromHostQuaternionDirect(targetQuaternion); + + Matrix4x4 targetLinear = Matrix4x4.CreateFromQuaternion(targetQuaternion); + LogManager.Debug( + $"[Ground宿主平面旋转] 当前Yaw={currentYawRadians * 180.0 / Math.PI:F2}°, 目标Yaw={targetYawRadians * 180.0 / Math.PI:F2}°, " + + $"hostXAxis=({targetLinear.M11:F4},{targetLinear.M21:F4},{targetLinear.M31:F4}), " + + $"hostYAxis=({targetLinear.M12:F4},{targetLinear.M22:F4},{targetLinear.M32:F4}), " + + $"hostZAxis=({targetLinear.M13:F4},{targetLinear.M23:F4},{targetLinear.M33:F4})"); + return true; + } + + if (!TryCreateRealObjectPlanarPoseSolution( + targetFrame.Forward, + targetFrame.Up, + out var solution)) + { + return false; + } + + LocalEulerRotationCorrection localCorrection = ResolveRealObjectLocalRotationCorrection(); + Quaternion hostComposedQuaternion = adapter.ComposeHostQuaternion(solution.BaselineRotation, localCorrection); + rotation = adapter.FromHostQuaternionDirect(hostComposedQuaternion); + + Matrix4x4 hostBaselineLocalAxesLinear = Matrix4x4.CreateFromQuaternion(solution.BaselineRotation); + Matrix4x4 hostComposedLocalAxesLinear = Matrix4x4.CreateFromQuaternion(hostComposedQuaternion); + + LogManager.Debug( + $"[真实物体起点姿态] 选中参考轴=({solution.SelectedReferenceAxisLocal.X:F3},{solution.SelectedReferenceAxisLocal.Y:F3},{solution.SelectedReferenceAxisLocal.Z:F3}), " + + $"投影前进=({solution.ProjectedForward.X:F3},{solution.ProjectedForward.Y:F3},{solution.ProjectedForward.Z:F3}), " + + $"宿主修正={_objectRotationCorrection}, 本地修正={localCorrection}, " + + $"hostBaselineLocalXAxis=({hostBaselineLocalAxesLinear.M11:F4},{hostBaselineLocalAxesLinear.M21:F4},{hostBaselineLocalAxesLinear.M31:F4}), " + + $"hostBaselineLocalYAxis=({hostBaselineLocalAxesLinear.M12:F4},{hostBaselineLocalAxesLinear.M22:F4},{hostBaselineLocalAxesLinear.M32:F4}), " + + $"hostBaselineLocalZAxis=({hostBaselineLocalAxesLinear.M13:F4},{hostBaselineLocalAxesLinear.M23:F4},{hostBaselineLocalAxesLinear.M33:F4}), " + + $"hostComposedLocalXAxis=({hostComposedLocalAxesLinear.M11:F4},{hostComposedLocalAxesLinear.M21:F4},{hostComposedLocalAxesLinear.M31:F4}), " + + $"hostComposedLocalYAxis=({hostComposedLocalAxesLinear.M12:F4},{hostComposedLocalAxesLinear.M22:F4},{hostComposedLocalAxesLinear.M32:F4}), " + + $"hostComposedLocalZAxis=({hostComposedLocalAxesLinear.M13:F4},{hostComposedLocalAxesLinear.M23:F4},{hostComposedLocalAxesLinear.M33:F4})"); + return true; + } + + private bool TryCreateHoistingRealObjectRotationFromActualPose(Vector3 hostForward, out Rotation3D rotation) + { + if (!TryResolveHoistingActualPose( + hostForward, + out rotation, + out _, + out var selectedAxisDirection, + out var selectedAxisLocal, + out var projectedForward)) + { + return false; + } + + _realObjectPlanarSelectedForwardAxis = selectedAxisDirection; + _hasRealObjectPlanarSelectedForwardAxis = true; + return true; + } + + private bool TryCreateRealObjectPlanarPoseSolution( + Vector3 targetForward, + Vector3 targetUp, + out RealObjectPlanarPoseSolution solution) + { + solution = null; + + if (!ShouldUseReferenceBasedRealObjectPlanarPose(_route?.PathType ?? PathType.Ground)) + { + return false; + } + + if (!TryGetRealObjectReferenceRotation(out var referenceRotation)) + { + return false; + } + + LocalAxisDirection? fixedForwardAxis = null; + if (_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) + { + // Ground/Hoisting 的真实物体前进轴采用固定对象语义: + // 真实物体默认以本地 +X 表示前进方向,不能因为路径更偏向 Z 就切换成 +Z。 + fixedForwardAxis = LocalAxisDirection.PositiveX; + } + + if (!RealObjectPlanarPoseSolver.TryCreatePlanarPoseFromReferencePose( + referenceRotation, + targetForward, + targetUp, + fixedForwardAxis, + out solution)) + { + return false; + } + + if ((_route?.PathType == PathType.Ground || _route?.PathType == PathType.Hoisting) && + (!_hasRealObjectPlanarSelectedForwardAxis || _realObjectPlanarSelectedForwardAxis != LocalAxisDirection.PositiveX)) + { + _realObjectPlanarSelectedForwardAxis = LocalAxisDirection.PositiveX; + _hasRealObjectPlanarSelectedForwardAxis = true; + LogManager.Debug("[真实物体平面前进轴] Ground/Hoisting 已固定使用 PositiveX 作为对象前进轴语义。"); + } + + return true; + } + + private void ShowRealObjectFragmentUpMismatchHintIfNeeded(Vector3 representativeYAxis, Vector3 representativeZAxis) + { + if (_route == null || (_route.PathType != PathType.Ground && _route.PathType != PathType.Hoisting)) + { + return; + } + + if (!IsRealObjectMode) + { + return; + } + + var doc = NavisApplication.ActiveDocument; + string hostUpAxis = FragmentDefaultUpContext.GetCurrentHostUpAxis(doc); + string configuredFragmentUpAxis = FragmentDefaultUpContext.GetCurrentDocumentFragmentDefaultUpAxis(doc); + Vector3 hostUpVector = string.Equals(hostUpAxis, "Y", StringComparison.OrdinalIgnoreCase) + ? Vector3.UnitY + : Vector3.UnitZ; + + float yAlignment = Math.Abs(Vector3.Dot(Vector3.Normalize(representativeYAxis), hostUpVector)); + float zAlignment = Math.Abs(Vector3.Dot(Vector3.Normalize(representativeZAxis), hostUpVector)); + string detectedFragmentUpAxis = zAlignment > yAlignment ? "Z" : "Y"; + + if (string.Equals(configuredFragmentUpAxis, detectedFragmentUpAxis, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string documentKey = FragmentDefaultUpContext.GetCurrentDocumentKey(doc); + if (string.IsNullOrWhiteSpace(documentKey)) + { + return; + } + + string hintKey = $"{documentKey}|{configuredFragmentUpAxis}|{detectedFragmentUpAxis}"; + lock (_realObjectFragmentUpMismatchHintsShown) + { + if (_realObjectFragmentUpMismatchHintsShown.Contains(hintKey)) + { + return; + } + + _realObjectFragmentUpMismatchHintsShown.Add(hintKey); + } + + string message = + $"请到系统管理的坐标系设置中,将 Fragment默认Up 调整为 {detectedFragmentUpAxis}。"; + LogManager.Info($"[fragment默认Up提示] 当前值={configuredFragmentUpAxis}, 检测值={detectedFragmentUpAxis}, 模型Up={hostUpAxis}。{message}"); + DialogHelper.ShowMessageBox( + message, + "Fragment默认Up提示", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + private bool TryGetRealObjectReferenceRotation(out Quaternion referenceRotation) + { + referenceRotation = Quaternion.Identity; + + if (_hasRealObjectReferenceRotation) + { + referenceRotation = _realObjectReferenceRotation; + return true; + } + + if (_originalTransform == null) + { + return false; + } + + if (_route?.PathType == PathType.Ground) + { + return false; + } + + if (!TryCaptureRealObjectReferenceRotation(_animatedObject, out referenceRotation)) + { + string objectName = _animatedObject?.DisplayName ?? "未知对象"; + LogManager.Error($"[真实物体参考姿态] {objectName} 无法获取 fragment 参考姿态,已禁止回退默认姿态。"); + return false; + } + + return true; + } + + private bool TryCaptureRealObjectReferenceRotation(ModelItem sourceObject, out Quaternion referenceRotation) + { + referenceRotation = Quaternion.Identity; + + if (sourceObject == null) + { + return false; + } + + try + { + var doc = NavisApplication.ActiveDocument; + if (!RealObjectReferencePoseResolver.TryResolveFromModelItem(sourceObject, doc, out var referencePose)) + { + LogManager.Warning($"[真实物体参考姿态] {sourceObject.DisplayName} fragment 姿态解析失败"); + return false; + } + + referenceRotation = referencePose.Rotation; + _realObjectReferenceRotation = referenceRotation; + _realObjectReferenceAxisX = referencePose.RawAxisX; + _realObjectReferenceAxisY = referencePose.RawAxisY; + _realObjectReferenceAxisZ = referencePose.RawAxisZ; + _realObjectReferenceHostWorldXAxisLocalAxis = referencePose.HostWorldXAxisLocalAxis; + _realObjectReferenceHostWorldYAxisLocalAxis = referencePose.HostWorldYAxisLocalAxis; + _realObjectReferenceHostWorldZAxisLocalAxis = referencePose.HostWorldZAxisLocalAxis; + _realObjectReferenceHostSemanticXAxisLocalAxis = referencePose.HostSemanticXAxisLocalAxis; + _realObjectReferenceHostSemanticYAxisLocalAxis = referencePose.HostSemanticYAxisLocalAxis; + _realObjectReferenceHostSemanticZAxisLocalAxis = referencePose.HostSemanticZAxisLocalAxis; + _realObjectReferenceHostUpLocalAxis = referencePose.HostUpLocalAxis; + _hasRealObjectReferenceRotation = true; + + LogManager.Info( + $"[真实物体参考姿态] {sourceObject.DisplayName} 使用 fragment 代表姿态: " + + $"RawX=({_realObjectReferenceAxisX.X:F4},{_realObjectReferenceAxisX.Y:F4},{_realObjectReferenceAxisX.Z:F4}), " + + $"RawY=({_realObjectReferenceAxisY.X:F4},{_realObjectReferenceAxisY.Y:F4},{_realObjectReferenceAxisY.Z:F4}), " + + $"RawZ=({_realObjectReferenceAxisZ.X:F4},{_realObjectReferenceAxisZ.Y:F4},{_realObjectReferenceAxisZ.Z:F4}), " + + $"宿主世界X对应本地轴={referencePose.HostWorldXAxisLocalAxis}, 宿主世界Y对应本地轴={referencePose.HostWorldYAxisLocalAxis}, 宿主世界Z对应本地轴={referencePose.HostWorldZAxisLocalAxis}, " + + $"宿主语义X=({referencePose.AxisX.X:F4},{referencePose.AxisX.Y:F4},{referencePose.AxisX.Z:F4}), " + + $"宿主语义Y=({referencePose.AxisY.X:F4},{referencePose.AxisY.Y:F4},{referencePose.AxisY.Z:F4}), " + + $"宿主语义Z=({referencePose.AxisZ.X:F4},{referencePose.AxisZ.Y:F4},{referencePose.AxisZ.Z:F4}), " + + $"宿主语义X对应本地轴={referencePose.HostSemanticXAxisLocalAxis}, 宿主语义Y对应本地轴={referencePose.HostSemanticYAxisLocalAxis}, 宿主语义Z对应本地轴={referencePose.HostSemanticZAxisLocalAxis}, " + + $"宿主Up对应本地轴={referencePose.HostUpLocalAxis}, fragment数量={referencePose.FragmentCount}, Fragment默认Up={referencePose.FragmentDefaultUpAxis}, 模型Up={referencePose.HostUpAxis}"); + return true; + } + catch (Exception ex) + { + LogManager.Warning($"[真实物体参考姿态] 从 fragment 提取代表姿态失败: {ex.Message}"); + return false; + } + } + + private void SyncTrackedRotationToObjectReference(ModelItem sourceObject, bool isVirtualObject) + { + if (sourceObject == null) + { + _trackedRotation = Rotation3D.Identity; + _hasTrackedRotation = false; + _currentYaw = 0.0; + return; + } + + if (isVirtualObject) + { + _trackedRotation = CreateVirtualObjectReferenceRotation(); + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(_trackedRotation); + + var linear = new Transform3D(_trackedRotation).Linear; + LogManager.Info( + $"[虚拟物体参考姿态] {sourceObject.DisplayName} 使用资产约定基姿态: " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"); + return; + } + + if (!isVirtualObject && + _route?.PathType == PathType.Ground && + ModelItemTransformHelper.TryGetCurrentGeometryRotation(sourceObject, out var groundActualGeometryRotation)) + { + _trackedRotation = groundActualGeometryRotation; + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(_trackedRotation); + + var linear = new Transform3D(_trackedRotation).Linear; + LogManager.Info( + $"[真实物体当前姿态] {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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"); + return; + } + + if (!isVirtualObject && TryCaptureRealObjectReferenceRotation(sourceObject, out Quaternion referenceRotation)) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + _trackedRotation = adapter.FromHostQuaternionDirect(referenceRotation); + _hasTrackedRotation = true; + _currentYaw = ModelItemTransformHelper.GetYawFromRotation(_trackedRotation); + return; + } + + throw new InvalidOperationException( + $"[真实物体参考姿态] {sourceObject.DisplayName} 无法同步参考姿态:未能读取当前实际几何姿态,fragment 代表姿态也不可用,已禁止回退 Transform。"); + } + + private static Rotation3D CreateVirtualObjectReferenceRotation() + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var convention = GetVirtualObjectAssetConvention(); + return convention.CreateRotation(new Vector3D(1, 0, 0), adapter.HostUpVector); + } + + private static ModelAxisConvention GetVirtualObjectAssetConvention() + { + return ModelAxisConvention.CreateVirtualObjectAssetConvention(); + } + + private void ResetRealObjectReferenceRotation() + { + _realObjectReferenceRotation = Quaternion.Identity; + _realObjectReferenceAxisX = Vector3.UnitX; + _realObjectReferenceAxisY = Vector3.UnitY; + _realObjectReferenceAxisZ = Vector3.UnitZ; + _realObjectReferenceHostWorldXAxisLocalAxis = LocalAxisDirection.PositiveX; + _realObjectReferenceHostWorldYAxisLocalAxis = LocalAxisDirection.PositiveY; + _realObjectReferenceHostWorldZAxisLocalAxis = LocalAxisDirection.PositiveZ; + _realObjectReferenceHostSemanticXAxisLocalAxis = LocalAxisDirection.PositiveX; + _realObjectReferenceHostSemanticYAxisLocalAxis = LocalAxisDirection.PositiveY; + _realObjectReferenceHostSemanticZAxisLocalAxis = LocalAxisDirection.PositiveZ; + _realObjectReferenceHostUpLocalAxis = LocalAxisDirection.PositiveY; + _hasRealObjectReferenceRotation = false; + _realObjectPlanarSelectedForwardAxis = LocalAxisDirection.PositiveX; + _hasRealObjectPlanarSelectedForwardAxis = false; + } + + private void ResetPlanarRealObjectBasePoseCache() + { + _groundRealObjectBaseRotation = Rotation3D.Identity; + _groundRealObjectBaseYaw = 0.0; + _hasGroundRealObjectBasePose = false; + } + + private LocalEulerRotationCorrection ResolveRealObjectLocalRotationCorrection() + { + return HostCoordinateAdapter.RemapHostSemanticCorrectionToLocalAxes( + _objectRotationCorrection, + _realObjectReferenceHostWorldXAxisLocalAxis, + _realObjectReferenceHostWorldYAxisLocalAxis, + _realObjectReferenceHostWorldZAxisLocalAxis); + } + + /// + /// 设置物体绕宿主 X/Y/Z 轴的角度修正 + /// + public void SetObjectRotationCorrection(LocalEulerRotationCorrection rotationCorrection) + { + _objectStartPlacementMode = ObjectStartPlacementMode.AlignToPathPose; + _hasPathPreservedPoseRotation = false; _objectRotationCorrection = rotationCorrection; // 如果动画已创建,更新物体到起点的朝向 @@ -2837,7 +5406,7 @@ namespace NavisworksTransport.Core.Animation { // 重新计算并应用朝向 MoveObjectToPathStart(); - LogManager.Info($"[角度修正] 物体角度已更新: {_objectRotationCorrection:F1}°"); + LogManager.Info($"[角度修正] 物体角度已更新: {_objectRotationCorrection}"); } catch (Exception ex) { @@ -2846,19 +5415,123 @@ namespace NavisworksTransport.Core.Animation } } + public void ApplyObjectStartPlacementRequest(ObjectStartPlacementRequest request) + { + _objectStartPlacementMode = request.PlacementMode; + if (_objectStartPlacementMode != ObjectStartPlacementMode.PreserveInitialPose) + { + _hasPathPreservedPoseRotation = false; + } + + _objectRotationCorrection = request.RotationCorrection; + _groundPathObjectLiftHeight = UnitsConverter.ConvertFromMeters(request.VerticalLiftInMeters); + + LogManager.Info( + $"[起点摆放] 已应用物体调整请求: 模式={_objectStartPlacementMode}, 角度={_objectRotationCorrection}, " + + $"提升高度={request.VerticalLiftInMeters:F3}m"); + + if (_animatedObject != null && _pathPoints != null && _pathPoints.Count > 0) + { + try + { + MoveObjectToPathStartUsingCurrentPlacementMode(); + } + catch (Exception ex) + { + LogManager.Error($"[起点摆放] 应用物体调整请求失败: {ex.Message}"); + } + } + } + /// - /// 直接设置物体角度修正值(不触发物体旋转) + /// 单轴兼容入口:按当前宿主 up 轴映射成三轴角度修正。 /// - /// 角度修正值(度) - public void SetObjectRotationCorrectionDirect(double rotationCorrection) + public void SetObjectRotationCorrection(double rotationCorrection) + { + SetObjectRotationCorrection(CreateSingleAxisUpCorrection(rotationCorrection)); + } + + /// + /// 直接设置物体绕宿主 X/Y/Z 轴的角度修正(不触发物体旋转) + /// + public void SetObjectRotationCorrectionDirect(LocalEulerRotationCorrection rotationCorrection) { _objectRotationCorrection = rotationCorrection; - LogManager.Debug($"[角度修正] 直接设置角度修正值: {_objectRotationCorrection:F1}°(不触发旋转)"); + ResetPlanarRealObjectBasePoseCache(); + LogManager.Debug($"[角度修正] 直接设置角度修正值: {_objectRotationCorrection}(不触发旋转)"); + } + + public void SetObjectStartVerticalLiftDirect(double verticalLiftInMeters) + { + _groundPathObjectLiftHeight = UnitsConverter.ConvertFromMeters(verticalLiftInMeters); + LogManager.Debug($"[起点摆放] 直接设置地面路径提升高度: {verticalLiftInMeters:F3}m"); + } + + public void SetObjectStartPlacementMode(ObjectStartPlacementMode placementMode) + { + _objectStartPlacementMode = placementMode; + if (placementMode != ObjectStartPlacementMode.PreserveInitialPose) + { + _hasPathPreservedPoseRotation = false; + } + LogManager.Debug($"[起点摆放] 当前模式已设置为: {_objectStartPlacementMode}"); + } + + private bool MoveObjectToPathStartUsingCurrentPlacementMode(ModelItem animatedObject = null, List pathPoints = null) + { + return _objectStartPlacementMode == ObjectStartPlacementMode.PreserveInitialPose + ? MoveObjectToPathStartPreservingInitialPose(animatedObject, pathPoints) + : MoveObjectToPathStart(animatedObject, pathPoints); + } + + /// + /// 单轴兼容入口:按当前宿主 up 轴映射成三轴角度修正。 + /// + public void SetObjectRotationCorrectionDirect(double rotationCorrection) + { + SetObjectRotationCorrectionDirect(CreateSingleAxisUpCorrection(rotationCorrection)); + } + + private LocalEulerRotationCorrection CreateSingleAxisUpCorrection(double rotationCorrection) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + return adapter.HostType == CoordinateSystemType.YUp + ? new LocalEulerRotationCorrection(0.0, rotationCorrection, 0.0) + : new LocalEulerRotationCorrection(0.0, 0.0, rotationCorrection); + } + + internal static Vector3 ResolveGroundPathObjectLiftOffsetVector( + double verticalLiftModelUnits, + CoordinateSystemType hostType) + { + if (verticalLiftModelUnits <= 0.0) + { + return Vector3.Zero; + } + + var adapter = new HostCoordinateAdapter(hostType); + return Vector3.Normalize(adapter.HostUpVector3) * (float)verticalLiftModelUnits; + } + + private Point3D ApplyGroundPathObjectLiftOffset(Point3D trackedCenter) + { + if (_route == null || _route.PathType != PathType.Ground || _groundPathObjectLiftHeight <= 0.0) + { + return trackedCenter; + } + + Vector3 offset = ResolveGroundPathObjectLiftOffsetVector( + _groundPathObjectLiftHeight, + CoordinateSystemManager.Instance.ResolvedType); + + return new Point3D( + trackedCenter.X + offset.X, + trackedCenter.Y + offset.Y, + trackedCenter.Z + offset.Z); } #region 动画实现方法 - /// /// DispatcherTimer动画处理核心 /// @@ -2975,16 +5648,7 @@ namespace NavisworksTransport.Core.Animation if (_currentFrameIndex < _animationFrames.Count) { var frameData = _animationFrames[_currentFrameIndex]; - - // 计算实际朝向 = 路径方向 + 角度修正 - double actualYaw = frameData.YawRadians; - if (_objectRotationCorrection != 0.0) - { - double correctionRad = _objectRotationCorrection * Math.PI / 180.0; - actualYaw += correctionRad; - } - - UpdateObjectPosition(frameData.Position, actualYaw); + ApplyRecordedPose(frameData, _currentFrameIndex); // 更新碰撞高亮(基于预计算结果) UpdateCollisionHighlightFromFrame(); @@ -3011,6 +5675,125 @@ namespace NavisworksTransport.Core.Animation UpdateFPSCounterAndCheckSwitch(); } + private void ApplyRecordedPose(AnimationFrame frameData, int frameIndex) + { + if (frameData == null) + { + return; + } + + ApplyResolvedObjectPose( + frameData.Position, + frameData.Rotation, + frameData.HasCustomRotation, + frameIndex, + frameData.PlanarHostYawRadians.HasValue + ? (double?)ResolveAnimationFramePlaybackYawRadians(frameData) + : null); + } + + private void ApplyResolvedObjectPose( + Point3D targetPosition, + Rotation3D targetRotation, + bool hasCustomRotation, + int frameIndex, + double? planarHostYawRadians = null) + { + if (ShouldUsePlanarRealObjectPureIncrementFrames(_route?.PathType ?? PathType.Ground, IsRealObjectMode) && + planarHostYawRadians.HasValue) + { + ApplyPlanarTrackedPose(targetPosition, planarHostYawRadians.Value); + return; + } + + if (hasCustomRotation) + { + ApplyFullPoseUpdate(targetPosition, targetRotation); + return; + } + + string pathTypeName = (_route?.PathType ?? PathType.Ground).GetDisplayName(); + throw new InvalidOperationException( + $"{pathTypeName}记录姿态 {frameIndex} 缺少完整姿态,禁止退回旧 yaw 链路。"); + } + + private double ResolveAnimationFramePlaybackYawRadians(AnimationFrame frameData) + { + return ResolveAnimationFramePlaybackYawRadians( + frameData, + _objectRotationCorrection, + CoordinateSystemManager.Instance.ResolvedType); + } + + internal static double ResolveAnimationFramePlaybackYawRadians( + AnimationFrame frameData, + LocalEulerRotationCorrection rotationCorrection, + CoordinateSystemType hostType) + { + if (frameData == null) + { + return 0.0; + } + + if (frameData.PlanarHostYawRadians.HasValue) + { + return NormalizeRadians( + frameData.PlanarHostYawRadians.Value + + ResolvePlanarHostUpCorrectionRadians( + rotationCorrection, + hostType)); + } + + double resolvedYaw = frameData.YawRadians; + if (!frameData.HasCustomRotation && !rotationCorrection.IsZero) + { + resolvedYaw += rotationCorrection.ZDegrees * Math.PI / 180.0; + } + + return NormalizeRadians(resolvedYaw); + } + + /// + /// 应用平面路径的已记录姿态。使用业务跟踪点 _trackedPosition 作为当前位置,不读取实时包围盒中心回写主链。 + /// + private void ApplyPlanarTrackedPose(Point3D targetPosition, double targetYawRadians) + { + var controlledObject = CurrentControlledObject; + if (controlledObject == null) return; + + Point3D currentPosition = _trackedPosition; + double currentYawRadians = _currentYaw; + targetYawRadians = NormalizeRadians( + targetYawRadians + + ResolvePlanarHostUpCorrectionRadians( + _objectRotationCorrection, + CoordinateSystemManager.Instance.ResolvedType)); + double deltaYawRadians = NormalizeRadians(targetYawRadians - currentYawRadians); + Vector3 hostUp = Vector3.Normalize(CoordinateSystemManager.Instance.CreateHostAdapter().HostUpVector3); + string pathTypeName = (_route?.PathType ?? PathType.Ground).GetDisplayName(); + + LogManager.Debug( + $"[{pathTypeName}纯增量逐帧] 当前Yaw={currentYawRadians * 180.0 / Math.PI:F2}°, " + + $"目标Yaw={targetYawRadians * 180.0 / Math.PI:F2}°, 增量Yaw={deltaYawRadians * 180.0 / Math.PI:F2}°, " + + $"当前跟踪点=({currentPosition.X:F3},{currentPosition.Y:F3},{currentPosition.Z:F3}), " + + $"目标跟踪点=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3})"); + + ModelItemTransformHelper.MoveItemIncrementallyByAxisRotationAndTranslation( + controlledObject, + currentPosition, + hostUp, + deltaYawRadians, + targetPosition); + + _trackedPosition = targetPosition; + if (ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out Rotation3D appliedRotation)) + { + _trackedRotation = appliedRotation; + _hasTrackedRotation = true; + } + _currentYaw = targetYawRadians; + } + /// /// 更新FPS计数器(DispatcherTimer模式) @@ -3116,7 +5899,7 @@ namespace NavisworksTransport.Core.Animation sb.Append("|"); // 包含角度修正(确保角度修正改变时重新检测) - sb.Append($"RotationCorrection:{_objectRotationCorrection:F2}deg"); + sb.Append($"RotationCorrection:{_objectRotationCorrection}"); sb.Append("|"); // 包含手工检测对象列表(确保手工指定模式的目标变化时重新检测) @@ -3800,4 +6583,6 @@ namespace NavisworksTransport.Core.Animation #endregion } -} \ No newline at end of file +} + + diff --git a/src/Core/AssemblyReferencePathManager.cs b/src/Core/AssemblyReferencePathManager.cs new file mode 100644 index 0000000..986604f --- /dev/null +++ b/src/Core/AssemblyReferencePathManager.cs @@ -0,0 +1,345 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; + +namespace NavisworksTransport.Core +{ + /// + /// 直线装配参考路径管理器。 + /// 使用单位立方体资源生成一根可点击的临时参考杆,供用户在 3D 视图中选择起点。 + /// 参考杆本质上表达的是一段 start->end 参考轴线,而不是终点处的独立构件。 + /// + public class AssemblyReferencePathManager + { + private const double ReferenceRodBaseSizeInMeters = 0.01; + private static readonly ModelAxisConvention ReferenceRodAxisConvention = ModelAxisConvention.CreateReferenceRodAssetConvention(); + + private static AssemblyReferencePathManager _instance; + private static readonly object _lock = new object(); + + private Model _referenceRodModel; + private ModelItem _referenceRodModelItem; + private bool _isUpdatingReferenceRod; + private bool _isReferenceRodVisible; + private Point3D _referenceLineStart = Point3D.Origin; + private Point3D _referenceLineEnd = Point3D.Origin; + private double _referenceRodDiameterInMeters; + private string _referenceResourceName; + public static AssemblyReferencePathManager Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new AssemblyReferencePathManager(); + } + } + } + + return _instance; + } + } + + public ModelItem CurrentReferenceRod => _referenceRodModelItem; + public bool IsReferenceRodVisible => _isReferenceRodVisible; + public bool IsUpdatingReferenceRod => _isUpdatingReferenceRod; + public bool HasReferenceLine => VectorLengthSquared(new Vector3D( + _referenceLineEnd.X - _referenceLineStart.X, + _referenceLineEnd.Y - _referenceLineStart.Y, + _referenceLineEnd.Z - _referenceLineStart.Z)) > 1e-9; + public Point3D ReferenceLineStart => _referenceLineStart; + public Point3D ReferenceLineEnd => _referenceLineEnd; + public string ReferenceResourceName => _referenceResourceName; + + private AssemblyReferencePathManager() + { + _isReferenceRodVisible = false; + } + + /// + /// 创建或更新参考杆。 + /// 先记录参考线主数据,再基于参考线更新可点击实体外壳。 + /// + public ModelItem CreateOrUpdateReferenceRod(Point3D startPoint, Point3D endPoint, double diameterInMeters) + { + var doc = Application.ActiveDocument; + if (doc == null || doc.IsClear) + { + throw new InvalidOperationException("当前没有可用文档,无法创建装配参考杆"); + } + + double referenceLength = GeometryHelper.CalculatePointDistance(startPoint, endPoint); + if (referenceLength <= 1e-6) + { + throw new InvalidOperationException("参考线长度为 0,无法创建装配参考杆"); + } + + _isUpdatingReferenceRod = true; + try + { + _referenceLineStart = startPoint; + _referenceLineEnd = endPoint; + _referenceRodDiameterInMeters = diameterInMeters; + + EnsureReferenceRodModelLoaded(); + + ScaleReferenceRod(referenceLength, diameterInMeters); + PositionReferenceRod(); + + ShowReferenceRod(); + + double referenceLengthInMeters = UnitsConverter.ConvertToMeters(referenceLength); + LogManager.Info($"[装配参考杆] 已创建/更新,长度={referenceLengthInMeters:F3}m,直径={diameterInMeters:F3}m"); + return _referenceRodModelItem; + } + finally + { + _isUpdatingReferenceRod = false; + } + } + + public void ShowReferenceRod() + { + if (_referenceRodModelItem == null) + { + return; + } + + var items = new ModelItemCollection { _referenceRodModel.RootItem }; + Application.ActiveDocument.Models.SetHidden(items, false); + _isReferenceRodVisible = true; + } + + public void HideReferenceRod() + { + if (_referenceRodModelItem == null) + { + return; + } + + var items = new ModelItemCollection { _referenceRodModel.RootItem }; + Application.ActiveDocument.Models.SetHidden(items, true); + _isReferenceRodVisible = false; + } + + /// + /// 将任意点击点投影到当前参考线。 + /// + public Point3D ProjectPointToReferenceLine(Point3D point) + { + if (!HasReferenceLine) + { + throw new InvalidOperationException("当前没有可用的装配参考线"); + } + + return ProjectPointToSegment(point, _referenceLineStart, _referenceLineEnd); + } + + public void Cleanup() + { + try + { + if (_referenceRodModel != null) + { + var doc = Application.ActiveDocument; + if (doc != null && !doc.IsClear) + { + int index = doc.Models.IndexOf(_referenceRodModel); + if (index >= 0) + { + doc.TryRemoveFile(index); + } + } + } + } + catch (Exception ex) + { + LogManager.Error($"清理装配参考杆失败: {ex.Message}"); + } + finally + { + _referenceRodModel = null; + _referenceRodModelItem = null; + _isReferenceRodVisible = false; + _referenceLineStart = Point3D.Origin; + _referenceLineEnd = Point3D.Origin; + _referenceRodDiameterInMeters = 0.0; + _referenceResourceName = null; + } + } + + private void EnsureReferenceRodModelLoaded() + { + if (_referenceRodModel != null && + Application.ActiveDocument.Models.IndexOf(_referenceRodModel) >= 0 && + _referenceRodModelItem != null) + { + return; + } + + string resourcePath = GetReferenceRodResourcePath(out string resourceName); + if (string.IsNullOrEmpty(resourcePath) || !File.Exists(resourcePath)) + { + throw new FileNotFoundException($"找不到参考杆资源文件: {resourcePath}"); + } + + var doc = Application.ActiveDocument; + int modelCountBefore = doc.Models.Count; + if (!doc.TryAppendFile(resourcePath)) + { + throw new InvalidOperationException($"无法追加文件: {resourcePath}"); + } + + if (doc.Models.Count <= modelCountBefore) + { + throw new InvalidOperationException("追加参考杆资源后模型数量未增加"); + } + + _referenceRodModel = doc.Models.Last(); + _referenceRodModelItem = _referenceRodModel.RootItem; + _isReferenceRodVisible = true; + _referenceResourceName = resourceName; + LogManager.Info($"[装配参考杆] 已加载资源: {resourceName}"); + } + + private void ScaleReferenceRod(double length, double diameterInMeters) + { + if (_referenceRodModel == null) + { + return; + } + + var doc = Application.ActiveDocument; + double lengthInMeters = UnitsConverter.ConvertToMeters(length); + + var currentTransform = _referenceRodModel.Transform; + var components = currentTransform.Factor(); + components.Scale = ReferenceRodAxisConvention.CreateScaleVector( + lengthInMeters / ReferenceRodBaseSizeInMeters, + diameterInMeters / ReferenceRodBaseSizeInMeters, + diameterInMeters / ReferenceRodBaseSizeInMeters); + + Transform3D scaledTransform = components.Combine(); + doc.Models.SetModelUnitsAndTransform( + _referenceRodModel, + _referenceRodModel.Units, + scaledTransform, + false); + + LogManager.Info( + $"[装配参考杆] 缩放完成,目标长度={lengthInMeters:F3}m,目标直径={diameterInMeters:F3}m,模型单位={_referenceRodModel.Units}"); + } + + private void PositionReferenceRod() + { + if (_referenceRodModelItem == null || _referenceRodModel == null) + { + return; + } + + Point3D targetCenter = new Point3D( + (_referenceLineStart.X + _referenceLineEnd.X) / 2.0, + (_referenceLineStart.Y + _referenceLineEnd.Y) / 2.0, + (_referenceLineStart.Z + _referenceLineEnd.Z) / 2.0); + + Rotation3D rotation = CreateLineRotation(_referenceLineStart, _referenceLineEnd); + ModelItemTransformHelper.MoveItemToCenterAndRotation( + _referenceRodModelItem, + targetCenter, + rotation); + + LogManager.Info( + $"[装配参考杆] 定位完成,中心=({targetCenter.X:F3}, {targetCenter.Y:F3}, {targetCenter.Z:F3}),起点=({_referenceLineStart.X:F3}, {_referenceLineStart.Y:F3}, {_referenceLineStart.Z:F3}),终点=({_referenceLineEnd.X:F3}, {_referenceLineEnd.Y:F3}, {_referenceLineEnd.Z:F3})"); + } + + private static Rotation3D CreateLineRotation(Point3D startPoint, Point3D endPoint) + { + var tangent = Normalize(new Vector3D( + endPoint.X - startPoint.X, + endPoint.Y - startPoint.Y, + endPoint.Z - startPoint.Z)); + + // 参考杆资源约定:几何中心在原点,长度轴沿本地 ForwardAxis。 + // Navisworks API 文档中 Rotation3D(vector1, vector2) 的语义 + // 是“将 vector1 旋转到与 vector2 同方向”,这里直接将资源本地 forward 轴对齐参考线方向。 + return new Rotation3D( + new UnitVector3D(ReferenceRodAxisConvention.ForwardVector), + new UnitVector3D(tangent)); + } + + private static Point3D ProjectPointToSegment(Point3D point, Point3D segmentStart, Point3D segmentEnd) + { + double segmentX = segmentEnd.X - segmentStart.X; + double segmentY = segmentEnd.Y - segmentStart.Y; + double segmentZ = segmentEnd.Z - segmentStart.Z; + double lengthSquared = segmentX * segmentX + segmentY * segmentY + segmentZ * segmentZ; + + if (lengthSquared < 1e-9) + { + return segmentStart; + } + + double pointX = point.X - segmentStart.X; + double pointY = point.Y - segmentStart.Y; + double pointZ = point.Z - segmentStart.Z; + double projection = (pointX * segmentX + pointY * segmentY + pointZ * segmentZ) / lengthSquared; + projection = Math.Max(0.0, Math.Min(1.0, projection)); + + return new Point3D( + segmentStart.X + projection * segmentX, + segmentStart.Y + projection * segmentY, + segmentStart.Z + projection * segmentZ); + } + + private static Vector3D Normalize(Vector3D vector) + { + double lengthSquared = VectorLengthSquared(vector); + if (lengthSquared < 1e-9) + { + return new Vector3D(1, 0, 0); + } + + double length = Math.Sqrt(lengthSquared); + return new Vector3D(vector.X / length, vector.Y / length, vector.Z / length); + } + + private static double VectorLengthSquared(Vector3D vector) + { + return vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z; + } + + private static string GetReferenceRodResourcePath(out string resourceName) + { + string pluginDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string[] preferredPaths = new[] + { + Path.Combine(pluginDir, "resources", "unit_cylinder.nwc"), + Path.Combine(pluginDir, "..", "..", "resources", "unit_cylinder.nwc"), + @"c:\Users\Tellme\apps\NavisworksTransport\resources\unit_cylinder.nwc", + Path.Combine(pluginDir, "resources", "unit_cube.nwc"), + Path.Combine(pluginDir, "..", "..", "resources", "unit_cube.nwc"), + @"c:\Users\Tellme\apps\NavisworksTransport\resources\unit_cube.nwc" + }; + + foreach (string path in preferredPaths) + { + if (File.Exists(path)) + { + resourceName = Path.GetFileName(path); + return path; + } + } + + resourceName = "unit_cylinder.nwc"; + return preferredPaths[0]; + } + } +} diff --git a/src/Core/BatchQueueManager.cs b/src/Core/BatchQueueManager.cs index 38b90f2..55ed6b0 100644 --- a/src/Core/BatchQueueManager.cs +++ b/src/Core/BatchQueueManager.cs @@ -391,6 +391,11 @@ namespace NavisworksTransport.Core try { + if (!item.DetectionRecordId.HasValue) + { + throw new InvalidOperationException("批处理队列项缺少 DetectionRecordId,无法保存完整碰撞结果和位姿数据。请重新生成动画后再加入批处理。"); + } + var pathRoute = await _database.GetPathRouteAsync(item.RouteId); if (pathRoute == null) { @@ -1072,4 +1077,4 @@ namespace NavisworksTransport.Core { public BatchQueueItem Item { get; set; } } -} \ No newline at end of file +} diff --git a/src/Core/Collision/ClashDetectiveIntegration.cs b/src/Core/Collision/ClashDetectiveIntegration.cs index d168eda..3211e45 100644 --- a/src/Core/Collision/ClashDetectiveIntegration.cs +++ b/src/Core/Collision/ClashDetectiveIntegration.cs @@ -7,6 +7,7 @@ using Autodesk.Navisworks.Api.Clash; using NavisworksTransport.Core; using NavisworksTransport.Core.Animation; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport { @@ -174,9 +175,11 @@ namespace NavisworksTransport /// private class CollisionPositionInfo { - public Point3D Item1Position { get; set; } + public Point3D AnimatedObjectTrackedPosition { get; set; } public Point3D Item2Position { get; set; } - public double Item1YawRadians { get; set; } + public double AnimatedObjectTrackedYawRadians { get; set; } + public Rotation3D AnimatedObjectTrackedRotation { get; set; } + public bool AnimatedObjectHasTrackedRotation { get; set; } public bool HasPositionInfo { get; set; } } @@ -269,15 +272,20 @@ namespace NavisworksTransport }; // 保存碰撞时运动物体的位置和朝向(如果可用) - if (collision.HasPositionInfo && collision.Item1Position != null) + if (collision.HasPositionInfo && collision.AnimatedObjectTrackedPosition != null) { - collisionRecord.Item1PosX = collision.Item1Position.X; - collisionRecord.Item1PosY = collision.Item1Position.Y; - collisionRecord.Item1PosZ = collision.Item1Position.Z; - collisionRecord.Item1YawRadians = collision.Item1YawRadians; + collisionRecord.AnimatedObjectTrackedPosX = collision.AnimatedObjectTrackedPosition.X; + collisionRecord.AnimatedObjectTrackedPosY = collision.AnimatedObjectTrackedPosition.Y; + collisionRecord.AnimatedObjectTrackedPosZ = collision.AnimatedObjectTrackedPosition.Z; + collisionRecord.AnimatedObjectTrackedYawRadians = collision.AnimatedObjectTrackedYawRadians; + collisionRecord.AnimatedObjectTrackedRotA = collision.AnimatedObjectTrackedRotation?.A; + collisionRecord.AnimatedObjectTrackedRotB = collision.AnimatedObjectTrackedRotation?.B; + collisionRecord.AnimatedObjectTrackedRotC = collision.AnimatedObjectTrackedRotation?.C; + collisionRecord.AnimatedObjectTrackedRotD = collision.AnimatedObjectTrackedRotation?.D; + collisionRecord.AnimatedObjectHasTrackedRotation = collision.AnimatedObjectHasTrackedRotation; collisionRecord.HasPositionInfo = true; - - LogManager.Debug($"[保存碰撞对象] 记录运动物体位置: ({collisionRecord.Item1PosX:F2}, {collisionRecord.Item1PosY:F2}, {collisionRecord.Item1PosZ:F2}), 朝向: {collisionRecord.Item1YawRadians:F2} rad"); + + LogManager.Debug($"[保存碰撞对象] 记录运动物体位置: ({collisionRecord.AnimatedObjectTrackedPosX:F2}, {collisionRecord.AnimatedObjectTrackedPosY:F2}, {collisionRecord.AnimatedObjectTrackedPosZ:F2}), yaw={collisionRecord.AnimatedObjectTrackedYawRadians:F2} rad, customRotation={collisionRecord.AnimatedObjectHasTrackedRotation}"); } collisionObjects.Add(collisionRecord); @@ -488,13 +496,26 @@ namespace NavisworksTransport }; // 恢复碰撞时运动物体的位置和朝向(如果可用) - if (obj.HasPositionInfo && obj.Item1PosX.HasValue && obj.Item1PosY.HasValue && obj.Item1PosZ.HasValue) + if (obj.HasPositionInfo && obj.AnimatedObjectTrackedPosX.HasValue && obj.AnimatedObjectTrackedPosY.HasValue && obj.AnimatedObjectTrackedPosZ.HasValue) { - collisionResult.Item1Position = new Point3D(obj.Item1PosX.Value, obj.Item1PosY.Value, obj.Item1PosZ.Value); - collisionResult.Item1YawRadians = obj.Item1YawRadians ?? 0.0; + collisionResult.AnimatedObjectTrackedPosition = new Point3D(obj.AnimatedObjectTrackedPosX.Value, obj.AnimatedObjectTrackedPosY.Value, obj.AnimatedObjectTrackedPosZ.Value); + collisionResult.AnimatedObjectTrackedYawRadians = obj.AnimatedObjectTrackedYawRadians ?? 0.0; + collisionResult.AnimatedObjectHasTrackedRotation = obj.AnimatedObjectHasTrackedRotation; + if (obj.AnimatedObjectHasTrackedRotation && + obj.AnimatedObjectTrackedRotA.HasValue && + obj.AnimatedObjectTrackedRotB.HasValue && + obj.AnimatedObjectTrackedRotC.HasValue && + obj.AnimatedObjectTrackedRotD.HasValue) + { + collisionResult.AnimatedObjectTrackedRotation = new Rotation3D( + obj.AnimatedObjectTrackedRotA.Value, + obj.AnimatedObjectTrackedRotB.Value, + obj.AnimatedObjectTrackedRotC.Value, + obj.AnimatedObjectTrackedRotD.Value); + } collisionResult.HasPositionInfo = true; - - LogManager.Debug($"[加载碰撞结果] 恢复运动物体位置: ({obj.Item1PosX:F2}, {obj.Item1PosY:F2}, {obj.Item1PosZ:F2}), 朝向: {obj.Item1YawRadians:F2} rad"); + + LogManager.Debug($"[加载碰撞结果] 恢复运动物体位置: ({obj.AnimatedObjectTrackedPosX:F2}, {obj.AnimatedObjectTrackedPosY:F2}, {obj.AnimatedObjectTrackedPosZ:F2}), yaw={obj.AnimatedObjectTrackedYawRadians:F2} rad, customRotation={obj.AnimatedObjectHasTrackedRotation}"); } results.Add(collisionResult); @@ -776,58 +797,40 @@ namespace NavisworksTransport { var testAnimatedObject = candidate.Item1; var modelItems = new ModelItemCollection { testAnimatedObject }; - var targetPosition = candidate.Item1Position; - var targetYaw = candidate.Item1YawRadians; + var targetPosition = candidate.AnimatedObjectTrackedPosition; + var targetYaw = candidate.AnimatedObjectTrackedYawRadians; + var targetRotation = candidate.AnimatedObjectTrackedRotation; - // 🔥 关键:为了最高精度,先回到CAD原始位置,再移动到目标位置 - // 避免累积误差影响碰撞检测精度 - doc.Models.ResetPermanentTransform(modelItems); - - // 获取CAD原始状态 - var originalBounds = testAnimatedObject.BoundingBox(); - var originalPos = new Point3D( - (originalBounds.Min.X + originalBounds.Max.X) / 2, - (originalBounds.Min.Y + originalBounds.Max.Y) / 2, - (originalBounds.Min.Z + originalBounds.Max.Z) / 2 - ); - var originalYaw = ModelItemTransformHelper.GetYawFromTransform(testAnimatedObject.Transform); - - // 计算从CAD位置到目标位置的偏移和旋转 - var deltaPos = new Vector3D( - targetPosition.X - originalPos.X, - targetPosition.Y - originalPos.Y, - targetPosition.Z - originalPos.Z - ); - double deltaYaw = targetYaw - originalYaw; - - // 应用变换(包含旋转补偿) - Transform3D transform; - if (Math.Abs(deltaYaw) > 0.001) + if (candidate.AnimatedObjectHasTrackedRotation && targetRotation != null) { - // 计算绕原点旋转的补偿 - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = originalPos.X * cos - originalPos.Y * sin; - double rotatedY = originalPos.X * sin + originalPos.Y * cos; - - var compensatedTranslation = new Vector3D( - targetPosition.X - rotatedX, - targetPosition.Y - rotatedY, - deltaPos.Z - ); - - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = compensatedTranslation; - transform = components.Combine(); + LogManager.Info( + $"[ClashDetective验证] 已跟踪姿态候选恢复: " + + $"对象={testAnimatedObject.DisplayName}, " + + $"目标跟踪点=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3})"); + + var pam = PathAnimationManager.GetInstance(); + if (pam != null && pam.ControlsAnimatedObject(testAnimatedObject)) + { + pam.MoveAnimatedObjectToPose( + testAnimatedObject, + targetPosition, + targetYaw, + targetRotation, + true); + } + else + { + throw new InvalidOperationException( + $"ClashDetective已跟踪姿态候选恢复失败:对象 {testAnimatedObject.DisplayName} 不受当前动画管理器控制。"); + } } else { - transform = Transform3D.CreateTranslation(deltaPos); + throw new InvalidOperationException( + $"ClashDetective候选恢复缺少完整姿态:对象 {testAnimatedObject.DisplayName}," + + $"目标跟踪点=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3})," + + $"TrackedYaw={targetYaw:F6}rad。"); } - - doc.Models.OverridePermanentTransform(modelItems, transform, false); var tempTestName = $"临时验证_{confirmedCount + 1}_{i}_{DateTime.Now:HHmmss_fff}"; @@ -889,12 +892,14 @@ namespace NavisworksTransport var confirmedPositionKey = GetCollisionObjectKey(candidate.Item1, candidate.Item2); _confirmedCollisionPositions[confirmedPositionKey] = new CollisionPositionInfo { - Item1Position = candidate.Item1Position, + AnimatedObjectTrackedPosition = candidate.AnimatedObjectTrackedPosition, Item2Position = candidate.Item2Position, - Item1YawRadians = candidate.Item1YawRadians, + AnimatedObjectTrackedYawRadians = candidate.AnimatedObjectTrackedYawRadians, + AnimatedObjectTrackedRotation = candidate.AnimatedObjectTrackedRotation, + AnimatedObjectHasTrackedRotation = candidate.AnimatedObjectHasTrackedRotation, HasPositionInfo = candidate.HasPositionInfo }; - LogManager.Debug($"[ClashDetective确认] 记录碰撞位置: {confirmedPositionKey}, 位置: ({candidate.Item1Position.X:F2}, {candidate.Item1Position.Y:F2}, {candidate.Item1Position.Z:F2})"); + LogManager.Debug($"[ClashDetective确认] 记录碰撞位置: {confirmedPositionKey}, 位置: ({candidate.AnimatedObjectTrackedPosition.X:F2}, {candidate.AnimatedObjectTrackedPosition.Y:F2}, {candidate.AnimatedObjectTrackedPosition.Z:F2})"); int subResultIndex = 1; // 🔥 优化:预获取运动物体名称,避免在循环中重复获取 @@ -1003,9 +1008,11 @@ namespace NavisworksTransport Distance = clashResult.Distance, CreatedTime = DateTime.Now, // 🔥 使用ClashDetective确认时的位置信息 - Item1Position = confirmedPosition?.Item1Position, + AnimatedObjectTrackedPosition = confirmedPosition?.AnimatedObjectTrackedPosition, Item2Position = confirmedPosition?.Item2Position, - Item1YawRadians = confirmedPosition?.Item1YawRadians ?? 0, + AnimatedObjectTrackedYawRadians = confirmedPosition?.AnimatedObjectTrackedYawRadians ?? 0, + AnimatedObjectTrackedRotation = confirmedPosition?.AnimatedObjectTrackedRotation, + AnimatedObjectHasTrackedRotation = confirmedPosition?.AnimatedObjectHasTrackedRotation ?? false, HasPositionInfo = confirmedPosition != null }; clashResults.Add(collisionResult); @@ -1020,7 +1027,7 @@ namespace NavisworksTransport .ToList(); // 🔥 日志:统计位置信息保留情况 - var withPositionCount = finalClashResults.Count(c => c.HasPositionInfo && c.Item1Position != null); + var withPositionCount = finalClashResults.Count(c => c.HasPositionInfo && c.AnimatedObjectTrackedPosition != null); LogManager.Info($"[最终去重] ClashDetective结果去重: {clashResults.Count} 个碰撞 -> {finalClashResults.Count} 个唯一碰撞对,其中 {withPositionCount} 个包含位置信息"); // 🔥 优化:在去重后为结果设置详细的 DisplayName @@ -1096,7 +1103,7 @@ namespace NavisworksTransport LogManager.Info($"[预计算数据] 共有 {precomputedCollisions.Count} 个碰撞记录"); // 检查预计算碰撞结果是否包含位置信息 - var collisionsWithPosition = precomputedCollisions.Count(c => c.HasPositionInfo && c.Item1Position != null); + var collisionsWithPosition = precomputedCollisions.Count(c => c.HasPositionInfo && c.AnimatedObjectTrackedPosition != null); LogManager.Info($"[预计算数据] 包含位置信息的碰撞: {collisionsWithPosition}/{precomputedCollisions.Count}"); // 直接使用所有预计算结果,只过滤有效对象(不去重) @@ -2132,9 +2139,11 @@ namespace NavisworksTransport public DateTime CreatedTime { get; set; } // 位置和朝向信息用于还原碰撞场景 - public Point3D Item1Position { get; set; } + public Point3D AnimatedObjectTrackedPosition { get; set; } public Point3D Item2Position { get; set; } - public double Item1YawRadians { get; set; } // 运动物体朝向(弧度) + public double AnimatedObjectTrackedYawRadians { get; set; } // 动画跟踪点对应的运动物体朝向(弧度) + public Rotation3D AnimatedObjectTrackedRotation { get; set; } // 动画跟踪点对应的运动物体完整三维姿态 + public bool AnimatedObjectHasTrackedRotation { get; set; } public bool HasPositionInfo { get; set; } // IEquatable 实现:基于碰撞对象进行去重 @@ -2199,4 +2208,4 @@ namespace NavisworksTransport CollisionCount = collisionCount; } } -} \ No newline at end of file +} diff --git a/src/Core/Config/ConfigManager.cs b/src/Core/Config/ConfigManager.cs index a89a216..5b04d96 100644 --- a/src/Core/Config/ConfigManager.cs +++ b/src/Core/Config/ConfigManager.cs @@ -394,6 +394,10 @@ namespace NavisworksTransport.Core.Config config.PathEditing.SafetyMarginMeters = GetDoubleValueWithDefault(pathEdit, "safety_margin_meters", 0.1, missingItems); config.PathEditing.DefaultPathTurnRadiusMeters = GetDoubleValueWithDefault(pathEdit, "default_path_turn_radius", 2.5, missingItems); config.PathEditing.ArcSamplingStepMeters = GetDoubleValueWithDefault(pathEdit, "arc_sampling_step", 0.05, missingItems); + config.PathEditing.HoistingViewDistanceMeters = GetDoubleValueWithDefault(pathEdit, "hoisting_view_distance_meters", 6.0, missingItems); + config.PathEditing.HoistingViewElevationDegrees = GetDoubleValueWithDefault(pathEdit, "hoisting_view_elevation_degrees", 30.0, missingItems); + config.PathEditing.RailViewDistanceMeters = GetDoubleValueWithDefault(pathEdit, "rail_view_distance_meters", 6.0, missingItems); + config.PathEditing.RailViewElevationDegrees = GetDoubleValueWithDefault(pathEdit, "rail_view_elevation_degrees", 30.0, missingItems); } } diff --git a/src/Core/Config/SystemConfig.cs b/src/Core/Config/SystemConfig.cs index c45e4e2..1c424f7 100644 --- a/src/Core/Config/SystemConfig.cs +++ b/src/Core/Config/SystemConfig.cs @@ -119,6 +119,26 @@ namespace NavisworksTransport.Core.Config /// public double ArcSamplingStepMeters { get; set; } + /// + /// 吊装路径自动视角距离(米) + /// + public double HoistingViewDistanceMeters { get; set; } + + /// + /// 吊装路径自动视角仰角(度) + /// + public double HoistingViewElevationDegrees { get; set; } + + /// + /// Rail 路径自动视角距离(米) + /// + public double RailViewDistanceMeters { get; set; } + + /// + /// Rail 路径自动视角仰角(度) + /// + public double RailViewElevationDegrees { get; set; } + // === 模型单位接口(用于内部计算) === /// @@ -160,6 +180,16 @@ namespace NavisworksTransport.Core.Config /// 圆弧采样步长(模型单位) /// public double ArcSamplingStep => ArcSamplingStepMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor(); + + /// + /// 吊装路径自动视角距离(模型单位) + /// + public double HoistingViewDistance => HoistingViewDistanceMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor(); + + /// + /// Rail 路径自动视角距离(模型单位) + /// + public double RailViewDistance => RailViewDistanceMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor(); } /// @@ -278,5 +308,6 @@ namespace NavisworksTransport.Core.Config /// YUp: 强制使用 Y-Up 坐标系 /// public string Type { get; set; } = "AutoDetect"; + } } diff --git a/src/Core/MainPlugin.cs b/src/Core/MainPlugin.cs index e2a2c46..eb5e92d 100644 --- a/src/Core/MainPlugin.cs +++ b/src/Core/MainPlugin.cs @@ -7,6 +7,7 @@ using System.Windows.Forms.Integration; using NavisworksTransport.Core; using NavisworksTransport.Core.Animation; using NavisworksTransport.Core.Config; +using NavisworksTransport.Core.Services; using NavisworksTransport.Utils; using NavisworksTransport.Utils.CoordinateSystem; using NavisApplication = Autodesk.Navisworks.Api.Application; @@ -381,6 +382,9 @@ namespace NavisworksTransport // 订阅文档事件 SubscribeToDocumentEvents(); + + // 启动本地测试自动化 HTTP 控制面 + TestAutomationHttpService.Instance.Start(); // 检查是否已有活动文档且包含模型 var activeDoc = NavisApplication.ActiveDocument; @@ -412,6 +416,9 @@ namespace NavisworksTransport // 清理管理器 CleanupManagers(); + + // 停止本地测试自动化 HTTP 控制面 + TestAutomationHttpService.Instance.Stop(); base.OnUnloading(); } @@ -574,7 +581,7 @@ namespace NavisworksTransport { LogManager.Info($"[文档管理] *** 状态分析: 从无到有(可能是打开文档)***"); - // 文档加载完成,初始化PathDatabase + // 文档加载完成,仅连接已存在的数据库,不为未使用插件的文档自动创建 .db if (!string.IsNullOrEmpty(activeDoc?.FileName)) { try @@ -582,33 +589,15 @@ namespace NavisworksTransport var pathManager = PathPlanningManager.GetActivePathManager(); if (pathManager != null) { - pathManager.DatabaseInitialize(); + pathManager.DatabaseInitialize(createIfMissing: false); } } catch (Exception ex) { - // 数据库初始化失败是严重错误,需要明确提示用户 - var errorMsg = $"数据库初始化失败:{ex.Message}\n\n"; - errorMsg += "可能的原因:\n"; - errorMsg += "1. 文件夹权限不足\n"; - errorMsg += "2. 磁盘空间不足\n"; - errorMsg += "3. 数据库文件被锁定\n"; - errorMsg += "4. SQLite 组件损坏\n\n"; - errorMsg += "详细信息请查看日志文件。"; - - LogManager.Error($"[文档管理] 数据库初始化失败: {ex.Message}", ex); + LogManager.Error($"[文档管理] 连接现有数据库失败: {ex.Message}", ex); LogManager.Error($"[文档管理] 文档路径: {activeDoc.FileName}"); LogManager.Error($"[文档管理] 异常类型: {ex.GetType().Name}"); LogManager.Error($"[文档管理] 堆栈信息: {ex.StackTrace}"); - - System.Windows.MessageBox.Show( - errorMsg, - "数据库初始化失败", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Error - ); - - // 不重新抛出,避免插件崩溃,但批处理等功能将不可用 } } } @@ -937,4 +926,4 @@ namespace NavisworksTransport } } -} \ No newline at end of file +} diff --git a/src/Core/PathAnalysisEngine.cs b/src/Core/PathAnalysisEngine.cs index 8bacd87..01b34fb 100644 --- a/src/Core/PathAnalysisEngine.cs +++ b/src/Core/PathAnalysisEngine.cs @@ -653,14 +653,14 @@ namespace NavisworksTransport.Core var collisions = new List(); foreach (var obj in collisionObjects) { - if (obj.Item1PosX.HasValue && obj.Item1PosY.HasValue && obj.Item1PosZ.HasValue) + if (obj.AnimatedObjectTrackedPosX.HasValue && obj.AnimatedObjectTrackedPosY.HasValue && obj.AnimatedObjectTrackedPosZ.HasValue) { collisions.Add(new CollisionResult { Center = new Point3D( - obj.Item1PosX.Value, - obj.Item1PosY.Value, - obj.Item1PosZ.Value), + obj.AnimatedObjectTrackedPosX.Value, + obj.AnimatedObjectTrackedPosY.Value, + obj.AnimatedObjectTrackedPosZ.Value), Item2 = ModelItemProxyHelper.CreateProxy(obj.DisplayName) }); } diff --git a/src/Core/PathCurveEngine.cs b/src/Core/PathCurveEngine.cs index b895442..fac612c 100644 --- a/src/Core/PathCurveEngine.cs +++ b/src/Core/PathCurveEngine.cs @@ -439,7 +439,6 @@ namespace NavisworksTransport var edge = BuildStraightEdge(sortedPoints[0], sortedPoints[1], samplingStep); route.Edges.Add(edge); route.IsCurved = true; - RecalculateRouteLength(route); return; } @@ -493,7 +492,6 @@ namespace NavisworksTransport route.Edges.Add(lastEdge); route.IsCurved = true; - RecalculateRouteLength(route); } /// @@ -510,4 +508,4 @@ namespace NavisworksTransport // 长度会自动从 Edges 或 Points 计算 } } -} \ No newline at end of file +} diff --git a/src/Core/PathDataManager.cs b/src/Core/PathDataManager.cs index c797993..c78ae3f 100644 --- a/src/Core/PathDataManager.cs +++ b/src/Core/PathDataManager.cs @@ -48,6 +48,10 @@ namespace NavisworksTransport public string name { get; set; } public string description { get; set; } public string pathType { get; set; } + public string railMountMode { get; set; } + public string railPathDefinitionMode { get; set; } + public double railNormalOffset { get; set; } + public JsonVector3 railPreferredNormal { get; set; } public double totalLength { get; set; } public JsonObjectLimits objectLimits { get; set; } public double gridSize { get; set; } @@ -56,6 +60,13 @@ namespace NavisworksTransport public JsonPathPoint[] points { get; set; } } + public class JsonVector3 + { + public double x { get; set; } + public double y { get; set; } + public double z { get; set; } + } + /// /// JSON格式的项目信息 /// @@ -275,6 +286,15 @@ namespace NavisworksTransport name = route.Name, description = route.Description ?? "", pathType = route.PathType.ToString(), + railMountMode = route.RailMountMode.ToString(), + railPathDefinitionMode = route.RailPathDefinitionMode.ToString(), + railNormalOffset = Math.Round(route.RailNormalOffset, exportSettings?.Precision ?? 3), + railPreferredNormal = route.RailPreferredNormal != null ? new + { + x = Math.Round(route.RailPreferredNormal.X, exportSettings?.Precision ?? 3), + y = Math.Round(route.RailPreferredNormal.Y, exportSettings?.Precision ?? 3), + z = Math.Round(route.RailPreferredNormal.Z, exportSettings?.Precision ?? 3) + } : null, totalLength = Math.Round(route.TotalLength, exportSettings?.Precision ?? 3), objectLimits = new { @@ -512,6 +532,28 @@ namespace NavisworksTransport continue; } + if (!string.IsNullOrEmpty(jsonRoute.railMountMode) && + Enum.TryParse(jsonRoute.railMountMode, out var railMountMode)) + { + route.RailMountMode = railMountMode; + } + + if (!string.IsNullOrEmpty(jsonRoute.railPathDefinitionMode) && + Enum.TryParse(jsonRoute.railPathDefinitionMode, out var railPathDefinitionMode)) + { + route.RailPathDefinitionMode = railPathDefinitionMode; + } + + route.RailNormalOffset = jsonRoute.railNormalOffset; + + if (jsonRoute.railPreferredNormal != null) + { + route.RailPreferredNormal = new Point3D( + jsonRoute.railPreferredNormal.x, + jsonRoute.railPreferredNormal.y, + jsonRoute.railPreferredNormal.z); + } + // 解析路径点 var points = new List(); foreach (var jsonPoint in jsonRoute.points) @@ -1369,6 +1411,15 @@ namespace NavisworksTransport routeElement.SetAttribute("name", route.Name); routeElement.SetAttribute("description", route.Description ?? ""); routeElement.SetAttribute("pathType", route.PathType.ToString()); + routeElement.SetAttribute("railMountMode", route.RailMountMode.ToString()); + routeElement.SetAttribute("railPathDefinitionMode", route.RailPathDefinitionMode.ToString()); + routeElement.SetAttribute("railNormalOffset", route.RailNormalOffset.ToString("F3")); + if (route.RailPreferredNormal != null) + { + routeElement.SetAttribute("railPreferredNormalX", route.RailPreferredNormal.X.ToString("F3")); + routeElement.SetAttribute("railPreferredNormalY", route.RailPreferredNormal.Y.ToString("F3")); + routeElement.SetAttribute("railPreferredNormalZ", route.RailPreferredNormal.Z.ToString("F3")); + } routeElement.SetAttribute("totalLength", route.TotalLength.ToString("F3")); routeElement.SetAttribute("maxObjectLength", route.MaxObjectLength.ToString("F3")); routeElement.SetAttribute("maxObjectWidth", route.MaxObjectWidth.ToString("F3")); @@ -1501,6 +1552,31 @@ namespace NavisworksTransport LiftHeight = double.TryParse(routeNode.Attributes?["liftHeight"]?.Value, out double liftHeight) ? liftHeight : 0 }; + if (Enum.TryParse(routeNode.Attributes?["railMountMode"]?.Value, out var railMountMode)) + { + route.RailMountMode = railMountMode; + } + + if (Enum.TryParse(routeNode.Attributes?["railPathDefinitionMode"]?.Value, out var railPathDefinitionMode)) + { + route.RailPathDefinitionMode = railPathDefinitionMode; + } + + if (double.TryParse(routeNode.Attributes?["railNormalOffset"]?.Value, out var railNormalOffset)) + { + route.RailNormalOffset = railNormalOffset; + } + + if (double.TryParse(routeNode.Attributes?["railPreferredNormalX"]?.Value, out var railPreferredNormalX) && + double.TryParse(routeNode.Attributes?["railPreferredNormalY"]?.Value, out var railPreferredNormalY) && + double.TryParse(routeNode.Attributes?["railPreferredNormalZ"]?.Value, out var railPreferredNormalZ)) + { + route.RailPreferredNormal = new Point3D( + railPreferredNormalX, + railPreferredNormalY, + railPreferredNormalZ); + } + // 解析创建时间 if (DateTime.TryParse(routeNode.Attributes?["created"]?.Value, out DateTime createdTime)) { @@ -1727,4 +1803,4 @@ namespace NavisworksTransport return Errors.Concat(Warnings); } } -} \ No newline at end of file +} diff --git a/src/Core/PathDatabase.cs b/src/Core/PathDatabase.cs index b1757cd..857d56a 100644 --- a/src/Core/PathDatabase.cs +++ b/src/Core/PathDatabase.cs @@ -63,6 +63,7 @@ namespace NavisworksTransport // 创建数据库表结构 CreateTables(); + EnsurePathRoutesTableExtended(); if (isNewDatabase) { @@ -98,6 +99,12 @@ namespace NavisworksTransport GridSize REAL, PathType INTEGER, LiftHeight REAL, + RailMountMode INTEGER, + RailPathDefinitionMode INTEGER, + RailNormalOffset REAL, + RailPreferredNormalX REAL, + RailPreferredNormalY REAL, + RailPreferredNormalZ REAL, CreatedTime DATETIME, LastModified DATETIME ) @@ -230,11 +237,16 @@ namespace NavisworksTransport PathId TEXT, DisplayName TEXT, ObjectName TEXT, - -- 碰撞时运动物体的位置和朝向(用于还原碰撞场景) - Item1PosX REAL, - Item1PosY REAL, - Item1PosZ REAL, - Item1YawRadians REAL, + -- 碰撞时运动物体的动画跟踪点位置和姿态(用于还原碰撞场景) + AnimatedObjectTrackedPosX REAL, + AnimatedObjectTrackedPosY REAL, + AnimatedObjectTrackedPosZ REAL, + AnimatedObjectTrackedYawRadians REAL, + AnimatedObjectTrackedRotA REAL, + AnimatedObjectTrackedRotB REAL, + AnimatedObjectTrackedRotC REAL, + AnimatedObjectTrackedRotD REAL, + AnimatedObjectHasTrackedRotation INTEGER DEFAULT 0, HasPositionInfo INTEGER DEFAULT 0, FOREIGN KEY(DetectionRecordId) REFERENCES CollisionDetectionRecords(Id) ON DELETE CASCADE ) @@ -291,8 +303,8 @@ namespace NavisworksTransport // 12. 设置数据库版本(SQLite内置user_version) // 版本号格式:主版本*10000 + 次版本*100 + 修订号 - // 例如:2.1.3 = 20103 - ExecuteNonQuery("PRAGMA user_version = 20103"); // v2.1.3 - 重命名DetectionToleranceMeters为DetectionTolerance + // 例如:2.1.6 = 20106 + ExecuteNonQuery("PRAGMA user_version = 20106"); // v2.1.6 - 删除无效的 RailReferenceToAnchorOffset 字段 // 创建索引 ExecuteNonQuery("CREATE INDEX IF NOT EXISTS idx_reports_route ON CollisionReports(RouteId)"); @@ -455,8 +467,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, CreatedTime, LastModified) - VALUES (@id, @name, @time, @turnRadius, @isCurved, @maxLength, @maxWidth, @maxHeight, @safetyMargin, @gridSize, @pathType, @liftHeightMeters, @created, @modified) + (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) "; using (var cmd = new SQLiteCommand(sql, _connection)) @@ -473,6 +485,12 @@ namespace NavisworksTransport cmd.Parameters.AddWithValue("@gridSize", route.GridSize); cmd.Parameters.AddWithValue("@pathType", (int)route.PathType); cmd.Parameters.AddWithValue("@liftHeightMeters", route.LiftHeight); + cmd.Parameters.AddWithValue("@railMountMode", (int)route.RailMountMode); + cmd.Parameters.AddWithValue("@railPathDefinitionMode", (int)route.RailPathDefinitionMode); + cmd.Parameters.AddWithValue("@railNormalOffset", route.RailNormalOffset); + 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("@created", route.CreatedTime); cmd.Parameters.AddWithValue("@modified", DateTime.Now); cmd.ExecuteNonQuery(); @@ -1102,9 +1120,11 @@ namespace NavisworksTransport var sql = @" INSERT INTO ClashDetectiveCollisionObjects (DetectionRecordId, ModelIndex, PathId, DisplayName, ObjectName, - Item1PosX, Item1PosY, Item1PosZ, Item1YawRadians, HasPositionInfo) + AnimatedObjectTrackedPosX, AnimatedObjectTrackedPosY, AnimatedObjectTrackedPosZ, AnimatedObjectTrackedYawRadians, + AnimatedObjectTrackedRotA, AnimatedObjectTrackedRotB, AnimatedObjectTrackedRotC, AnimatedObjectTrackedRotD, AnimatedObjectHasTrackedRotation, HasPositionInfo) VALUES (@detectionRecordId, @modelIndex, @pathId, @displayName, @objectName, - @item1PosX, @item1PosY, @item1PosZ, @item1YawRadians, @hasPositionInfo) + @trackedPosX, @trackedPosY, @trackedPosZ, @trackedYawRadians, + @trackedRotA, @trackedRotB, @trackedRotC, @trackedRotD, @hasTrackedRotation, @hasPositionInfo) "; foreach (var obj in objects) @@ -1116,10 +1136,15 @@ namespace NavisworksTransport cmd.Parameters.AddWithValue("@pathId", obj.PathId ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@displayName", obj.DisplayName ?? ""); cmd.Parameters.AddWithValue("@objectName", obj.ObjectName ?? ""); - cmd.Parameters.AddWithValue("@item1PosX", obj.Item1PosX.HasValue ? (object)obj.Item1PosX.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@item1PosY", obj.Item1PosY.HasValue ? (object)obj.Item1PosY.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@item1PosZ", obj.Item1PosZ.HasValue ? (object)obj.Item1PosZ.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@item1YawRadians", obj.Item1YawRadians.HasValue ? (object)obj.Item1YawRadians.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedPosX", obj.AnimatedObjectTrackedPosX.HasValue ? (object)obj.AnimatedObjectTrackedPosX.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedPosY", obj.AnimatedObjectTrackedPosY.HasValue ? (object)obj.AnimatedObjectTrackedPosY.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedPosZ", obj.AnimatedObjectTrackedPosZ.HasValue ? (object)obj.AnimatedObjectTrackedPosZ.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedYawRadians", obj.AnimatedObjectTrackedYawRadians.HasValue ? (object)obj.AnimatedObjectTrackedYawRadians.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedRotA", obj.AnimatedObjectTrackedRotA.HasValue ? (object)obj.AnimatedObjectTrackedRotA.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedRotB", obj.AnimatedObjectTrackedRotB.HasValue ? (object)obj.AnimatedObjectTrackedRotB.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedRotC", obj.AnimatedObjectTrackedRotC.HasValue ? (object)obj.AnimatedObjectTrackedRotC.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trackedRotD", obj.AnimatedObjectTrackedRotD.HasValue ? (object)obj.AnimatedObjectTrackedRotD.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@hasTrackedRotation", obj.AnimatedObjectHasTrackedRotation ? 1 : 0); cmd.Parameters.AddWithValue("@hasPositionInfo", obj.HasPositionInfo ? 1 : 0); cmd.ExecuteNonQuery(); } @@ -1147,7 +1172,8 @@ namespace NavisworksTransport { var sql = @" SELECT Id, DetectionRecordId, ModelIndex, PathId, DisplayName, ObjectName, - Item1PosX, Item1PosY, Item1PosZ, Item1YawRadians, HasPositionInfo + AnimatedObjectTrackedPosX, AnimatedObjectTrackedPosY, AnimatedObjectTrackedPosZ, AnimatedObjectTrackedYawRadians, + AnimatedObjectTrackedRotA, AnimatedObjectTrackedRotB, AnimatedObjectTrackedRotC, AnimatedObjectTrackedRotD, AnimatedObjectHasTrackedRotation, HasPositionInfo FROM ClashDetectiveCollisionObjects WHERE DetectionRecordId = @detectionRecordId "; @@ -1167,17 +1193,22 @@ namespace NavisworksTransport PathId = reader["PathId"] != DBNull.Value ? reader["PathId"].ToString() : null, DisplayName = reader["DisplayName"]?.ToString(), ObjectName = reader["ObjectName"]?.ToString(), - Item1PosX = reader["Item1PosX"] != DBNull.Value ? Convert.ToDouble(reader["Item1PosX"]) : (double?)null, - Item1PosY = reader["Item1PosY"] != DBNull.Value ? Convert.ToDouble(reader["Item1PosY"]) : (double?)null, - Item1PosZ = reader["Item1PosZ"] != DBNull.Value ? Convert.ToDouble(reader["Item1PosZ"]) : (double?)null, - Item1YawRadians = reader["Item1YawRadians"] != DBNull.Value ? Convert.ToDouble(reader["Item1YawRadians"]) : (double?)null, + AnimatedObjectTrackedPosX = reader["AnimatedObjectTrackedPosX"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedPosX"]) : (double?)null, + AnimatedObjectTrackedPosY = reader["AnimatedObjectTrackedPosY"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedPosY"]) : (double?)null, + AnimatedObjectTrackedPosZ = reader["AnimatedObjectTrackedPosZ"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedPosZ"]) : (double?)null, + AnimatedObjectTrackedYawRadians = reader["AnimatedObjectTrackedYawRadians"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedYawRadians"]) : (double?)null, + AnimatedObjectTrackedRotA = reader["AnimatedObjectTrackedRotA"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedRotA"]) : (double?)null, + AnimatedObjectTrackedRotB = reader["AnimatedObjectTrackedRotB"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedRotB"]) : (double?)null, + AnimatedObjectTrackedRotC = reader["AnimatedObjectTrackedRotC"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedRotC"]) : (double?)null, + AnimatedObjectTrackedRotD = reader["AnimatedObjectTrackedRotD"] != DBNull.Value ? Convert.ToDouble(reader["AnimatedObjectTrackedRotD"]) : (double?)null, + AnimatedObjectHasTrackedRotation = reader["AnimatedObjectHasTrackedRotation"] != DBNull.Value && Convert.ToInt32(reader["AnimatedObjectHasTrackedRotation"]) == 1, HasPositionInfo = reader["HasPositionInfo"] != DBNull.Value && Convert.ToInt32(reader["HasPositionInfo"]) == 1 }); } } } - LogManager.Info($"[GetClashDetectiveCollisionObjects] 查询到 {results.Count} 个碰撞对象,DetectionRecordId={detectionRecordId}"); + LogManager.Info($"[获取碰撞对象] 查询到 {results.Count} 个碰撞对象,DetectionRecordId={detectionRecordId}"); } catch (Exception ex) { @@ -1515,10 +1546,23 @@ namespace NavisworksTransport GridSize = Convert.ToDouble(reader["GridSize"]), PathType = (PathType)Convert.ToInt32(reader["PathType"]), 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, CreatedTime = Convert.ToDateTime(reader["CreatedTime"]), LastModified = Convert.ToDateTime(reader["LastModified"]) }; + if (!reader.IsDBNull(reader.GetOrdinal("RailPreferredNormalX")) && + !reader.IsDBNull(reader.GetOrdinal("RailPreferredNormalY")) && + !reader.IsDBNull(reader.GetOrdinal("RailPreferredNormalZ"))) + { + route.RailPreferredNormal = new Point3D( + Convert.ToDouble(reader["RailPreferredNormalX"]), + Convert.ToDouble(reader["RailPreferredNormalY"]), + Convert.ToDouble(reader["RailPreferredNormalZ"])); + } + // 添加分析结果(如果存在) if (!reader.IsDBNull(reader.GetOrdinal("CollisionCount"))) { @@ -2696,7 +2740,8 @@ namespace NavisworksTransport cmd.CommandText = @" SELECT Id, Name, CreatedTime, LastModified, TurnRadius, IsCurved, MaxObjectLength, MaxObjectWidth, MaxObjectHeight, - SafetyMargin, GridSize, PathType, LiftHeight + SafetyMargin, GridSize, PathType, LiftHeight, RailMountMode, RailPathDefinitionMode, + RailNormalOffset, RailPreferredNormalX, RailPreferredNormalY, RailPreferredNormalZ FROM PathRoutes WHERE Id = @Id"; @@ -2720,9 +2765,22 @@ namespace NavisworksTransport SafetyMargin = Convert.ToDouble(reader["SafetyMargin"]), GridSize = Convert.ToDouble(reader["GridSize"]), PathType = (PathType)Convert.ToInt32(reader["PathType"]), - LiftHeight = Convert.ToDouble(reader["LiftHeight"]) + 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 }; + if (reader["RailPreferredNormalX"] != DBNull.Value && + reader["RailPreferredNormalY"] != DBNull.Value && + reader["RailPreferredNormalZ"] != DBNull.Value) + { + route.RailPreferredNormal = new Point3D( + Convert.ToDouble(reader["RailPreferredNormalX"]), + Convert.ToDouble(reader["RailPreferredNormalY"]), + Convert.ToDouble(reader["RailPreferredNormalZ"])); + } + // 加载路径点和路径边 LoadPathPoints(route); LoadPathEdges(route); @@ -2735,6 +2793,50 @@ namespace NavisworksTransport #endregion + /// + /// 检查并扩展 PathRoutes 表结构 + /// + public void EnsurePathRoutesTableExtended() + { + try + { + var checkSql = "PRAGMA table_info(PathRoutes)"; + var existingColumns = new HashSet(); + + using (var cmd = new SQLiteCommand(checkSql, _connection)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + existingColumns.Add(reader["name"].ToString().ToLower()); + } + } + + var columnsToAdd = new Dictionary + { + ["railmountmode"] = "ALTER TABLE PathRoutes ADD COLUMN RailMountMode INTEGER DEFAULT 0", + ["railpathdefinitionmode"] = "ALTER TABLE PathRoutes ADD COLUMN RailPathDefinitionMode INTEGER DEFAULT 0", + ["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" + }; + + foreach (var column in columnsToAdd) + { + if (!existingColumns.Contains(column.Key)) + { + ExecuteNonQuery(column.Value); + LogManager.Info($"扩展 PathRoutes 表: 添加字段 {column.Key}"); + } + } + } + catch (Exception ex) + { + LogManager.Error($"扩展 PathRoutes 表失败: {ex.Message}", ex); + } + } + #region 批处理队列管理(简化版) /// @@ -3489,11 +3591,16 @@ namespace NavisworksTransport public string DisplayName { get; set; } public string ObjectName { get; set; } - // 碰撞时运动物体的位置和朝向(用于还原碰撞场景) - public double? Item1PosX { get; set; } - public double? Item1PosY { get; set; } - public double? Item1PosZ { get; set; } - public double? Item1YawRadians { get; set; } + // 碰撞时运动物体的动画跟踪点位置和姿态(用于还原碰撞场景) + public double? AnimatedObjectTrackedPosX { get; set; } + public double? AnimatedObjectTrackedPosY { get; set; } + public double? AnimatedObjectTrackedPosZ { get; set; } + public double? AnimatedObjectTrackedYawRadians { get; set; } + public double? AnimatedObjectTrackedRotA { get; set; } + public double? AnimatedObjectTrackedRotB { get; set; } + public double? AnimatedObjectTrackedRotC { get; set; } + public double? AnimatedObjectTrackedRotD { get; set; } + public bool AnimatedObjectHasTrackedRotation { get; set; } public bool HasPositionInfo { get; set; } } diff --git a/src/Core/PathInputMonitor.cs b/src/Core/PathInputMonitor.cs index 6f376c7..04e119e 100644 --- a/src/Core/PathInputMonitor.cs +++ b/src/Core/PathInputMonitor.cs @@ -43,17 +43,17 @@ namespace NavisworksTransport if (key == 32) // 空格键 { var pathManager = PathPlanningManager.GetActivePathManager(); - if (pathManager != null && - (pathManager.PathEditState == PathEditState.Creating || - pathManager.PathEditState == PathEditState.AddingPoints || - pathManager.PathEditState == PathEditState.EditingPoint)) + if (pathManager != null) { var currentTool = Application.MainDocument.Tool.Value; if (currentTool != Tool.CustomToolPlugin) { - LogManager.Info($"[InputMonitor] 用户按空格键,当前工具为{currentTool},重新激活工具"); - pathManager.ReactivateToolPlugin(); - return true; + LogManager.Info($"[InputMonitor] 用户按空格键,当前工具为{currentTool},强制恢复ToolPlugin焦点"); + bool restored = pathManager.RestoreToolPluginFocusForCurrentContext(); + if (restored) + { + return true; + } } } } @@ -67,4 +67,4 @@ namespace NavisworksTransport } } } -} \ No newline at end of file +} diff --git a/src/Core/PathPlanningManager.cs b/src/Core/PathPlanningManager.cs index e61ac37..f109fd2 100644 --- a/src/Core/PathPlanningManager.cs +++ b/src/Core/PathPlanningManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using Autodesk.Navisworks.Api; @@ -9,6 +10,7 @@ using NavisworksTransport.Utils; using NavisworksTransport.Core; using NavisworksTransport.Core.Config; using NavisworksTransport.Commands; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport { @@ -30,6 +32,7 @@ namespace NavisworksTransport // 路径分析相关 private PathDatabase _pathDatabase; private PathAnalysisService _analysisService; + private string _databaseDocumentPath; private List _walkableAreas; private List _routes; private PathRoute _currentRoute; @@ -166,33 +169,61 @@ namespace NavisworksTransport LogManager.Info($"PathPlanningManager初始化完成,ManagerId: {_managerId}"); - // 注意:数据库初始化延迟到文档加载完成后 - // 在 MainPlugin.OnModelsCollectionChanged 中会调用 DatabaseInitialize() + // 注意:数据库采用按需初始化。 + // 文档加载时只尝试连接已存在的数据库,不再为未使用插件的文档自动创建 .db 文件。 } /// /// 路径分析数据库初始化 /// 此方法应在文档加载完成后调用 /// - public void DatabaseInitialize() + public void DatabaseInitialize(bool createIfMissing = true) { try { var documentPath = Application.ActiveDocument?.FileName; - if (!string.IsNullOrEmpty(documentPath)) + if (string.IsNullOrEmpty(documentPath)) { - _pathDatabase = new PathDatabase(documentPath); - _analysisService = new PathAnalysisService(_pathDatabase); - LogManager.Info($"路径分析数据库初始化成功,文档路径: {documentPath}"); - - // 从数据库加载历史路径到内存 - LoadHistoricalRoutesFromDatabase(); - } - else - { - // 文档路径为空时,静默跳过(这种情况不应该发生,因为调用者应该确保文档已加载) LogManager.Debug("文档路径为空,跳过数据库初始化"); + return; } + + if (_pathDatabase != null && + string.Equals(_databaseDocumentPath, documentPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string dbPath = Path.ChangeExtension(documentPath, ".db"); + if (!createIfMissing && !File.Exists(dbPath)) + { + LogManager.Info($"文档对应数据库不存在,跳过自动连接: {dbPath}"); + return; + } + + if (_pathDatabase != null) + { + try + { + _pathDatabase.Dispose(); + } + catch (Exception disposeEx) + { + LogManager.Warning($"释放旧数据库连接时出现警告: {disposeEx.Message}"); + } + + _pathDatabase = null; + _analysisService = null; + _databaseDocumentPath = null; + } + + _pathDatabase = new PathDatabase(documentPath); + _analysisService = new PathAnalysisService(_pathDatabase); + _databaseDocumentPath = documentPath; + LogManager.Info($"路径分析数据库初始化成功,文档路径: {documentPath}"); + + // 从数据库加载历史路径到内存 + LoadHistoricalRoutesFromDatabase(); } catch (Exception ex) { @@ -1041,7 +1072,12 @@ namespace NavisworksTransport // 1. 获取模型边界 var bounds = GetModelBounds() ?? throw new Exception("无法获取模型边界,请确保模型已加载"); - LogManager.Info($"模型边界: Min({bounds.Min.X:F2}, {bounds.Min.Y:F2}), Max({bounds.Max.X:F2}, {bounds.Max.Y:F2})"); + var hostAdapter = new HostCoordinateAdapter(CoordinateSystemManager.Instance.ResolvedType); + var hostBoundsMin = new System.Numerics.Vector3((float)bounds.Min.X, (float)bounds.Min.Y, (float)bounds.Min.Z); + var hostBoundsMax = new System.Numerics.Vector3((float)bounds.Max.X, (float)bounds.Max.Y, (float)bounds.Max.Z); + var (boundsMinH1, boundsMaxH1, boundsMinH2, boundsMaxH2) = + HostPlanarGridHelper.GetHorizontalRange3(hostBoundsMin, hostBoundsMax, hostAdapter); + LogManager.Info($"模型边界: H1[{boundsMinH1:F2}, {boundsMaxH1:F2}], H2[{boundsMinH2:F2}, {boundsMaxH2:F2}]"); // 智能选择网格大小 if (gridSize <= 0) @@ -1065,7 +1101,7 @@ namespace NavisworksTransport // 获取当前文档 var document = Application.ActiveDocument; - LogManager.Info($"网格生成参数 - 边界: {bounds.Min.X:F2},{bounds.Min.Y:F2} -> {bounds.Max.X:F2},{bounds.Max.Y:F2}"); + LogManager.Info($"网格生成参数 - 边界: H1 {boundsMinH1:F2}->{boundsMaxH1:F2}, H2 {boundsMinH2:F2}->{boundsMaxH2:F2}"); LogManager.Info($"网格生成参数 - 网格大小: {gridSize}m, 物体半径: {objectRadius}m, 安全边距: {safetyMargin}m"); LogManager.Info($"网格生成参数 - 起点: ({startPoint.Position.X:F2}, {startPoint.Position.Y:F2}, {startPoint.Position.Z:F2})"); LogManager.Info($"网格生成参数 - 终点: ({endPoint.Position.X:F2}, {endPoint.Position.Y:F2}, {endPoint.Position.Z:F2})"); @@ -1268,6 +1304,15 @@ namespace NavisworksTransport } // 更新其他属性(不要覆盖TotalLength,因为LoadPathEdges已经根据Edges重新计算了) route.LiftHeight = loadedRoute.LiftHeight; + route.RailMountMode = loadedRoute.RailMountMode; + route.RailPathDefinitionMode = loadedRoute.RailPathDefinitionMode; + route.RailNormalOffset = loadedRoute.RailNormalOffset; + route.RailPreferredNormal = loadedRoute.RailPreferredNormal != null + ? new Point3D( + loadedRoute.RailPreferredNormal.X, + loadedRoute.RailPreferredNormal.Y, + loadedRoute.RailPreferredNormal.Z) + : null; LogManager.Debug($"已从数据库加载路径数据: {route.Name}, 共 {loadedRoute.Points.Count} 个点"); } } @@ -1312,6 +1357,12 @@ namespace NavisworksTransport if (route != null) { LogManager.Info($"路径 '{route.Name}' 没有关联的GridMap"); + + if (IsAnyGridVisualizationEnabled) + { + LogManager.Info("检测到网格可视化已启用,尝试按路径保存的自动规划参数重建GridMap"); + RefreshGridVisualization(); + } } } @@ -1600,6 +1651,12 @@ namespace NavisworksTransport { PathType = pathType }; + + if (pathType == PathType.Rail) + { + newRoute.RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine; + } + _editingRoute = newRoute; // 立即添加到路径集合,确保UI能正确找到Core路径 @@ -1699,33 +1756,25 @@ namespace NavisworksTransport var lastPoint = CurrentRoute.Points.Last(); LogManager.Info($"[吊装路径] 最后一个路径点: {lastPoint.Name}, 位置: ({lastPoint.Position.X:F2}, {lastPoint.Position.Y:F2}, {lastPoint.Position.Z:F2})"); - // 计算下一个索引 - int nextIndex = CurrentRoute.Points.Count; + var descendPoint = ReuseLastHoistingPointAsDescendPoint(CurrentRoute.Points); + LogManager.Info($"[吊装路径] 已将最后一个空中点复用为下降点(终点正上方): ({descendPoint.Position.X:F2}, {descendPoint.Position.Y:F2}, {descendPoint.Position.Z:F2})"); - // 创建下降点:在落地点正上方,Z坐标为最后一个路径点的Z(保持当前高度) - var descendPoint = new PathPoint( - new Point3D(lastPoint.Position.X, lastPoint.Position.Y, lastPoint.Position.Z), - "下降点", - PathPointType.WayPoint); - descendPoint.Direction = HoistingPointDirection.Vertical; - descendPoint.Index = nextIndex++; - LogManager.Info($"[吊装路径] 已生成下降点(在终点正上方): ({descendPoint.Position.X:F2}, {descendPoint.Position.Y:F2}, {descendPoint.Position.Z:F2})"); - - // 创建落地点(终点):使用最后一个路径点的XY,使用起点Z作为地面高度 + Point3D finalGroundPoint = _hoistingGroundIntermediatePoints.Count > 0 + ? _hoistingGroundIntermediatePoints[_hoistingGroundIntermediatePoints.Count - 1] + : SetHoistingElevation(lastPoint.Position, GetHoistingElevation(startPoint.Position)); + + // 创建落地点(终点):使用用户最后一次点击的地面点,避免把终点高程错误吸回起点 var endPoint = new PathPoint( - new Point3D(lastPoint.Position.X, lastPoint.Position.Y, startPoint.Position.Z), + finalGroundPoint, "落地点", PathPointType.EndPoint); endPoint.Direction = HoistingPointDirection.Vertical; - endPoint.Index = nextIndex++; + endPoint.Index = CurrentRoute.Points.Count; LogManager.Info($"[吊装路径] 已创建落地点(终点): ({endPoint.Position.X:F2}, {endPoint.Position.Y:F2}, {endPoint.Position.Z:F2})"); - - // 添加下降点 - CurrentRoute.Points.Add(descendPoint); - + // 添加落地点(终点) CurrentRoute.Points.Add(endPoint); - LogManager.Info($"[吊装路径] 已添加下降点和落地点,当前共 {CurrentRoute.Points.Count} 个路径点"); + LogManager.Info($"[吊装路径] 已复用最后一个空中点作为下降点,并添加落地点,当前共 {CurrentRoute.Points.Count} 个路径点"); // 注意:不需要再次调用 OrthogonalizePath,因为添加的只是垂直线段 // 垂直线段不会形成斜线,所以不需要优化 @@ -1790,6 +1839,9 @@ namespace NavisworksTransport // 触发路径点列表更新事件 RaisePathPointsListUpdated(CurrentRoute, "编辑完成"); + // 结束编辑后主动再同步一次当前路径,确保UI中的占位路径能回填完整点列表 + SetCurrentRouteInternal(CurrentRoute, triggerEvent: true); + // 触发路径生成事件 RaiseRouteGenerated(CurrentRoute, RouteGenerationMethod.Manual); @@ -1904,12 +1956,12 @@ namespace NavisworksTransport { var startPoint = points[0]; var liftPoint = points[1]; - double newAerialZ = startPoint.Position.Z + route.LiftHeight; - liftPoint.Position = new Point3D(startPoint.Position.X, startPoint.Position.Y, newAerialZ); + double newAerialElevation = GetHoistingElevation(startPoint.Position) + route.LiftHeight; + liftPoint.Position = SetHoistingElevation(startPoint.Position, newAerialElevation); // 同步所有其他空中点Z坐标(索引2到n-2) for (int i = 2; i <= points.Count - 2; i++) { - points[i].Position = new Point3D(points[i].Position.X, points[i].Position.Y, newAerialZ); + points[i].Position = SetHoistingElevation(points[i].Position, newAerialElevation); } LogManager.Debug($"[吊装路径] 修改起点,更新提升点位置: ({liftPoint.Position.X:F2}, {liftPoint.Position.Y:F2}, {liftPoint.Position.Z:F2}),同步所有空中点Z坐标"); } @@ -1918,7 +1970,7 @@ namespace NavisworksTransport { var endPoint = points[modifiedPointIndex]; var descendPoint = points[modifiedPointIndex - 1]; - descendPoint.Position = new Point3D(endPoint.Position.X, endPoint.Position.Y, descendPoint.Position.Z); + descendPoint.Position = SetHoistingElevation(endPoint.Position, GetHoistingElevation(descendPoint.Position)); LogManager.Debug($"[吊装路径] 修改终点,更新下降点位置: ({descendPoint.Position.X:F2}, {descendPoint.Position.Y:F2}, {descendPoint.Position.Z:F2})"); } } @@ -1945,9 +1997,12 @@ namespace NavisworksTransport LogManager.Debug($"[斜线处理] 当前点 [{startIndex}]: {currentPoint.Name}, 位置: ({currentPoint.Position.X:F2}, {currentPoint.Position.Y:F2}, {currentPoint.Position.Z:F2})"); LogManager.Debug($"[斜线处理] 下一点 [{startIndex + 1}]: {nextPoint.Name}, 位置: ({nextPoint.Position.X:F2}, {nextPoint.Position.Y:F2}, {nextPoint.Position.Z:F2})"); - double deltaX = Math.Abs(nextPoint.Position.X - currentPoint.Position.X); - double deltaY = Math.Abs(nextPoint.Position.Y - currentPoint.Position.Y); - double deltaZ = Math.Abs(nextPoint.Position.Z - currentPoint.Position.Z); + Point3D canonicalCurrent = ToHoistingCanonicalPoint(currentPoint.Position); + Point3D canonicalNext = ToHoistingCanonicalPoint(nextPoint.Position); + + double deltaX = Math.Abs(canonicalNext.X - canonicalCurrent.X); + double deltaY = Math.Abs(canonicalNext.Y - canonicalCurrent.Y); + double deltaZ = Math.Abs(canonicalNext.Z - canonicalCurrent.Z); LogManager.Debug($"[斜线处理] deltaX={deltaX:F2}, deltaY={deltaY:F2}, deltaZ={deltaZ:F2}"); @@ -2004,7 +2059,7 @@ namespace NavisworksTransport // 不修改用户点击的坐标,只插入转折点 // 转折点:X用新点的X,Y用旧点的Y(先X方向移动,再Y方向移动) return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(nextPoint.Position.X, currentPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Longitudinal), HoistingPointDirection.Longitudinal); } @@ -2018,7 +2073,7 @@ namespace NavisworksTransport // 插入中间点:先水平移动(到新X,旧Z),目标点自然就是(新X,新Z) return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(nextPoint.Position.X, currentPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Longitudinal), HoistingPointDirection.Longitudinal); } @@ -2032,7 +2087,7 @@ namespace NavisworksTransport // 插入中间点:先水平移动(到新Y,旧Z),目标点自然就是(新Y,新Z) return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(currentPoint.Position.X, nextPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Lateral), HoistingPointDirection.Lateral); } @@ -2054,7 +2109,7 @@ namespace NavisworksTransport { // 插入中间点:先X方向移动(保持Y),到目标X后再处理Y和Z return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(nextPoint.Position.X, currentPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Longitudinal), HoistingPointDirection.Longitudinal); } else @@ -2064,13 +2119,13 @@ namespace NavisworksTransport if (deltaX > deltaY) { return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(nextPoint.Position.X, currentPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Longitudinal), HoistingPointDirection.Longitudinal); } else { return InsertIntermediatePoint(points, startIndex, currentPoint, nextPoint, - new Point3D(currentPoint.Position.X, nextPoint.Position.Y, currentPoint.Position.Z), + CreateHorizontalTurnPoint(currentPoint.Position, nextPoint.Position, HoistingPointDirection.Lateral), HoistingPointDirection.Lateral); } } @@ -2176,10 +2231,12 @@ namespace NavisworksTransport continue; } - // 判断三个点是否在同一直线上(需要在3D空间中共线) - // 首先检查Z坐标是否相同(在同一平面上) - double deltaZ1 = Math.Abs(currentPoint.Position.Z - prevPoint.Position.Z); - double deltaZ2 = Math.Abs(nextPoint.Position.Z - currentPoint.Position.Z); + // 判断三个点是否在同一直线上(统一在 Canonical 水平面上判断) + Point3D canonicalPrevPoint = ToHoistingCanonicalPoint(prevPoint.Position); + Point3D canonicalCurrentPoint = ToHoistingCanonicalPoint(currentPoint.Position); + Point3D canonicalNextPoint = ToHoistingCanonicalPoint(nextPoint.Position); + double deltaZ1 = Math.Abs(canonicalCurrentPoint.Z - canonicalPrevPoint.Z); + double deltaZ2 = Math.Abs(canonicalNextPoint.Z - canonicalCurrentPoint.Z); bool sameZ = deltaZ1 < tolerance && deltaZ2 < tolerance; // 如果Z不同,这三个点一定不共线(一个是垂直过渡点) @@ -2190,10 +2247,10 @@ namespace NavisworksTransport } // 在同一平面上,检查XY方向是否共线 - bool sameX = Math.Abs(prevPoint.Position.X - currentPoint.Position.X) < tolerance && - Math.Abs(currentPoint.Position.X - nextPoint.Position.X) < tolerance; - bool sameY = Math.Abs(prevPoint.Position.Y - currentPoint.Position.Y) < tolerance && - Math.Abs(currentPoint.Position.Y - nextPoint.Position.Y) < tolerance; + bool sameX = Math.Abs(canonicalPrevPoint.X - canonicalCurrentPoint.X) < tolerance && + Math.Abs(canonicalCurrentPoint.X - canonicalNextPoint.X) < tolerance; + bool sameY = Math.Abs(canonicalPrevPoint.Y - canonicalCurrentPoint.Y) < tolerance && + Math.Abs(canonicalCurrentPoint.Y - canonicalNextPoint.Y) < tolerance; bool isCollinear = sameX || sameY; @@ -2261,7 +2318,7 @@ namespace NavisworksTransport var layers = new Dictionary>(); for (int i = 1; i < points.Count - 1; i++) // 跳过起点(0)和终点(Count-1) { - double z = points[i].Position.Z; + double z = GetHoistingElevation(points[i].Position); // 查找是否已有相同Z坐标的层(考虑浮点数精度) bool found = false; @@ -2346,7 +2403,7 @@ namespace NavisworksTransport } bool hasChanges = false; - double layerZ = points[layerIndices[0]].Position.Z; + double layerZ = GetHoistingElevation(points[layerIndices[0]].Position); // 在该层内进行环路检测 // n和m是layerIndices列表中的索引位置 @@ -2368,10 +2425,10 @@ namespace NavisworksTransport var p4 = points[idx4].Position; // 确保所有点都在同一层(水平线段,Z坐标严格相同,只考虑浮点数精度) - if (Math.Abs(p1.Z - layerZ) > epsilon || - Math.Abs(p2.Z - layerZ) > epsilon || - Math.Abs(p3.Z - layerZ) > epsilon || - Math.Abs(p4.Z - layerZ) > epsilon) + if (Math.Abs(GetHoistingElevation(p1) - layerZ) > epsilon || + Math.Abs(GetHoistingElevation(p2) - layerZ) > epsilon || + Math.Abs(GetHoistingElevation(p3) - layerZ) > epsilon || + Math.Abs(GetHoistingElevation(p4) - layerZ) > epsilon) { m++; if (n + m + 1 >= layerIndices.Count) @@ -2470,14 +2527,19 @@ namespace NavisworksTransport private bool GetOrthogonalSegmentIntersection(Point3D a1, Point3D a2, Point3D b1, Point3D b2, out Point3D intersection) { intersection = new Point3D(0, 0, 0); - - // 判断线段A是水平还是垂直 - bool aIsHorizontal = Math.Abs(a1.Y - a2.Y) < 0.001; - bool aIsVertical = Math.Abs(a1.X - a2.X) < 0.001; - + + Point3D canonicalA1 = ToHoistingCanonicalPoint(a1); + Point3D canonicalA2 = ToHoistingCanonicalPoint(a2); + Point3D canonicalB1 = ToHoistingCanonicalPoint(b1); + Point3D canonicalB2 = ToHoistingCanonicalPoint(b2); + + // 判断线段A是水平还是垂直(在 Canonical 水平面 XY 中) + bool aIsHorizontal = Math.Abs(canonicalA1.Y - canonicalA2.Y) < 0.001; + bool aIsVertical = Math.Abs(canonicalA1.X - canonicalA2.X) < 0.001; + // 判断线段B是水平还是垂直 - bool bIsHorizontal = Math.Abs(b1.Y - b2.Y) < 0.001; - bool bIsVertical = Math.Abs(b1.X - b2.X) < 0.001; + bool bIsHorizontal = Math.Abs(canonicalB1.Y - canonicalB2.Y) < 0.001; + bool bIsVertical = Math.Abs(canonicalB1.X - canonicalB2.X) < 0.001; // 必须一条水平一条垂直才可能形成矩形环路 if ((aIsHorizontal && bIsHorizontal) || (aIsVertical && bIsVertical)) @@ -2489,9 +2551,9 @@ namespace NavisworksTransport if (aIsVertical && bIsHorizontal) { // 交换 - var temp1 = a1; var temp2 = a2; - a1 = b1; a2 = b2; - b1 = temp1; b2 = temp2; + var temp1 = canonicalA1; var temp2 = canonicalA2; + canonicalA1 = canonicalB1; canonicalA2 = canonicalB2; + canonicalB1 = temp1; canonicalB2 = temp2; aIsHorizontal = true; aIsVertical = false; bIsHorizontal = false; bIsVertical = true; } @@ -2504,12 +2566,12 @@ namespace NavisworksTransport // 水平线段A: y = a1.Y, x范围 [min(a1.X,a2.X), max(a1.X,a2.X)] // 垂直线段B: x = b1.X, y范围 [min(b1.Y,b2.Y), max(b1.Y,b2.Y)] - double aY = a1.Y; - double bX = b1.X; - double aMinX = Math.Min(a1.X, a2.X); - double aMaxX = Math.Max(a1.X, a2.X); - double bMinY = Math.Min(b1.Y, b2.Y); - double bMaxY = Math.Max(b1.Y, b2.Y); + double aY = canonicalA1.Y; + double bX = canonicalB1.X; + double aMinX = Math.Min(canonicalA1.X, canonicalA2.X); + double aMaxX = Math.Max(canonicalA1.X, canonicalA2.X); + double bMinY = Math.Min(canonicalB1.Y, canonicalB2.Y); + double bMaxY = Math.Max(canonicalB1.Y, canonicalB2.Y); // 检查垂直线段的X是否在水平线段的X范围内(不包括端点) // 且水平线段的Y是否在垂直线段的Y范围内(不包括端点) @@ -2520,7 +2582,10 @@ namespace NavisworksTransport if (xInRange && yInRange) { - intersection = new Point3D(bX, aY, a1.Z); + intersection = GetHoistingHostAdapter().FromCanonicalPoint(new Point3D( + bX, + aY, + canonicalA1.Z)); return true; } @@ -2636,16 +2701,12 @@ namespace NavisworksTransport if (isLiftPoint || isDescendPoint) { // 修改提升点或下降点的Z坐标,同步更新所有空中路径点的Z坐标 - double newHoistingHeight = newPosition.Z; - double originalZ = pointToUpdate.Position.Z; + double newHoistingHeight = GetHoistingElevation(newPosition); + double originalZ = GetHoistingElevation(pointToUpdate.Position); double heightDelta = newHoistingHeight - originalZ; - // 更新当前点的XY和Z坐标 - pointToUpdate.Position = new Point3D( - newPosition.X, - newPosition.Y, - newHoistingHeight - ); + // 更新当前点的宿主水平位置与高程 + pointToUpdate.Position = SetHoistingElevation(newPosition, newHoistingHeight); // 同步更新所有空中路径点的Z坐标 foreach (var point in route.Points) @@ -2654,11 +2715,7 @@ namespace NavisworksTransport // 跳过当前正在修改的点,避免重复更新 if (point.Name != "起吊点" && point.Name != "落地点" && point != pointToUpdate) { - point.Position = new Point3D( - point.Position.X, - point.Position.Y, - point.Position.Z + heightDelta - ); + point.Position = OffsetPointAlongHoistingUp(point.Position, heightDelta); } } @@ -2673,12 +2730,8 @@ namespace NavisworksTransport // 空中路径点(非提升点、下降点):只更新XY坐标,保持原有吊装高度 else if (!isStartPoint && !isEndPoint) { - double originalZ = pointToUpdate.Position.Z; - pointToUpdate.Position = new Point3D( - newPosition.X, - newPosition.Y, - originalZ // 保留原有Z坐标(吊装高度) - ); + double originalZ = GetHoistingElevation(pointToUpdate.Position); + pointToUpdate.Position = SetHoistingElevation(newPosition, originalZ); LogManager.Info($"[更新路径点] 吊装路径空中点 {pointToUpdate.Name},保留原有高度: {originalZ:F3}"); } // 起吊点/落地点:更新完整位置 @@ -2733,6 +2786,41 @@ namespace NavisworksTransport /// /// 吊装路径 /// 提升高度(从起吊点到提升点的高度差) + private HostCoordinateAdapter GetHoistingHostAdapter() + { + return CoordinateSystemManager.Instance.CreateHostAdapter(); + } + + private Point3D ToHoistingCanonicalPoint(Point3D hostPoint) + { + return GetHoistingHostAdapter().ToCanonicalPoint(hostPoint); + } + + private double GetHoistingElevation(Point3D point) + { + return HoistingCoordinateHelper.GetElevation(point, GetHoistingHostAdapter()); + } + + private Point3D SetHoistingElevation(Point3D point, double elevation) + { + return HoistingCoordinateHelper.SetElevation(point, elevation, GetHoistingHostAdapter()); + } + + private Point3D OffsetPointAlongHoistingUp(Point3D point, double offset) + { + return HoistingCoordinateHelper.OffsetAlongUp(point, offset, GetHoistingHostAdapter()); + } + + private Point3D CreateHorizontalTurnPoint(Point3D currentPoint, Point3D nextPoint, HoistingPointDirection direction) + { + bool keepCurrentFirstHorizontalAxis = direction == HoistingPointDirection.Lateral; + return HoistingCoordinateHelper.CreateHorizontalTurnPoint( + currentPoint, + nextPoint, + keepCurrentFirstHorizontalAxis, + GetHoistingHostAdapter()); + } + private double CalculateHoistingHeight(PathRoute route) { if (route == null || route.PathType != PathType.Hoisting || route.Points.Count < 2) @@ -2749,8 +2837,8 @@ namespace NavisworksTransport return 0.0; } - // 计算高度差(提升点Z - 起吊点Z) - return liftPoint.Position.Z - startPoint.Position.Z; + // 计算高度差(沿宿主 up) + return GetHoistingElevation(liftPoint.Position) - GetHoistingElevation(startPoint.Position); } /// @@ -3203,13 +3291,14 @@ namespace NavisworksTransport double liftHeightModelUnits = CurrentRoute.LiftHeight > 0 ? CurrentRoute.LiftHeight : UnitsConverter.ConvertFromMeters(3.0); + double targetElevation = GetHoistingElevation(CurrentRoute.Points[0].Position) + liftHeightModelUnits; // 使用 AerialPathGenerator 生成空中路径点 var generator = new AerialPathGenerator(); confirmPoint = generator.GenerateSmartHoistingPoint( previousPoint, _previewPoint.Position, // 用户点击的地面位置 - liftHeightModelUnits, // 直接使用模型单位 + targetElevation, CurrentRoute.Points.Count > 0 ? CurrentRoute.Points.Count : 1); LogManager.Info($"[预览点-吊装路径] 已生成空中路径点: {confirmPoint.Name}, 位置: ({confirmPoint.Position.X:F2}, {confirmPoint.Position.Y:F2}, {confirmPoint.Position.Z:F2})"); @@ -3902,7 +3991,7 @@ namespace NavisworksTransport // 使用已转换的模型单位高度 var liftPoint = new PathPoint( - new Point3D(_hoistingStartPoint.X, _hoistingStartPoint.Y, _hoistingStartPoint.Z + liftHeightModelUnits), + OffsetPointAlongHoistingUp(_hoistingStartPoint, liftHeightModelUnits), "提升点", PathPointType.WayPoint); liftPoint.Direction = HoistingPointDirection.Vertical; @@ -3989,30 +4078,26 @@ namespace NavisworksTransport { previousPoint = CurrentRoute.Points.Last().Position; var lastPoint = CurrentRoute.Points.Last(); - LogManager.Info($"[吊装模式] 上一路径点: {lastPoint.Name}, Z={lastPoint.Position.Z:F2}"); + LogManager.Info($"[吊装模式] 上一路径点: {lastPoint.Name}, Elevation={GetHoistingElevation(lastPoint.Position):F2}"); } else { - previousPoint = new Point3D( - _hoistingStartPoint.X, - _hoistingStartPoint.Y, - _hoistingStartPoint.Z + liftHeightModelUnits); - LogManager.Info($"[吊装模式] 无上一路径点,使用起点+高度: Z={previousPoint.Z:F2}"); + previousPoint = OffsetPointAlongHoistingUp(_hoistingStartPoint, liftHeightModelUnits); + LogManager.Info($"[吊装模式] 无上一路径点,使用起点+高度: Elevation={GetHoistingElevation(previousPoint):F2}"); } // 记录原始地面点击位置 _hoistingGroundIntermediatePoints.Add(groundPoint); LogManager.Debug($"[吊装模式] 已记录地面点击点: ({groundPoint.X:F2}, {groundPoint.Y:F2}, {groundPoint.Z:F2})"); - // 检查高度是否变化(比较绝对高度) - // liftHeightModelUnits 是相对地面的高度,需要加上起点Z得到绝对高度 - double newAbsoluteHeight = _hoistingStartPoint.Z + liftHeightModelUnits; - double heightDiff = Math.Abs(newAbsoluteHeight - previousPoint.Z); + // 检查高度是否变化(比较沿宿主 up 的绝对高程) + double newAbsoluteHeight = GetHoistingElevation(_hoistingStartPoint) + liftHeightModelUnits; + double heightDiff = Math.Abs(newAbsoluteHeight - GetHoistingElevation(previousPoint)); bool heightChanged = heightDiff > 0.001; LogManager.Info($"[吊装模式] 起点=({_hoistingStartPoint.X:F2}, {_hoistingStartPoint.Y:F2}, {_hoistingStartPoint.Z:F2})"); LogManager.Info($"[吊装模式] 相对高度(米)={liftHeightMetersInput:F2}, 相对高度(模型)={liftHeightModelUnits:F2}"); - LogManager.Info($"[吊装模式] 新绝对高度={newAbsoluteHeight:F2}, 上一位置Z={previousPoint.Z:F2}"); + LogManager.Info($"[吊装模式] 新绝对高度={newAbsoluteHeight:F2}, 上一位置Elevation={GetHoistingElevation(previousPoint):F2}"); // 步骤1:水平移动点(在旧高度上移动到新XY) // 使用 AerialPathGenerator 生成水平路径点 @@ -4025,7 +4110,7 @@ namespace NavisworksTransport var aerialPoint = generator.GenerateSmartHoistingPoint( previousPoint, groundPoint, - previousPoint.Z, // 保持旧高度 + GetHoistingElevation(previousPoint), CurrentRoute.Points.Count > 0 ? CurrentRoute.Points.Count : 1); if (aerialPoint != null) @@ -4047,12 +4132,9 @@ namespace NavisworksTransport if (heightChanged) { // 高度变化:垂直上升到新高度 - LogManager.Info($"[吊装模式] 高度变化,插入垂直过渡点: Z={previousPoint.Z:F2} -> Z={newAbsoluteHeight:F2}"); + LogManager.Info($"[吊装模式] 高度变化,插入垂直过渡点: Elevation={GetHoistingElevation(previousPoint):F2} -> Elevation={newAbsoluteHeight:F2}"); - var verticalTransitionPoint = new Point3D( - previousPoint.X, - previousPoint.Y, - newAbsoluteHeight); + var verticalTransitionPoint = SetHoistingElevation(previousPoint, newAbsoluteHeight); var verticalPoint = new PathPoint( verticalTransitionPoint, $"高度过渡{CurrentRoute.Points.Count}", @@ -4194,27 +4276,30 @@ namespace NavisworksTransport return clickedPoint; } - // 找到最近的基准路径点 + // 找到最近的基准路径投影点 Point3D nearestPoint = null; double minDistance = double.MaxValue; double maxSnapDistance = 2.0; // 最大吸附距离(模型单位) foreach (var pathPoints in baselinePaths.Values) { - foreach (var point in pathPoints) + var snappedPoint = FindNearestPointOnPolyline(pathPoints, clickedPoint); + if (snappedPoint == null) { - double distance = CalculateDistance3D(clickedPoint, point); - if (distance < minDistance) - { - minDistance = distance; - nearestPoint = point; - } + continue; + } + + double distance = CalculateDistance3D(clickedPoint, snappedPoint); + if (distance < minDistance) + { + minDistance = distance; + nearestPoint = snappedPoint; } } if (nearestPoint != null && minDistance <= maxSnapDistance) { - LogManager.Info($"[空轨吸附] 点击点 ({clickedPoint.X:F2}, {clickedPoint.Y:F2}, {clickedPoint.Z:F2}) 吸附到基准路径点 ({nearestPoint.X:F2}, {nearestPoint.Y:F2}, {nearestPoint.Z:F2}),距离 {minDistance:F2}"); + LogManager.Info($"[空轨吸附] 点击点 ({clickedPoint.X:F2}, {clickedPoint.Y:F2}, {clickedPoint.Z:F2}) 吸附到基准路径投影点 ({nearestPoint.X:F2}, {nearestPoint.Y:F2}, {nearestPoint.Z:F2}),距离 {minDistance:F2}"); return nearestPoint; } else @@ -4241,6 +4326,66 @@ namespace NavisworksTransport return Math.Sqrt(dx * dx + dy * dy + dz * dz); } + /// + /// 查找折线上距离目标点最近的投影点 + /// + private Point3D FindNearestPointOnPolyline(List pathPoints, Point3D targetPoint) + { + if (pathPoints == null || pathPoints.Count == 0) + { + return null; + } + + if (pathPoints.Count == 1) + { + return pathPoints[0]; + } + + Point3D nearestPoint = null; + double minDistance = double.MaxValue; + + for (int i = 0; i < pathPoints.Count - 1; i++) + { + var projectedPoint = ProjectPointToSegment(targetPoint, pathPoints[i], pathPoints[i + 1]); + double distance = CalculateDistance3D(targetPoint, projectedPoint); + if (distance < minDistance) + { + minDistance = distance; + nearestPoint = projectedPoint; + } + } + + return nearestPoint ?? pathPoints[0]; + } + + /// + /// 将点投影到线段上 + /// + private Point3D ProjectPointToSegment(Point3D point, Point3D segmentStart, Point3D segmentEnd) + { + double segmentX = segmentEnd.X - segmentStart.X; + double segmentY = segmentEnd.Y - segmentStart.Y; + double segmentZ = segmentEnd.Z - segmentStart.Z; + + double segmentLengthSquared = segmentX * segmentX + segmentY * segmentY + segmentZ * segmentZ; + if (segmentLengthSquared < 1e-9) + { + return segmentStart; + } + + double pointX = point.X - segmentStart.X; + double pointY = point.Y - segmentStart.Y; + double pointZ = point.Z - segmentStart.Z; + + double projection = (pointX * segmentX + pointY * segmentY + pointZ * segmentZ) / segmentLengthSquared; + projection = Math.Max(0.0, Math.Min(1.0, projection)); + + return new Point3D( + segmentStart.X + projection * segmentX, + segmentStart.Y + projection * segmentY, + segmentStart.Z + projection * segmentZ); + } + /// /// 搜索并设置可通行的通道 /// @@ -4360,6 +4505,11 @@ namespace NavisworksTransport { DeactivateToolPlugin(); } + + _pathDatabase?.Dispose(); + _pathDatabase = null; + _analysisService = null; + _databaseDocumentPath = null; } catch (Exception ex) { @@ -4446,6 +4596,33 @@ namespace NavisworksTransport } } + /// + /// 根据当前业务上下文恢复 ToolPlugin 焦点。 + /// 普通手动建/编辑路径应恢复 PathPlanningManager 主链; + /// 自动路径、装配拾取、多层吊装等外部自管拾取模式则保持外部事件处理。 + /// + public bool RestoreToolPluginFocusForCurrentContext() + { + try + { + bool shouldSubscribeToManagerEvents = !IsInAutoPathMode; + string contextLabel = shouldSubscribeToManagerEvents + ? "普通手动取点" + : "外部自管拾取"; + + LogManager.Info( + $"[工具插件恢复] 按当前上下文恢复焦点: 模式={contextLabel}, " + + $"PathEditState={PathEditState}, 订阅PathPlanningManager事件={shouldSubscribeToManagerEvents}"); + + return ForceReinitializeToolPlugin(shouldSubscribeToManagerEvents); + } + catch (Exception ex) + { + LogManager.Error($"[工具插件恢复] 按当前上下文恢复焦点失败: {ex.Message}", ex); + return false; + } + } + #endregion #region 路径计算和保存 @@ -4551,14 +4728,10 @@ namespace NavisworksTransport { try { - var width = bounds.Max.X - bounds.Min.X; - var height = bounds.Max.Y - bounds.Min.Y; - var maxDimension = Math.Max(width, height); - - // 基于模型大小智能选择网格大小 - if (maxDimension > 1000) return 5.0; // 大型模型:5米 - if (maxDimension > 500) return 2.0; // 中型模型:2米 - return 0.5; // 小型模型:0.5米 + return CalculateOptimalGridSizeFromBounds( + bounds.Min.X, bounds.Min.Y, bounds.Min.Z, + bounds.Max.X, bounds.Max.Y, bounds.Max.Z, + CoordinateSystemManager.Instance.ResolvedType); } catch { @@ -4566,6 +4739,32 @@ namespace NavisworksTransport } } + /// + /// 根据宿主包围盒和当前宿主坐标系语义,计算自动路径规划的最优网格大小。 + /// 关键约束: + /// - 仅修正“水平平面”的解释,不改变历史阈值与业务策略。 + /// - ZUp: 使用 Host XY 平面跨度。 + /// - YUp: 使用 Host XZ 平面跨度。 + /// + private static double CalculateOptimalGridSizeFromBounds( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var hostMin = new System.Numerics.Vector3((float)minX, (float)minY, (float)minZ); + var hostMax = new System.Numerics.Vector3((float)maxX, (float)maxY, (float)maxZ); + var (min1, max1, min2, max2) = HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + + var width = max1 - min1; + var height = max2 - min2; + var maxDimension = Math.Max(width, height); + + if (maxDimension > 1000) return 5.0; + if (maxDimension > 500) return 2.0; + return 0.5; + } + /// /// 获取通道模型项用于高度计算 /// @@ -4946,6 +5145,124 @@ namespace NavisworksTransport get { return _showWalkableGrid || _showObstacleGrid || _showUnknownGrid || _showDoorGrid; } } + /// + /// 按自动路径保存的规划参数重建GridMap,用于数据库重载后恢复网格可视化和诊断。 + /// + public bool TryRestoreGridMapForRoute(PathRoute route, out GridMap gridMap, out string message) + { + gridMap = null; + message = null; + + try + { + if (route == null) + { + message = "路径为空,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + if (route.AssociatedGridMap != null) + { + gridMap = route.AssociatedGridMap; + message = $"路径 '{route.Name}' 已有关联GridMap"; + return true; + } + + List orderedPoints = route.Points?.OrderBy(point => point.Index).ToList() ?? new List(); + if (orderedPoints.Count < 2) + { + message = $"路径 '{route.Name}' 点数不足,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + if (route.GridSize <= 0) + { + message = $"路径 '{route.Name}' 缺少有效网格大小,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + double objectRadius = Math.Max(route.MaxObjectLength, route.MaxObjectWidth) / 2.0; + if (objectRadius <= 0) + { + message = $"路径 '{route.Name}' 缺少有效物体长宽,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + if (route.SafetyMargin < 0) + { + message = $"路径 '{route.Name}' 安全间隙为负数,无法重建GridMap: {route.SafetyMargin}"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + if (route.MaxObjectHeight <= 0) + { + message = $"路径 '{route.Name}' 缺少有效物体高度,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + BoundingBox3D bounds = GetModelBounds(); + if (bounds == null) + { + message = "无法获取模型边界,无法重建GridMap"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + PathPoint startPoint = orderedPoints.First(); + PathPoint endPoint = orderedPoints.Last(); + LogManager.Info( + $"[网格可视化] 开始按路径参数重建GridMap: 路径={route.Name}, 网格={route.GridSize}m, " + + $"物体半径={objectRadius}m, 安全间隙={route.SafetyMargin}m, 高度={route.MaxObjectHeight}m"); + + var gridMapGenerator = new GridMapGenerator(); + gridMap = gridMapGenerator.GenerateFromBIM( + bounds, + route.GridSize, + objectRadius, + route.SafetyMargin, + startPoint.Position, + endPoint.Position, + route.MaxObjectHeight); + + if (gridMap == null) + { + message = $"路径 '{route.Name}' 的GridMap重建失败"; + LogManager.Warning($"[网格可视化] {message}"); + return false; + } + + route.AssociatedGridMap = gridMap; + if (_currentRoute == route || (_currentRoute != null && _currentRoute.Id == route.Id)) + { + _currentGridMap = gridMap; + _currentObjectHeight = route.MaxObjectHeight; + } + + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin != null) + { + renderPlugin.SetGridSize(UnitsConverter.ConvertToMeters(gridMap.CellSize)); + } + + message = $"路径 '{route.Name}' 的GridMap已重建: {gridMap.GetStatistics()}"; + LogManager.Info($"[网格可视化] {message}"); + return true; + } + catch (Exception ex) + { + message = $"重建GridMap失败: {ex.Message}"; + LogManager.Error($"[网格可视化] {message}", ex); + gridMap = null; + return false; + } + } + /// /// 刷新网格可视化(根据当前设置重新显示) /// @@ -4956,19 +5273,33 @@ namespace NavisworksTransport // 清除当前网格可视化 ClearGridVisualization(); + if (!IsAnyGridVisualizationEnabled) + { + LogManager.Info("[网格可视化] 网格可视化设置已关闭,跳过渲染"); + return; + } + + if (_currentGridMap == null && _currentRoute != null) + { + if (!TryRestoreGridMapForRoute(_currentRoute, out GridMap restoredGridMap, out string restoreMessage)) + { + LogManager.Info($"[网格可视化] {restoreMessage}"); + } + else + { + _currentGridMap = restoredGridMap; + } + } + // 如果存在缓存的网格地图且有网格可视化设置启用,重新进行可视化 - if (_currentGridMap != null && IsAnyGridVisualizationEnabled) + if (_currentGridMap != null) { LogManager.Info("[网格可视化] 使用缓存的网格地图重新渲染"); VisualizeGridCells(_currentGridMap, _currentObjectHeight); } - else if (_currentGridMap == null) - { - LogManager.Info("[网格可视化] 无缓存网格地图,需要重新进行路径规划以查看网格"); - } else { - LogManager.Info("[网格可视化] 网格可视化设置已关闭,跳过渲染"); + LogManager.Info("[网格可视化] 无缓存网格地图,且无法通过当前路径参数重建"); } } catch (Exception ex) @@ -5032,11 +5363,10 @@ namespace NavisworksTransport { var cell = gridMap.Cells[x, y]; - // 计算网格单元格的XY中心位置(Z坐标在多层逻辑中单独处理) + // 当前 GridMap 的索引语义是采样节点:GridToWorld2D(x,y) + // 必须直接作为可视化平面位置,不能再按左下角单元取 (x+1,y+1) 平均。 var gridPos = new NavisworksTransport.PathPlanning.GridPoint2D(x, y); - var gridCorner = gridMap.GridToWorld3D(gridPos); - double centerX = gridCorner.X + gridMap.CellSize / 2; - double centerY = gridCorner.Y + gridMap.CellSize / 2; + var gridNode2D = gridMap.GridToWorld2D(gridPos); // 所有网格都基于高度层,统一处理 if (cell.HeightLayers != null && cell.HeightLayers.Count > 0) @@ -5084,8 +5414,8 @@ namespace NavisworksTransport // 创建该层的可视化标记 if (cellType.HasValue) { - var gridCenter = new Point3D(centerX, centerY, layerZ); - var marker = new GridMarker(gridCenter, cellType.Value, x, y, layerZ); + var gridNode = gridMap.SetWorldElevation(gridNode2D, layerZ); + var marker = new GridMarker(gridNode, cellType.Value, x, y, layerZ); // 根据类型添加到对应列表 switch (cellType.Value) @@ -5112,8 +5442,9 @@ namespace NavisworksTransport // Unknown网格没有高度层,用于调试 if (_showUnknownGrid) { - var gridCenter = new Point3D(centerX, centerY, gridCorner.Z); - var marker = new GridMarker(gridCenter, GridVisualizationType.Unknown, x, y, gridCorner.Z); + double unknownElevation = gridMap.GetWorldElevation(gridNode2D); + var gridNode = gridMap.SetWorldElevation(gridNode2D, unknownElevation); + var marker = new GridMarker(gridNode, GridVisualizationType.Unknown, x, y, unknownElevation); gridVis.Unknown.Add(marker); unknownCells++; totalVisualized++; @@ -5299,7 +5630,17 @@ namespace NavisworksTransport { try { - if (_pathDatabase != null && route != null) + if (route == null) + { + return; + } + + if (_pathDatabase == null) + { + DatabaseInitialize(createIfMissing: true); + } + + if (_pathDatabase != null) { _pathDatabase.SavePathRoute(route); LogManager.Info($"路径已保存到数据库: {route.Name}"); @@ -5340,6 +5681,26 @@ namespace NavisworksTransport return _activePathManager; } + internal static PathPoint ReuseLastHoistingPointAsDescendPoint(IList points) + { + if (points == null) + { + throw new ArgumentNullException(nameof(points)); + } + + if (points.Count == 0) + { + throw new ArgumentException("吊装路径至少需要一个空中点才能复用为下降点。", nameof(points)); + } + + PathPoint descendPoint = points[points.Count - 1]; + descendPoint.Name = "下降点"; + descendPoint.Type = PathPointType.WayPoint; + descendPoint.Direction = HoistingPointDirection.Vertical; + descendPoint.Index = points.Count - 1; + return descendPoint; + } + #endregion } } diff --git a/src/Core/PathPlanningModels.cs b/src/Core/PathPlanningModels.cs index ec31b81..3c4ddc1 100644 --- a/src/Core/PathPlanningModels.cs +++ b/src/Core/PathPlanningModels.cs @@ -113,6 +113,38 @@ namespace NavisworksTransport Lateral } + /// + /// Rail 路径安装方式 + /// + public enum RailMountMode + { + /// + /// 轨下安装 + /// + UnderRail = 0, + + /// + /// 轨上安装 + /// + OverRail = 1 + } + + /// + /// Rail 路径的参考线定义方式 + /// + public enum RailPathDefinitionMode + { + /// + /// 旧版兼容模式:路径点即轨下悬挂参考点 + /// + LegacySuspensionPoint = 0, + + /// + /// 新版模式:路径点表示双轨参考中心线 + /// + RailCenterLine = 1 + } + /// /// 通道检测结果 /// @@ -343,6 +375,13 @@ namespace NavisworksTransport /// public double? CustomTurnRadius { get; set; } + /// + /// 仅用于视图显示的点直径(模型单位)。 + /// 为 null 或非正数时使用默认点类型尺寸。 + /// + [XmlIgnore] + public double? VisualizationDiameter { get; set; } + /// /// 方向类型(仅吊装路径使用) /// 用于标识吊装路径点的移动方向:垂直、纵向(X轴)、横向(Y轴) @@ -363,6 +402,7 @@ namespace NavisworksTransport Notes = string.Empty; SpeedLimit = 0; CustomTurnRadius = null; + VisualizationDiameter = null; Direction = HoistingPointDirection.Vertical; } @@ -383,9 +423,25 @@ namespace NavisworksTransport Notes = string.Empty; SpeedLimit = 0; CustomTurnRadius = null; + VisualizationDiameter = null; Direction = HoistingPointDirection.Vertical; } + /// + /// 解析最终用于渲染的点半径(模型单位)。 + /// + /// 默认点半径(模型单位) + /// 最终半径(模型单位) + public double ResolveVisualizationRadius(double defaultRadius) + { + if (!VisualizationDiameter.HasValue || VisualizationDiameter.Value <= 0) + { + return defaultRadius; + } + + return VisualizationDiameter.Value * 0.5; + } + /// /// 返回路径点描述字符串 /// @@ -510,6 +566,11 @@ namespace NavisworksTransport [Serializable] public class PathRoute { + public static bool IsTopReferenceFaceForMountMode(RailMountMode railMountMode) + { + return railMountMode == RailMountMode.UnderRail; + } + /// /// 路径点集合(控制点) /// @@ -645,27 +706,27 @@ namespace NavisworksTransport public NavisworksTransport.PathPlanning.GridMap AssociatedGridMap { get; set; } /// - /// 网格大小(模型单位) + /// 网格大小(米) /// public double GridSize { get; set; } /// - /// 最大物体长度(模型单位) - 路径适用的物体长度限制 + /// 最大物体长度(米) - 路径适用的物体长度限制 /// public double MaxObjectLength { get; set; } /// - /// 最大物体宽度(模型单位) - 路径适用的物体宽度限制 + /// 最大物体宽度(米) - 路径适用的物体宽度限制 /// public double MaxObjectWidth { get; set; } /// - /// 最大物体高度(模型单位) - 路径适用的物体高度限制 + /// 最大物体高度(米) - 路径适用的物体高度限制 /// public double MaxObjectHeight { get; set; } /// - /// 安全间隙(模型单位) + /// 安全间隙(米) /// public double SafetyMargin { get; set; } @@ -690,6 +751,28 @@ namespace NavisworksTransport /// public double LiftHeight { get; set; } + /// + /// Rail 路径安装方式(仅当 PathType == Rail 时有效) + /// + public RailMountMode RailMountMode { get; set; } + + /// + /// Rail 路径参考线定义方式(仅当 PathType == Rail 时有效) + /// + public RailPathDefinitionMode RailPathDefinitionMode { get; set; } + + /// + /// Rail 路径优先法向(宿主坐标)。 + /// 仅在存在显式安装面/业务法向时使用;为空时退回当前默认求解。 + /// + public Point3D RailPreferredNormal { get; set; } + + /// + /// Rail 安装法向偏移(模型单位)。 + /// 沿 Rail 最终法向对安装参考点施加额外偏移,用于快速微调路径。 + /// + public double RailNormalOffset { get; set; } + // 数据库分析相关属性 /// /// 碰撞数量(从数据库加载) @@ -728,6 +811,10 @@ namespace NavisworksTransport Description = string.Empty; TurnRadius = 0.0; // 0.0 表示未设置,实际使用时从配置获取默认值 IsCurved = false; + RailMountMode = RailMountMode.UnderRail; + RailPathDefinitionMode = RailPathDefinitionMode.LegacySuspensionPoint; + RailPreferredNormal = null; + RailNormalOffset = 0.0; } /// @@ -748,6 +835,10 @@ namespace NavisworksTransport IsCurved = false; LastModified = DateTime.Now; Description = string.Empty; + RailMountMode = RailMountMode.UnderRail; + RailPathDefinitionMode = RailPathDefinitionMode.LegacySuspensionPoint; + RailPreferredNormal = null; + RailNormalOffset = 0.0; } /// @@ -967,7 +1058,7 @@ namespace NavisworksTransport Id = Guid.NewGuid().ToString(), EstimatedTime = EstimatedTime, // TotalLength:计算属性,自动从几何数据计算,克隆时会重新计算 - CreatedTime = CreatedTime, + CreatedTime = DateTime.Now, LastModified = DateTime.Now, Description = Description, AssociatedChannelIds = new List(AssociatedChannelIds), @@ -980,9 +1071,19 @@ namespace NavisworksTransport MaxObjectLength = MaxObjectLength, MaxObjectWidth = MaxObjectWidth, MaxObjectHeight = MaxObjectHeight, - SafetyMargin = SafetyMargin + SafetyMargin = SafetyMargin, + TurnRadius = TurnRadius, + IsCurved = IsCurved, + PathType = PathType, + LiftHeight = LiftHeight, + RailMountMode = RailMountMode, + RailPathDefinitionMode = RailPathDefinitionMode, + RailNormalOffset = RailNormalOffset, + RailPreferredNormal = RailPreferredNormal }; + var pointIdMap = new Dictionary(); + // 克隆所有路径点 foreach (var point in Points) { @@ -991,9 +1092,43 @@ namespace NavisworksTransport Id = Guid.NewGuid().ToString(), Index = point.Index, CreatedTime = point.CreatedTime, - Notes = point.Notes + Notes = point.Notes, + SpeedLimit = point.SpeedLimit, + CustomTurnRadius = point.CustomTurnRadius, + VisualizationDiameter = point.VisualizationDiameter, + Direction = point.Direction }; clonedRoute.Points.Add(clonedPoint); + pointIdMap[point.Id] = clonedPoint.Id; + } + + foreach (var edge in Edges) + { + var clonedEdge = new PathEdge + { + Id = Guid.NewGuid().ToString(), + StartPointId = pointIdMap.ContainsKey(edge.StartPointId) ? pointIdMap[edge.StartPointId] : edge.StartPointId, + EndPointId = pointIdMap.ContainsKey(edge.EndPointId) ? pointIdMap[edge.EndPointId] : edge.EndPointId, + SegmentType = edge.SegmentType, + PhysicalLength = edge.PhysicalLength, + Trajectory = edge.Trajectory != null + ? new ArcTrajectory + { + Ts = edge.Trajectory.Ts, + Te = edge.Trajectory.Te, + ArcCenter = edge.Trajectory.ArcCenter, + RequestedRadius = edge.Trajectory.RequestedRadius, + ActualRadius = edge.Trajectory.ActualRadius, + DeflectionAngle = edge.Trajectory.DeflectionAngle, + ArcLength = edge.Trajectory.ArcLength + } + : null, + SampledPoints = edge.SampledPoints != null + ? new List(edge.SampledPoints) + : new List() + }; + + clonedRoute.Edges.Add(clonedEdge); } return clonedRoute; @@ -2627,4 +2762,4 @@ namespace NavisworksTransport } #endregion -} \ No newline at end of file +} diff --git a/src/Core/PathPointRenderPlugin.cs b/src/Core/PathPointRenderPlugin.cs index 3656bcb..e4da8b3 100644 --- a/src/Core/PathPointRenderPlugin.cs +++ b/src/Core/PathPointRenderPlugin.cs @@ -5,6 +5,7 @@ using Autodesk.Navisworks.Api; using Autodesk.Navisworks.Api.Plugins; using NavisworksTransport.Core.Config; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; using static NavisworksTransport.Core.Config.ConfigManager; namespace NavisworksTransport @@ -127,10 +128,36 @@ namespace NavisworksTransport /// RailBaseline, + /// + /// 直线装配基准向量样式(绿色) + /// + AssemblyGuideLine, + + /// + /// 直线装配安装参考面样式(绿色半透明) + /// + AssemblyInstallationPlane, + /// /// 吊装路径样式(紫色) /// HoistingLine + , + + /// + /// 真实物体原始位姿 X 轴样式(红色) + /// + TransformAxisX, + + /// + /// 真实物体原始位姿 Y 轴样式(绿色) + /// + TransformAxisY, + + /// + /// 真实物体原始位姿 Z 轴样式(蓝色) + /// + TransformAxisZ } /// @@ -286,6 +313,11 @@ namespace NavisworksTransport /// public Vector3D HorizontalDirection { get; set; } + /// + /// 通行空间的优先 up 方向(例如 Rail 安装面法向) + /// + public Vector3D UpDirection { get; set; } + public override string ToString() { return $"LineMarker[{FromIndex}->{ToIndex}, 类型={SegmentType}, 起点=({StartPoint.X:F2},{StartPoint.Y:F2},{StartPoint.Z:F2}), 终点=({EndPoint.X:F2},{EndPoint.Y:F2},{EndPoint.Z:F2})]"; @@ -332,6 +364,11 @@ namespace NavisworksTransport /// public List ObjectSpaceMarkers { get; set; } + /// + /// 平面标记集合 + /// + public List PlaneMarkers { get; set; } + /// /// 是否显示控制点可视化(用户意图) /// @@ -352,6 +389,7 @@ namespace NavisworksTransport PathLineMarkers = new List(); TangentMarkers = new List(); ObjectSpaceMarkers = new List(); + PlaneMarkers = new List(); LastUpdated = DateTime.Now; } @@ -627,6 +665,11 @@ namespace NavisworksTransport RenderPointMarker(graphics, pointMarker); } + foreach (var planeMarker in visualization.PlaneMarkers) + { + RenderPlaneMarker(graphics, planeMarker); + } + // 渲染控制点连线(半透明) foreach (var controlLineMarker in visualization.ControlLineMarkers) { @@ -796,6 +839,55 @@ namespace NavisworksTransport } } + public void RenderRectanglePlane( + string pathId, + Point3D origin, + Vector3D xVector, + Vector3D yVector, + RenderStyleName renderStyleName, + bool filled = true) + { + if (string.IsNullOrWhiteSpace(pathId)) + { + throw new ArgumentException("pathId 不能为空。", nameof(pathId)); + } + + try + { + var visualization = new PathVisualization + { + PathId = pathId, + PathRoute = new PathRoute(pathId) + { + Id = pathId, + Description = $"矩形平面可视化:{pathId}" + } + }; + + var renderStyle = GetRenderStyle(renderStyleName); + visualization.PlaneMarkers.Add(new PlaneMarker + { + Origin = origin, + XVector = xVector, + YVector = yVector, + Color = renderStyle.Color, + Alpha = renderStyle.Alpha, + Filled = filled + }); + + lock (_lockObject) + { + _pathVisualizations[pathId] = visualization; + } + + RequestViewRefresh(); + } + catch (Exception ex) + { + LogManager.Error($"[平面渲染] 渲染矩形平面失败: {ex.Message}", ex); + } + } + /// /// 清除网格可视化 /// @@ -933,7 +1025,7 @@ namespace NavisworksTransport return new CircleMarker { Center = marker.Position, - Normal = new Vector3D(0, 0, 1), + Normal = GetHostUpVector(), Radius = GetRadiusForGridVisualization(), Color = style.Color, Alpha = style.Alpha, @@ -954,6 +1046,7 @@ namespace NavisworksTransport visualization.PointMarkers.Clear(); visualization.ControlLineMarkers.Clear(); // 确保没有控制点连线 visualization.PathLineMarkers.Clear(); // 确保没有路径连线 + visualization.PlaneMarkers.Clear(); var points = visualization.PathRoute.Points; if (points.Count == 0) return; @@ -1126,6 +1219,14 @@ namespace NavisworksTransport /// 空轨模型ID /// 基准路径点列表 public void RenderRailBaseline(string railModelId, List baselinePoints) + { + RenderRailBaseline(railModelId, baselinePoints, RenderStyleName.RailBaseline); + } + + /// + /// 按指定样式渲染基准路径。 + /// + public void RenderRailBaseline(string railModelId, List baselinePoints, RenderStyleName renderStyleName) { if (string.IsNullOrEmpty(railModelId) || baselinePoints == null || baselinePoints.Count < 2) { @@ -1146,7 +1247,7 @@ namespace NavisworksTransport LastUpdated = DateTime.Now }; - var renderStyle = GetRenderStyle(RenderStyleName.RailBaseline); + var renderStyle = GetRenderStyle(renderStyleName); // 创建连线标记 for (int i = 0; i < baselinePoints.Count - 1; i++) @@ -1212,7 +1313,14 @@ namespace NavisworksTransport var result = new Dictionary>(); foreach (var kvp in _railBaselineVisualizations) { - result[kvp.Key] = kvp.Value.PathLineMarkers.Select(m => m.StartPoint).ToList(); + var pathPoints = kvp.Value.PathLineMarkers.Select(m => m.StartPoint).ToList(); + var lastMarker = kvp.Value.PathLineMarkers.LastOrDefault(); + if (lastMarker != null) + { + pathPoints.Add(lastMarker.EndPoint); + } + + result[kvp.Key] = pathPoints; } return result; } @@ -1353,7 +1461,7 @@ namespace NavisworksTransport var grayEndMarker = new CircleMarker { Center = originalEndPoint, - Normal = new Vector3D(0, 0, 1), + Normal = GetHostUpVector(), Radius = GetRadiusForPointType(PathPointType.EndPoint), Color = unreachedStyle.Color, // 未到达终点颜色 Alpha = unreachedStyle.Alpha, // 透明度,统一管理 @@ -1481,17 +1589,18 @@ namespace NavisworksTransport // 计算通行空间的垂直偏移(根据路径类型) double verticalOffset = 0; - if (_showObjectSpace) - { - if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Ground) + if (_showObjectSpace) { - // 地面路径:路径点是地面位置,通行空间底面在地面,中心需要向下偏移半个高度 - verticalOffset = -height / 2.0; - } - else if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail) - { - // 空轨路径:路径点是轨道中心线位置,通行空间顶面对齐轨道,中心需要向上偏移半个高度 - verticalOffset = height / 2.0; + if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Ground) + { + // 地面路径:路径点是地面位置,通行空间底面在地面,中心需要向上偏移半个高度 + verticalOffset = ResolveGroundPathObjectSpaceVerticalOffset(height); + } + else if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail) + { + verticalOffset = RailPathPoseHelper.GetObjectSpaceCenterZOffset( + visualization.PathRoute, + height); } else if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Hoisting) { @@ -1570,26 +1679,35 @@ namespace NavisworksTransport var endPoint = edge.SampledPoints.Last(); // 计算长方体的轴向量(right和up向量) - var (right, up) = CalculateCuboidAxes(startPoint, endPoint); + var preferredUp = ResolveObjectSpaceUpDirection(visualization.PathRoute); + var (right, up) = CalculateCuboidAxes(startPoint, endPoint, preferredUp); // 根据路径类型调整起点和终点位置 // 关键修复:垂直偏移应该沿着长方体的轴线方向(up向量)平移 Point3D adjustedStartPoint; Point3D adjustedEndPoint; - if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + if (_showObjectSpace && visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail) { - // 通行空间模式且有垂直偏移时,沿Z轴平移(确保只有垂直偏移,没有水平偏移) - adjustedStartPoint = new Point3D( - startPoint.X, - startPoint.Y, - startPoint.Z + verticalOffset - ); - adjustedEndPoint = new Point3D( - endPoint.X, - endPoint.Y, - endPoint.Z + verticalOffset - ); + adjustedStartPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + startPoint, + startPoint, + endPoint, + height); + adjustedEndPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + endPoint, + startPoint, + endPoint, + height); + } + else if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + { + // 非 Rail 通行空间必须沿宿主 up 偏移,不能写死世界 Z。 + var hostUp = GetHostUpVector(); + adjustedStartPoint = ApplyVerticalOffset(startPoint, hostUp, verticalOffset); + adjustedEndPoint = ApplyVerticalOffset(endPoint, hostUp, verticalOffset); } else { @@ -1614,7 +1732,8 @@ namespace NavisworksTransport SampledPoints = edge.SampledPoints, Height = height, // 根据模式设置高度 Width = width, // 根据模式设置宽度 - Opacity = lineOpacity // 根据模式设置透明度 + Opacity = lineOpacity, // 根据模式设置透明度 + UpDirection = preferredUp }; visualization.PathLineMarkers.Add(lineMarker); } @@ -1640,7 +1759,8 @@ namespace NavisworksTransport var endPoint = edge.Trajectory.Te; // 计算长方体的轴向量(right和up向量) - var (right, up) = CalculateCuboidAxes(startPoint, endPoint); + var preferredUp = ResolveObjectSpaceUpDirection(visualization.PathRoute); + var (right, up) = CalculateCuboidAxes(startPoint, endPoint, preferredUp); // 根据路径类型调整起点和终点位置 // 关键修复:垂直偏移应该沿着长方体的轴线方向(up向量)平移 @@ -1648,31 +1768,52 @@ namespace NavisworksTransport Point3D adjustedEndPoint; List adjustedSampledPoints; - if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + if (_showObjectSpace && visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail) { - // 通行空间模式且有垂直偏移时,沿Z轴平移(确保只有垂直偏移,没有水平偏移) - adjustedStartPoint = new Point3D( - startPoint.X, - startPoint.Y, - startPoint.Z + verticalOffset - ); - adjustedEndPoint = new Point3D( - endPoint.X, - endPoint.Y, - endPoint.Z + verticalOffset - ); + adjustedStartPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + startPoint, + startPoint, + endPoint, + height); + adjustedEndPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + endPoint, + startPoint, + endPoint, + height); - // 对 SampledPoints 应用垂直偏移(沿Z轴) + adjustedSampledPoints = new List(); + if (edge.SampledPoints != null) + { + for (int sampleIndex = 0; sampleIndex < edge.SampledPoints.Count; sampleIndex++) + { + var currentPoint = edge.SampledPoints[sampleIndex]; + var previousPoint = sampleIndex > 0 ? edge.SampledPoints[sampleIndex - 1] : currentPoint; + var nextPoint = sampleIndex < edge.SampledPoints.Count - 1 ? edge.SampledPoints[sampleIndex + 1] : currentPoint; + adjustedSampledPoints.Add(RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + currentPoint, + previousPoint, + nextPoint, + height)); + } + } + } + else if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + { + // 非 Rail 通行空间必须沿宿主 up 偏移,不能写死世界 Z。 + var hostUp = GetHostUpVector(); + adjustedStartPoint = ApplyVerticalOffset(startPoint, hostUp, verticalOffset); + adjustedEndPoint = ApplyVerticalOffset(endPoint, hostUp, verticalOffset); + + // 对 SampledPoints 应用同一宿主 up 方向偏移。 adjustedSampledPoints = new List(); if (edge.SampledPoints != null) { foreach (var point in edge.SampledPoints) { - adjustedSampledPoints.Add(new Point3D( - point.X, - point.Y, - point.Z + verticalOffset - )); + adjustedSampledPoints.Add(ApplyVerticalOffset(point, hostUp, verticalOffset)); } } } @@ -1701,7 +1842,8 @@ namespace NavisworksTransport SampledPoints = adjustedSampledPoints, Height = height, // 根据模式设置高度 Width = width, // 根据模式设置宽度 - Opacity = arcLineOpacity // 根据模式设置透明度 + Opacity = arcLineOpacity, // 根据模式设置透明度 + UpDirection = preferredUp }; visualization.PathLineMarkers.Add(arcMarker); @@ -1721,67 +1863,13 @@ namespace NavisworksTransport /// 路径段终点 /// 水平段方向向量(用于垂直路径渲染时确定通行空间方向) /// 元组(right向量,up向量) - private (Vector3D right, Vector3D up) CalculateCuboidAxes(Point3D startPoint, Point3D endPoint, Vector3D horizontalDirection = null) + private (Vector3D right, Vector3D up) CalculateCuboidAxes(Point3D startPoint, Point3D endPoint, Vector3D upReference = null, Vector3D horizontalDirection = null) { - // 计算路径段的方向向量 - var segmentDirection = new Vector3D( - endPoint.X - startPoint.X, - endPoint.Y - startPoint.Y, - endPoint.Z - startPoint.Z - ); - var segmentLength = Math.Sqrt( - segmentDirection.X * segmentDirection.X + - segmentDirection.Y * segmentDirection.Y + - segmentDirection.Z * segmentDirection.Z - ); - - // 归一化方向向量 - var normalizedDirection = new Vector3D( - segmentDirection.X / segmentLength, - segmentDirection.Y / segmentLength, - segmentDirection.Z / segmentLength - ); - - // 计算垂直向量(宽度方向) - // 使用方向向量与Z轴的叉积来计算right向量,确保right向量在水平面上 - var right = new Vector3D(normalizedDirection.Y, -normalizedDirection.X, 0); - - // 如果方向向量垂直于XY平面(吊装路径),则使用水平段的方向向量来计算right - if (Math.Abs(normalizedDirection.Z) > 0.9) - { - if (horizontalDirection != null) - { - right = new Vector3D(horizontalDirection.Y, -horizontalDirection.X, 0); - } - else - { - right = new Vector3D(1, 0, 0); - } - } - - // 归一化right向量 - var rightLength = Math.Sqrt(right.X * right.X + right.Y * right.Y + right.Z * right.Z); - if (rightLength > 0.001) - { - right = new Vector3D(right.X / rightLength, right.Y / rightLength, right.Z / rightLength); - } - - // 计算高度向量(垂直于方向向量和宽度向量)- 这是长方体的轴线方向 - // up = direction × right - var up = new Vector3D( - normalizedDirection.Y * right.Z - normalizedDirection.Z * right.Y, - normalizedDirection.Z * right.X - normalizedDirection.X * right.Z, - normalizedDirection.X * right.Y - normalizedDirection.Y * right.X - ); - - // 归一化up向量 - var upLength = Math.Sqrt(up.X * up.X + up.Y * up.Y + up.Z * up.Z); - if (upLength > 0.001) - { - up = new Vector3D(up.X / upLength, up.Y / upLength, up.Z / upLength); - } - - return (right, up); + return ObjectSpaceOrientationHelper.CalculateAxes( + startPoint, + endPoint, + upReference ?? GetHostUpVector(), + horizontalDirection); } /// @@ -1802,16 +1890,11 @@ namespace NavisworksTransport if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Hoisting && sortedPoints.Count >= 3) { - // 吊装路径:线段1是水平段(从点1到点2) - var horizontalStart = sortedPoints[1].Position; - var horizontalEnd = sortedPoints[2].Position; - var dx = horizontalEnd.X - horizontalStart.X; - var dy = horizontalEnd.Y - horizontalStart.Y; - var length = Math.Sqrt(dx * dx + dy * dy); - if (length > 0.001) - { - horizontalDirection = new Vector3D(dx / length, dy / length, 0); - } + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + horizontalDirection = ToVector3D(HoistingCoordinateHelper.GetHorizontalDirection3( + ToVector3(sortedPoints[1].Position), + ToVector3(sortedPoints[2].Position), + adapter)); } // 直接使用路径点构建直线连线 @@ -1819,6 +1902,19 @@ namespace NavisworksTransport { var startPoint = sortedPoints[i]; var endPoint = sortedPoints[i + 1]; + double segmentDx = endPoint.Position.X - startPoint.Position.X; + double segmentDy = endPoint.Position.Y - startPoint.Position.Y; + double segmentDz = endPoint.Position.Z - startPoint.Position.Z; + double segmentLength = Math.Sqrt( + segmentDx * segmentDx + + segmentDy * segmentDy + + segmentDz * segmentDz); + + if (segmentLength < 0.001) + { + LogManager.Debug($"[路径渲染] 跳过零长度路径段: 索引={i}, 路径={visualization.PathRoute?.Name}"); + continue; + } // 对于吊装路径,判断是否是垂直段和起吊/下降段 bool isVerticalSegment = false; @@ -1842,13 +1938,12 @@ namespace NavisworksTransport int firstSegment = 0; // 起吊段 int lastSegment = totalSegments - 1; // 下降段 - // 检测Z方向变化来判断垂直段 - double dz = endPoint.Position.Z - startPoint.Position.Z; - double dx = endPoint.Position.X - startPoint.Position.X; - double dy = endPoint.Position.Y - startPoint.Position.Y; - double horizontalDist = Math.Sqrt(dx * dx + dy * dy); - // 如果Z变化占主导(垂直段),或者是第一个/最后一个线段 - bool isZDominant = Math.Abs(dz) > horizontalDist * 2.0; // 垂直变化是水平变化的2倍以上 + // 沿宿主 up 判断垂直段,避免把世界 Z 误当成向上轴 + var hostUp = GetHostUpVector(); + double upDelta = segmentDx * hostUp.X + segmentDy * hostUp.Y + segmentDz * hostUp.Z; + double horizontalDist = Math.Sqrt(Math.Max(0.0, segmentLength * segmentLength - upDelta * upDelta)); + // 如果沿宿主 up 的变化占主导(垂直段),或者是第一个/最后一个线段 + bool isZDominant = Math.Abs(upDelta) > horizontalDist * 2.0; // 第一个线段和最后一个线段是起吊/下降段 // 中间的垂直段是层间过渡 @@ -1863,15 +1958,22 @@ namespace NavisworksTransport bool isTurn90Horizontal = false; if (!isVerticalSegment && visualization.PathRoute.PathType == NavisworksTransport.PathType.Hoisting && horizontalDirection != null) { - // 计算当前水平段方向 - var currentDx = endPoint.Position.X - startPoint.Position.X; - var currentDy = endPoint.Position.Y - startPoint.Position.Y; - var currentLength = Math.Sqrt(currentDx * currentDx + currentDy * currentDy); + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var currentDirection = ToVector3D(HoistingCoordinateHelper.GetHorizontalDirection3( + ToVector3(startPoint.Position), + ToVector3(endPoint.Position), + adapter)); + var currentLength = Math.Sqrt( + currentDirection.X * currentDirection.X + + currentDirection.Y * currentDirection.Y + + currentDirection.Z * currentDirection.Z); if (currentLength > 0.001) { - var currentDirection = new Vector3D(currentDx / currentLength, currentDy / currentLength, 0); // 计算与初始水平段方向的点积 - double dotProduct = currentDirection.X * horizontalDirection.X + currentDirection.Y * horizontalDirection.Y; + double dotProduct = + currentDirection.X * horizontalDirection.X + + currentDirection.Y * horizontalDirection.Y + + currentDirection.Z * horizontalDirection.Z; // 点积接近0表示垂直(90度转折) isTurn90Horizontal = Math.Abs(dotProduct) < 0.1; } @@ -1881,27 +1983,35 @@ namespace NavisworksTransport CalculatePassageSpaceParameters(visualization, isVerticalSegment, isTurn90Horizontal, isFirstOrLastSegment); // 计算长方体的轴向量(right和up向量) - var (right, up) = CalculateCuboidAxes(startPoint.Position, endPoint.Position, horizontalDirection); + var preferredUp = ResolveObjectSpaceUpDirection(visualization.PathRoute); + var (right, up) = CalculateCuboidAxes(startPoint.Position, endPoint.Position, preferredUp, horizontalDirection); // 根据路径类型调整起点和终点位置 // 关键修复:垂直偏移应该沿着长方体的轴线方向(up向量)平移,而不是简单地添加到Z坐标上 Point3D adjustedStartPoint; Point3D adjustedEndPoint; - if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + if (_showObjectSpace && visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail) { - // 通行空间模式且有垂直偏移时 - // 所有空中路径:统一沿Z轴向下偏移,顶面中心对齐路径点 - adjustedStartPoint = new Point3D( - startPoint.Position.X, - startPoint.Position.Y, - startPoint.Position.Z + verticalOffset - ); - adjustedEndPoint = new Point3D( - endPoint.Position.X, - endPoint.Position.Y, - endPoint.Position.Z + verticalOffset - ); + adjustedStartPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + startPoint.Position, + startPoint.Position, + endPoint.Position, + height); + adjustedEndPoint = RailPathPoseHelper.ResolveObjectSpaceCenterPosition( + visualization.PathRoute, + endPoint.Position, + startPoint.Position, + endPoint.Position, + height); + } + else if (_showObjectSpace && Math.Abs(verticalOffset) > 0.001) + { + // 非 Rail 通行空间统一沿宿主 up 方向偏移,不能再默认世界 Z。 + var hostUp = GetHostUpVector(); + adjustedStartPoint = ApplyVerticalOffset(startPoint.Position, hostUp, verticalOffset); + adjustedEndPoint = ApplyVerticalOffset(endPoint.Position, hostUp, verticalOffset); } else { @@ -1927,27 +2037,26 @@ namespace NavisworksTransport { // 层间垂直过渡:通行空间总高度 = 层间路径高度 + 物体高度 // 顶面中心 = 路径高点,底面中心 = 路径低点向下物体高度 - bool isStartHigher = adjustedStartPoint.Z > adjustedEndPoint.Z; + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + bool isStartHigher = + HoistingCoordinateHelper.GetElevation(adjustedStartPoint, adapter) > + HoistingCoordinateHelper.GetElevation(adjustedEndPoint, adapter); if (isStartHigher) { - // 起点是高点,终点是低点 - // 顶面在起点,底面在终点向下延伸物体高度 - adjustedEndPoint = new Point3D( - adjustedEndPoint.X, - adjustedEndPoint.Y, - adjustedEndPoint.Z - _objectHeight - ); + adjustedEndPoint = HoistingCoordinateHelper.ExtendVerticalTransitionForObjectSpace( + adjustedEndPoint, + _objectHeight, + isLowerPoint: true, + adapter: adapter); } else { - // 终点是高点,起点是低点 - // 顶面在终点,底面在起点向下延伸物体高度 - adjustedStartPoint = new Point3D( - adjustedStartPoint.X, - adjustedStartPoint.Y, - adjustedStartPoint.Z - _objectHeight - ); + adjustedStartPoint = HoistingCoordinateHelper.ExtendVerticalTransitionForObjectSpace( + adjustedStartPoint, + _objectHeight, + isLowerPoint: true, + adapter: adapter); } } // 起吊/下降段不需要延伸,因为底面对齐地面 @@ -1965,7 +2074,8 @@ namespace NavisworksTransport Height = height, Width = width, Opacity = lineOpacity, - HorizontalDirection = horizontalDirection // 设置水平段方向向量 + HorizontalDirection = horizontalDirection, // 设置水平段方向向量 + UpDirection = preferredUp }; visualization.PathLineMarkers.Add(lineMarker); } @@ -1985,35 +2095,84 @@ namespace NavisworksTransport return point; } - // 归一化up向量 - var upLength = Math.Sqrt(up.X * up.X + up.Y * up.Y + up.Z * up.Z); - if (upLength < 0.001) + var normalizedUp = Normalize(up); + if (normalizedUp.X == 0.0 && normalizedUp.Y == 0.0 && normalizedUp.Z == 0.0) { return point; } - var normalizedUp = new Vector3D(up.X / upLength, up.Y / upLength, up.Z / upLength); - // 计算up向量在垂直平面上的投影(只保留Z分量) - // 这样可以确保偏移只在垂直方向上,不会产生水平偏移 - var verticalComponent = new Vector3D(0, 0, normalizedUp.Z); + return new Point3D( + point.X + normalizedUp.X * verticalOffset, + point.Y + normalizedUp.Y * verticalOffset, + point.Z + normalizedUp.Z * verticalOffset + ); + } - // 计算垂直分量的长度 - var verticalComponentLength = Math.Abs(verticalComponent.Z); + private static System.Numerics.Vector3 ToVector3(Point3D point) + { + return new System.Numerics.Vector3((float)point.X, (float)point.Y, (float)point.Z); + } - if (verticalComponentLength < 0.001) + private static Vector3D ToVector3D(System.Numerics.Vector3 vector) + { + return new Vector3D(vector.X, vector.Y, vector.Z); + } + + private static Vector3D GetHostUpVector() + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + return Normalize(adapter.FromCanonicalVector(HostCoordinateAdapter.CanonicalUp)); + } + + 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 Vector3D Normalize(Vector3D vector) + { + double lengthSquared = vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z; + if (lengthSquared < 1e-9) { - // 如果up向量几乎是水平的,则直接使用纯Z方向 - return new Point3D(point.X, point.Y, point.Z + verticalOffset); + return new Vector3D(0, 0, 0); } - // 沿着垂直分量方向平移 - // 需要调整偏移量,因为垂直分量的长度可能不是1 - var adjustedOffset = verticalOffset * (Math.Sign(verticalComponent.Z) / verticalComponentLength); + double length = Math.Sqrt(lengthSquared); + return new Vector3D(vector.X / length, vector.Y / length, vector.Z / length); + } + + private static Point3D ApplyVectorOffset(Point3D point, Vector3D direction, double distance) + { + var normalized = Normalize(direction); return new Point3D( - point.X, - point.Y, - point.Z + adjustedOffset - ); + point.X + normalized.X * distance, + point.Y + normalized.Y * distance, + point.Z + normalized.Z * distance); + } + + private static (Vector3D axis1, Vector3D axis2) GetHostPlanarAxes(Vector3D hostUp) + { + var normalizedUp = Normalize(hostUp); + var reference = Math.Abs(normalizedUp.X) > 0.9 + ? new Vector3D(0, 1, 0) + : new Vector3D(1, 0, 0); + + double projection = reference.X * normalizedUp.X + reference.Y * normalizedUp.Y + reference.Z * normalizedUp.Z; + var axis1 = Normalize(new Vector3D( + reference.X - projection * normalizedUp.X, + reference.Y - projection * normalizedUp.Y, + reference.Z - projection * normalizedUp.Z)); + + if (axis1.X == 0 && axis1.Y == 0 && axis1.Z == 0) + { + axis1 = new Vector3D(0, 0, 1); + } + + var axis2 = Normalize(Cross(normalizedUp, axis1)); + return (axis1, axis2); } /// @@ -2042,7 +2201,7 @@ namespace NavisworksTransport var tsMarker = new SquareMarker { Center = edge.Trajectory.Ts, - Normal = new Vector3D(0, 0, 1), + Normal = GetHostUpVector(), Size = tangentRadius * 2, // 直径 Height = tangentHeight, // 高度 Color = tangentColor, @@ -2056,7 +2215,7 @@ namespace NavisworksTransport var teMarker = new SquareMarker { Center = edge.Trajectory.Te, - Normal = new Vector3D(0, 0, 1), + Normal = GetHostUpVector(), Size = tangentRadius * 2, // 直径 Height = tangentHeight, // 高度 Color = tangentColor, @@ -2139,8 +2298,9 @@ namespace NavisworksTransport return new CircleMarker { Center = point.Position, - Normal = new Vector3D(0, 0, 1), - Radius = isGridVisualization ? GetRadiusForGridVisualization() : GetRadiusForPointType(point.Type), + Normal = GetHostUpVector(), + Radius = point.ResolveVisualizationRadius( + isGridVisualization ? GetRadiusForGridVisualization() : GetRadiusForPointType(point.Type)), Color = isGridVisualization ? gridStyle.Color : GetColorForPointType(point.Type), Alpha = isGridVisualization ? gridStyle.Alpha : 1.0, Filled = true, @@ -2185,7 +2345,12 @@ namespace NavisworksTransport /// 沿路径方向的通行空间尺寸(模型单位) /// 垂直段的高度(物体长度 + 2×安全间隙,模型单位) /// 水平段的高度(物体高度 + 2×安全间隙,模型单位) - public void SetPassageSpaceParameters(double passageAcrossPath, double passageNormalToPath, double passageAlongPath, double passageNormalToPathVertical, double passageNormalToPathHorizontal) + public void SetPassageSpaceParameters( + double passageAcrossPath, + double passageNormalToPath, + double passageAlongPath, + double passageNormalToPathVertical, + double passageNormalToPathHorizontal) { // 直接存储模型单位(调用方已转换) _passageAlongPath = passageAlongPath; @@ -2200,6 +2365,11 @@ namespace NavisworksTransport RefreshNormalPaths(); } + internal static double ResolveGroundPathObjectSpaceVerticalOffset(double objectSpaceHeight) + { + return objectSpaceHeight / 2.0; + } + #region 颜色管理 /// @@ -2258,9 +2428,24 @@ namespace NavisworksTransport case RenderStyleName.RailBaseline: return new RenderStyle(Color.FromByteRGB(255, 138, 128), 0.7); // 浅红色,30%透明 + case RenderStyleName.AssemblyGuideLine: + return new RenderStyle(Color.FromByteRGB(76, 175, 80), 0.85); // Material Green装配基准向量 + + case RenderStyleName.AssemblyInstallationPlane: + return new RenderStyle(Color.FromByteRGB(76, 175, 80), 0.5); // Material Green安装参考面,50%透明 + case RenderStyleName.HoistingLine: return new RenderStyle(Color.FromByteRGB(156, 39, 176), 0.8); // Material Purple吊装路径,20%透明 + case RenderStyleName.TransformAxisX: + return new RenderStyle(Color.FromByteRGB(244, 67, 54), 0.9); // Material Red + + case RenderStyleName.TransformAxisY: + return new RenderStyle(Color.FromByteRGB(76, 175, 80), 0.9); // Material Green + + case RenderStyleName.TransformAxisZ: + return new RenderStyle(Color.FromByteRGB(33, 150, 243), 0.9); // Material Blue + default: return new RenderStyle(Color.White, 1.0); // 默认白色,完全不透明 } @@ -2639,7 +2824,7 @@ namespace NavisworksTransport /// 宽度 /// 高度 /// 水平段方向向量(用于垂直路径渲染时确定通行空间方向) - private void RenderCuboidMarker(Graphics graphics, Point3D startPoint, Point3D endPoint, double width, double height, Vector3D horizontalDirection = null) + private void RenderCuboidMarker(Graphics graphics, Point3D startPoint, Point3D endPoint, double width, double height, Vector3D horizontalDirection = null, Vector3D upReference = null) { try { @@ -2659,44 +2844,11 @@ namespace NavisworksTransport // 归一化方向向量 direction = new Vector3D(direction.X / length, direction.Y / length, direction.Z / length); - // 计算垂直向量(在XY平面内垂直于方向向量) - var right = new Vector3D(-direction.Y, direction.X, 0); - - // 如果方向向量垂直于XY平面(吊装路径),则使用水平段的方向向量来计算right - if (Math.Abs(direction.Z) > 0.9) - { - if (horizontalDirection != null) - { - // 垂直段的 right 向量应该垂直于水平段的方向向量 - right = new Vector3D(horizontalDirection.Y, -horizontalDirection.X, 0); - } - else - { - // 如果没有提供水平段方向向量,使用默认值 - right = new Vector3D(1, 0, 0); - } - } - - // 归一化right向量 - var rightLength = Math.Sqrt(right.X * right.X + right.Y * right.Y + right.Z * right.Z); - if (rightLength > 0.001) - { - right = new Vector3D(right.X / rightLength, right.Y / rightLength, right.Z / rightLength); - } - - // 计算高度向量(垂直于方向向量和宽度向量) - var up = new Vector3D( - direction.Y * right.Z - direction.Z * right.Y, - direction.Z * right.X - direction.X * right.Z, - direction.X * right.Y - direction.Y * right.X - ); - - // 归一化up向量 - var upLength = Math.Sqrt(up.X * up.X + up.Y * up.Y + up.Z * up.Z); - if (upLength > 0.001) - { - up = new Vector3D(up.X / upLength, up.Y / upLength, up.Z / upLength); - } + var (right, up) = ObjectSpaceOrientationHelper.CalculateAxes( + startPoint, + endPoint, + upReference ?? GetHostUpVector(), + horizontalDirection); // 计算长方体的边界框和向量 var halfWidth = width / 2.0; @@ -2733,7 +2885,7 @@ namespace NavisworksTransport /// 高度 /// 指定长度(覆盖默认计算值) /// 水平段方向向量(用于垂直路径渲染时确定通行空间方向) - private void RenderCuboidMarkerWithLength(Graphics graphics, Point3D startPoint, Point3D endPoint, double width, double height, double length, Vector3D horizontalDirection = null) + private void RenderCuboidMarkerWithLength(Graphics graphics, Point3D startPoint, Point3D endPoint, double width, double height, double length, Vector3D horizontalDirection = null, Vector3D upReference = null) { try { @@ -2751,44 +2903,11 @@ namespace NavisworksTransport direction = new Vector3D(direction.X / directionLength, direction.Y / directionLength, direction.Z / directionLength); - // 计算垂直向量(在XY平面内垂直于方向向量) - var right = new Vector3D(-direction.Y, direction.X, 0); - - // 如果方向向量垂直于XY平面(吊装路径),则使用水平段的方向向量来计算right - if (Math.Abs(direction.Z) > 0.9) - { - if (horizontalDirection != null) - { - // 垂直段的 right 向量应该垂直于水平段的方向向量 - right = new Vector3D(-horizontalDirection.Y, horizontalDirection.X, 0); - } - else - { - // 如果没有提供水平段方向向量,使用默认值 - right = new Vector3D(1, 0, 0); - } - } - - // 归一化right向量 - var rightLength = Math.Sqrt(right.X * right.X + right.Y * right.Y + right.Z * right.Z); - if (rightLength > 0.001) - { - right = new Vector3D(right.X / rightLength, right.Y / rightLength, right.Z / rightLength); - } - - // 计算高度向量(垂直于方向向量和宽度向量) - var up = new Vector3D( - direction.Y * right.Z - direction.Z * right.Y, - direction.Z * right.X - direction.X * right.Z, - direction.X * right.Y - direction.Y * right.X - ); - - // 归一化up向量 - var upLength = Math.Sqrt(up.X * up.X + up.Y * up.Y + up.Z * up.Z); - if (upLength > 0.001) - { - up = new Vector3D(up.X / upLength, up.Y / upLength, up.Z / upLength); - } + var (right, up) = ObjectSpaceOrientationHelper.CalculateAxes( + startPoint, + endPoint, + upReference ?? GetHostUpVector(), + horizontalDirection); // 计算长方体的边界框和向量 var halfWidth = width / 2.0; @@ -2845,7 +2964,8 @@ namespace NavisworksTransport lineMarker.EndPoint, width, lineMarker.Height, - lineMarker.HorizontalDirection + lineMarker.HorizontalDirection, + lineMarker.UpDirection ); } } @@ -2888,11 +3008,25 @@ namespace NavisworksTransport width, lineMarker.Height, _passageAlongPath, - lineMarker.HorizontalDirection + lineMarker.HorizontalDirection, + lineMarker.UpDirection ); } } + private Vector3D ResolveObjectSpaceUpDirection(PathRoute route) + { + if (route?.PathType == NavisworksTransport.PathType.Rail && route.RailPreferredNormal != null) + { + return Normalize(new Vector3D( + route.RailPreferredNormal.X, + route.RailPreferredNormal.Y, + route.RailPreferredNormal.Z)); + } + + return GetHostUpVector(); + } + /// /// 计算采样点处的切线方向 /// @@ -2983,15 +3117,18 @@ namespace NavisworksTransport // 计算正方形的边长(直径) double sideLength = pointMarker.Radius * 2; - // 定义正方形的两个轴向量(沿X、Y轴) - var xVector = new Vector3D(sideLength, 0, 0); - var yVector = new Vector3D(0, sideLength, 0); + // 网格矩形必须落在宿主水平平面内,不能写死世界 XY。 + var hostUp = GetHostUpVector(); + var (planarAxis1, planarAxis2) = GetHostPlanarAxes(hostUp); + var xVector = new Vector3D(planarAxis1.X * sideLength, planarAxis1.Y * sideLength, planarAxis1.Z * sideLength); + var yVector = new Vector3D(planarAxis2.X * sideLength, planarAxis2.Y * sideLength, planarAxis2.Z * sideLength); - // 计算正方形原点(中心点减去各轴向量的一半),略微抬高以避免与模型重叠 + // 沿宿主 up 略微抬高,避免与模型重叠。 + var liftedCenter = ApplyVectorOffset(pointMarker.Center, hostUp, 0.01); var origin = new Point3D( - pointMarker.Center.X - pointMarker.Radius, - pointMarker.Center.Y - pointMarker.Radius, - pointMarker.Center.Z + 0.01 + liftedCenter.X - planarAxis1.X * pointMarker.Radius - planarAxis2.X * pointMarker.Radius, + liftedCenter.Y - planarAxis1.Y * pointMarker.Radius - planarAxis2.Y * pointMarker.Radius, + liftedCenter.Z - planarAxis1.Z * pointMarker.Radius - planarAxis2.Z * pointMarker.Radius ); // 使用Rectangle API渲染正方形 @@ -3005,12 +3142,8 @@ namespace NavisworksTransport /// 点标记 private void RenderCircleMarker(Graphics graphics, CircleMarker pointMarker) { - // 调整中心点位置,略微抬高以避免与模型重叠 - Point3D adjustCenterPoint = new Point3D( - pointMarker.Center.X, - pointMarker.Center.Y, - pointMarker.Center.Z + 0.01 - ); + // 调整中心点位置,沿宿主 up 略微抬高以避免与模型重叠 + Point3D adjustCenterPoint = ApplyVectorOffset(pointMarker.Center, pointMarker.Normal, 0.01); // 使用Circle API渲染圆形 graphics.Circle(adjustCenterPoint, pointMarker.Normal, pointMarker.Radius, true); @@ -3056,6 +3189,12 @@ namespace NavisworksTransport } } + private void RenderPlaneMarker(Graphics graphics, PlaneMarker marker) + { + graphics.Color(marker.Color, marker.Alpha); + graphics.Rectangle(marker.Origin, marker.XVector, marker.YVector, marker.Filled); + } + /// /// 渲染连线标记(支持直线段和圆弧段) /// @@ -3417,4 +3556,14 @@ namespace NavisworksTransport return $"CircleMarker[序号={SequenceNumber}, 类型={PointType}, 中心=({Center.X:F2},{Center.Y:F2},{Center.Z:F2}), 半径={Radius:F2}]"; } } -} \ No newline at end of file + + public class PlaneMarker + { + public Point3D Origin { get; set; } + public Vector3D XVector { get; set; } + public Vector3D YVector { get; set; } + public Color Color { get; set; } + public double Alpha { get; set; } + public bool Filled { get; set; } = true; + } +} diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs new file mode 100644 index 0000000..24ea1f6 --- /dev/null +++ b/src/Core/Services/TestAutomationHttpService.cs @@ -0,0 +1,2227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autodesk.Navisworks.Api; +using NavisworksTransport.Commands; +using NavisworksTransport.Core.Animation; +using NavisworksTransport.Core.Config; +using NavisworksTransport.PathPlanning; +using NavisworksTransport.UI.WPF.ViewModels; +using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; +using Newtonsoft.Json; + +namespace NavisworksTransport.Core.Services +{ + /// + /// 本地测试自动化 HTTP 控制面。 + /// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。 + /// + public sealed class TestAutomationHttpService : IDisposable + { + private const int DefaultPort = 18777; + private const string DefaultHost = "127.0.0.1"; + private const string DefaultAutoTestRoutePrefix = "自动测试_"; + + private static readonly Lazy _instance = + new Lazy(() => new TestAutomationHttpService()); + + private readonly object _syncRoot = new object(); + private static int _autoConfirmCollisionAnalysisDialogRequestCount; + private static int _autoChooseCreateNewDetectionRecordRequestCount; + + private TcpListener _listener; + private CancellationTokenSource _cts; + private Task _acceptLoopTask; + private DateTime _startedAtUtc; + private string _lastStartError; + + private TestAutomationHttpService() + { + } + + public static TestAutomationHttpService Instance => _instance.Value; + + public bool IsRunning + { + get + { + lock (_syncRoot) + { + return _listener != null; + } + } + } + + public int Port => DefaultPort; + + public string BaseUrl => $"http://{DefaultHost}:{DefaultPort}"; + + public static bool ShouldAutoConfirmCollisionAnalysisDialogs => + Interlocked.CompareExchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0, 0) > 0; + + public static bool ShouldAutoChooseCreateNewDetectionRecord => + Interlocked.CompareExchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0, 0) > 0; + + public void Start() + { + lock (_syncRoot) + { + if (_listener != null) + { + return; + } + + try + { + _cts = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Loopback, DefaultPort); + _listener.Start(); + _startedAtUtc = DateTime.UtcNow; + _lastStartError = null; + _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); + + LogManager.Info($"[测试HTTP] 服务已启动: {BaseUrl}"); + } + catch (Exception ex) + { + _lastStartError = ex.Message; + _listener = null; + _cts?.Dispose(); + _cts = null; + _acceptLoopTask = null; + LogManager.Error($"[测试HTTP] 服务启动失败: {ex.Message}", ex); + } + } + } + + public void Stop() + { + Task acceptLoopTask = null; + + lock (_syncRoot) + { + if (_listener == null) + { + return; + } + + try + { + _cts?.Cancel(); + _listener.Stop(); + acceptLoopTask = _acceptLoopTask; + } + catch (Exception ex) + { + LogManager.Warning($"[测试HTTP] 服务停止时出现警告: {ex.Message}"); + } + finally + { + _listener = null; + _acceptLoopTask = null; + _cts?.Dispose(); + _cts = null; + } + } + + try + { + acceptLoopTask?.Wait(1000); + } + catch + { + // 忽略退出等待异常 + } + + LogManager.Info("[测试HTTP] 服务已停止"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client = null; + try + { + client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + LogManager.Error($"[测试HTTP] 接收请求失败: {ex.Message}", ex); + client?.Dispose(); + } + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + using (client) + using (var stream = client.GetStream()) + using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, true) { NewLine = "\r\n", AutoFlush = true }) + { + try + { + string requestLine = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(requestLine)) + { + return; + } + + string[] requestLineParts = requestLine.Split(' '); + if (requestLineParts.Length < 2) + { + await WriteJsonResponseAsync(writer, 400, BuildEnvelope(false, error: "Invalid request line")).ConfigureAwait(false); + return; + } + + string method = requestLineParts[0].Trim().ToUpperInvariant(); + string requestTarget = requestLineParts[1].Trim(); + var request = ParseRequestTarget(requestTarget); + + string headerLine; + while (!string.IsNullOrEmpty(headerLine = await reader.ReadLineAsync().ConfigureAwait(false))) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/ping", StringComparison.OrdinalIgnoreCase)) + { + var payload = new + { + ok = true, + service = "NavisworksTransport.TestAutomation", + protocol = "http", + version = 1, + baseUrl = BaseUrl, + serverTimeUtc = DateTime.UtcNow.ToString("o") + }; + await WriteJsonResponseAsync(writer, 200, payload).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildStatusPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/routes", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildRoutesPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/route-grid-diagnostics", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => BuildRouteGridDiagnosticsPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/selection", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildSelectionPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/export-debug-snapshot", StringComparison.OrdinalIgnoreCase)) + { + object snapshotPayload = InvokeOnUiThread(BuildDebugSnapshotPayload); + string snapshotFilePath = WriteSnapshotToDisk(snapshotPayload); + var payload = new + { + snapshotFilePath, + snapshot = snapshotPayload + }; + + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-route", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectRoutePayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-default-route", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectDefaultRoutePayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-animated-object", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectAnimatedObjectPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/run-ground-collision-test", StringComparison.OrdinalIgnoreCase)) + { + object payload = await RunVirtualCollisionTestAsync(request.Query, PathType.Ground).ConfigureAwait(false); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/run-virtual-collision-test", StringComparison.OrdinalIgnoreCase)) + { + object payload = await RunVirtualCollisionTestAsync(request.Query, null).ConfigureAwait(false); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/analyze-auto-path-grid", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => AnalyzeAutoPathGridPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/run-auto-path", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => RunAutoPathPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + !string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase)) + { + await WriteJsonResponseAsync(writer, 405, BuildEnvelope(false, error: "Only GET and POST are supported in the current test API")).ConfigureAwait(false); + return; + } + + await WriteJsonResponseAsync(writer, 404, BuildEnvelope(false, error: $"Unknown endpoint: {request.Path}")).ConfigureAwait(false); + } + catch (Exception ex) + { + LogManager.Error($"[测试HTTP] 处理请求失败: {ex.Message}", ex); + await WriteJsonResponseAsync(writer, 500, BuildEnvelope(false, error: ex.Message)).ConfigureAwait(false); + } + } + } + + private object BuildStatusPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + + PathRoute currentRoute = pathManager?.CurrentRoute; + List allRoutes = pathManager?.GetAllRoutes(); + + return new + { + service = new + { + name = "NavisworksTransport.TestAutomation", + protocol = "http", + version = 1, + isRunning = IsRunning, + baseUrl = BaseUrl, + port = Port, + startedAtUtc = _startedAtUtc == default(DateTime) ? null : _startedAtUtc.ToString("o"), + lastStartError = _lastStartError + }, + environment = new + { + processId = System.Diagnostics.Process.GetCurrentProcess().Id, + machineName = Environment.MachineName, + logFilePath = LogManager.LogFilePath + }, + document = new + { + hasActiveDocument = activeDocument != null, + fileName = activeDocument?.FileName, + title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), + modelCount = activeDocument?.Models?.Count ?? 0 + }, + pathManager = new + { + isAvailable = pathManager != null, + routeCount = allRoutes?.Count ?? 0, + currentRouteId = currentRoute?.Id, + currentRouteName = currentRoute?.Name, + currentRoutePathType = currentRoute?.PathType.ToString(), + currentRoutePointCount = currentRoute?.Points?.Count ?? 0 + }, + animation = new + { + isAvailable = animationManager != null, + currentState = animationManager?.CurrentState.ToString(), + isAnimating = animationManager?.IsAnimating ?? false, + currentFrame = animationManager?.CurrentFrame ?? 0, + totalFrames = animationManager?.TotalFrames ?? 0, + hasTrackedRotation = animationManager?.HasTrackedRotation ?? false, + currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI + } + }; + } + + private object BuildDebugSnapshotPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + + PathRoute currentRoute = pathManager?.CurrentRoute; + ModelItem controlledObject = ResolveControlledObject(animationManager); + var trackedState = ResolveTrackedState(animationManager, controlledObject); + var geometryState = ResolveGeometryState(controlledObject); + var boundingBoxState = ResolveBoundingBoxState(controlledObject); + + return new + { + snapshotVersion = 1, + capturedAtUtc = DateTime.UtcNow.ToString("o"), + document = new + { + hasActiveDocument = activeDocument != null, + fileName = activeDocument?.FileName, + title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), + modelCount = activeDocument?.Models?.Count ?? 0 + }, + route = new + { + isAvailable = currentRoute != null, + id = currentRoute?.Id, + name = currentRoute?.Name, + pathType = currentRoute?.PathType.ToString(), + pointCount = currentRoute?.Points?.Count ?? 0 + }, + animation = new + { + isAvailable = animationManager != null, + currentState = animationManager?.CurrentState.ToString(), + isAnimating = animationManager?.IsAnimating ?? false, + currentFrame = animationManager?.CurrentFrame ?? 0, + totalFrames = animationManager?.TotalFrames ?? 0, + currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI, + hasTrackedRotation = animationManager?.HasTrackedRotation ?? false + }, + controlledObject = new + { + exists = controlledObject != null, + displayName = controlledObject?.DisplayName, + instanceGuid = controlledObject == null ? null : controlledObject.InstanceGuid.ToString(), + isVirtualObject = controlledObject != null && + VirtualObjectManager.Instance.IsVirtualObjectActive && + ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, controlledObject), + trackedState, + geometryState, + boundingBox = boundingBoxState + }, + environment = new + { + processId = System.Diagnostics.Process.GetCurrentProcess().Id, + machineName = Environment.MachineName, + logFilePath = LogManager.LogFilePath + } + }; + } + + private object BuildRoutesPayload() + { + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + List routes = pathManager?.GetAllRoutes() ?? new List(); + PathRoute currentRoute = pathManager?.CurrentRoute; + + return new + { + isAvailable = pathManager != null, + currentRouteId = currentRoute?.Id, + currentRouteName = currentRoute?.Name, + currentRoutePathType = currentRoute?.PathType.ToString(), + totalRouteCount = routes.Count, + routes = routes.Select(route => SerializeRoute(route, currentRoute)).ToList(), + suggestedAutoTestRoutes = new + { + ground = TryFindAutoTestRoute(routes, PathType.Ground)?.Name, + hoisting = TryFindAutoTestRoute(routes, PathType.Hoisting)?.Name, + rail = TryFindAutoTestRoute(routes, PathType.Rail)?.Name + } + }; + } + + private object BuildSelectionPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + var selectedItems = activeDocument?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() + ?? new List(); + + return new + { + hasActiveDocument = activeDocument != null, + selectionCount = selectedItems.Count, + selectedItems = selectedItems.Select(SerializeModelItem).ToList() + }; + } + + private object SelectRoutePayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + string routeName = GetRequiredQueryValue(query, "name"); + List routes = pathManager.GetAllRoutes() ?? new List(); + + PathRoute selectedRoute = routes.FirstOrDefault(route => + string.Equals(route.Name, routeName, StringComparison.OrdinalIgnoreCase)); + if (selectedRoute == null) + { + throw new InvalidOperationException($"找不到指定路径: {routeName}"); + } + + pathManager.SetCurrentRoute(selectedRoute); + LogManager.Info($"[测试HTTP] 已切换当前路径: {selectedRoute.Name} ({selectedRoute.PathType})"); + + return new + { + selected = SerializeRoute(selectedRoute, selectedRoute), + totalRouteCount = routes.Count + }; + } + + private object SelectDefaultRoutePayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + string pathTypeValue = GetRequiredQueryValue(query, "pathType"); + string prefix = GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix; + + if (!Enum.TryParse(pathTypeValue, true, out PathType pathType)) + { + throw new InvalidOperationException($"无法解析路径类型: {pathTypeValue}"); + } + + List routes = pathManager.GetAllRoutes() ?? new List(); + PathRoute selectedRoute = routes + .FirstOrDefault(route => + route.PathType == pathType && + !string.IsNullOrWhiteSpace(route.Name) && + route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + if (selectedRoute == null) + { + throw new InvalidOperationException( + $"找不到默认测试路径: prefix={prefix}, pathType={pathType}"); + } + + pathManager.SetCurrentRoute(selectedRoute); + LogManager.Info($"[测试HTTP] 已切换默认测试路径: {selectedRoute.Name} ({selectedRoute.PathType})"); + + return new + { + selectionRule = new + { + prefix, + pathType = pathType.ToString() + }, + selected = SerializeRoute(selectedRoute, selectedRoute) + }; + } + + private object SelectAnimatedObjectPayload(Dictionary query) + { + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + PathPlanningManager pathManager = RequirePathManager(); + animationViewModel.SetPathPlanningManager(pathManager); + + ModelItem animatedObject = ResolveAnimatedObjectFromQuery(query); + SelectDocumentItem(animatedObject); + + if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null)) + { + throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令"); + } + + animationViewModel.UseVirtualObject = false; + animationViewModel.SelectAnimatedObjectCommand.Execute(null); + + if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject)) + { + throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); + } + + return new + { + selectedAnimatedObject = SerializeModelItem(animatedObject), + canGenerateAnimation = animationViewModel.CanGenerateAnimation, + isManualCollisionTargetEnabled = animationViewModel.IsManualCollisionTargetEnabled + }; + } + + private async Task RunVirtualCollisionTestAsync(Dictionary query, PathType? fixedPathType) + { + using (EnableAutoConfirmCollisionAnalysisDialogs()) + using (EnableAutoChooseCreateNewDetectionRecord()) + { + int timeoutSeconds = ParseTimeoutSeconds(query, 180); + DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + var setupResult = InvokeOnUiThread(() => PrepareVirtualCollisionTest(query, fixedPathType)); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => + { + var animationManager = PathAnimationManager.GetInstance(); + return animationManager != null && + animationManager.TotalFrames > 0 && + (animationManager.CurrentState == AnimationState.Ready || + animationManager.CurrentState == AnimationState.Finished); + }), + "等待动画生成完成超时").ConfigureAwait(false); + + InvokeOnUiThread(() => + { + StartPreparedAnimation(); + return true; + }); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentState == AnimationState.Finished), + "等待动画播放和碰撞检测完成超时").ConfigureAwait(false); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => + { + var animationVm = RequireAnimationControlViewModel(); + return animationVm.HasGeneratedCollisionReport; + }), + "等待碰撞报告生成超时").ConfigureAwait(false); + + return InvokeOnUiThread(() => + { + AnimationControlViewModel animationVm = RequireAnimationControlViewModel(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + CollisionReportResult report = animationVm.LastGeneratedReport; + + return new + { + route = setupResult.route, + animatedObject = setupResult.animatedObject, + requestedPathType = setupResult.pathType.ToString(), + timeoutSeconds, + automation = new + { + autoConfirmedCollisionAnalysisDialog = true, + autoChooseCreateNewDetectionRecord = true + }, + animation = new + { + currentState = animationManager?.CurrentState.ToString(), + totalFrames = animationManager?.TotalFrames ?? 0, + currentFrame = animationManager?.CurrentFrame ?? 0, + detectionRecordId = animationManager?.CurrentDetectionRecordId + }, + report = report == null + ? null + : new + { + totalCollisions = report.TotalCollisions, + uniqueCollidedObjectsCount = report.UniqueCollidedObjectsCount, + pathName = report.PathName, + movingObjectInfo = report.MovingObjectInfo, + hasScreenshots = report.Screenshots != null && report.Screenshots.Count > 0, + screenshotCount = report.Screenshots?.Count ?? 0, + resultId = report.ResultId, + routeId = report.RouteId + } + }; + }); + } + } + + private static object BuildEnvelope(bool ok, object data = null, string error = null) + { + return new Dictionary + { + ["ok"] = ok, + ["data"] = data, + ["error"] = error + }; + } + + private static T InvokeOnUiThread(Func func) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + return func(); + } + + return dispatcher.Invoke(func); + } + + private static ModelItem ResolveControlledObject(PathAnimationManager animationManager) + { + if (VirtualObjectManager.Instance.IsVirtualObjectActive && + VirtualObjectManager.Instance.CurrentVirtualObject != null) + { + return VirtualObjectManager.Instance.CurrentVirtualObject; + } + + return animationManager?.AnimatedObject; + } + + private static object SerializeModelItem(ModelItem item) + { + if (item == null) + { + return null; + } + + return new + { + displayName = item.DisplayName, + instanceGuid = item.InstanceGuid.ToString() + }; + } + + private static object SerializeRoute(PathRoute route, PathRoute currentRoute) + { + return new + { + id = route?.Id, + name = route?.Name, + pathType = route?.PathType.ToString(), + pointCount = route?.Points?.Count ?? 0, + isCurrent = currentRoute != null && ReferenceEquals(route, currentRoute), + matchesAutoTestPrefix = !string.IsNullOrWhiteSpace(route?.Name) && + route.Name.StartsWith(DefaultAutoTestRoutePrefix, StringComparison.OrdinalIgnoreCase) + }; + } + + private static PathRoute TryFindAutoTestRoute( + IEnumerable routes, + PathType pathType, + string routePrefix = DefaultAutoTestRoutePrefix) + { + string effectivePrefix = string.IsNullOrWhiteSpace(routePrefix) + ? DefaultAutoTestRoutePrefix + : routePrefix; + + return routes?.FirstOrDefault(route => + route != null && + route.PathType == pathType && + !string.IsNullOrWhiteSpace(route.Name) && + route.Name.StartsWith(effectivePrefix, StringComparison.OrdinalIgnoreCase)); + } + + private static object AnalyzeAutoPathGridPayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null); + if (route.Points == null || route.Points.Count < 2) + { + throw new InvalidOperationException($"路径点不足,无法分析自动路径网格: {route.Name}"); + } + + List orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); + PathPoint startPoint = orderedPoints.First(); + PathPoint endPoint = orderedPoints.Last(); + + BoundingBox3D bounds = InvokePathManagerPrivate(pathManager, "GetModelBounds"); + double gridSize = InvokePathManagerPrivate(pathManager, "CalculateOptimalGridSize", bounds); + + var config = ConfigManager.Instance.Current?.PathEditing + ?? throw new InvalidOperationException("缺少 PathEditing 配置,无法分析自动路径网格"); + + double objectLengthInMeters = config.ObjectLengthMeters; + double objectWidthInMeters = config.ObjectWidthMeters; + double objectHeightInMeters = config.ObjectHeightMeters; + double safetyMarginInMeters = config.SafetyMarginMeters; + double objectRadiusInMeters = Math.Max(objectLengthInMeters, objectWidthInMeters) / 2.0; + + var gridMapGenerator = new GridMapGenerator(); + GridMap gridMap = gridMapGenerator.GenerateFromBIM( + bounds, + gridSize, + objectRadiusInMeters, + safetyMarginInMeters, + startPoint.Position, + endPoint.Position, + objectHeightInMeters); + + return new + { + requestedPathType = route.PathType.ToString(), + route = new + { + id = route.Id, + name = route.Name, + pathType = route.PathType.ToString(), + pointCount = route.Points.Count, + startPoint = SerializePathPoint(startPoint), + endPoint = SerializePathPoint(endPoint) + }, + planningParameters = new + { + gridSizeInMeters = gridSize, + objectLengthInMeters, + objectWidthInMeters, + objectHeightInMeters, + objectRadiusInMeters, + safetyMarginInMeters + }, + bounds = new + { + min = SerializePoint3D(bounds.Min), + max = SerializePoint3D(bounds.Max) + }, + gridStats = SerializeGridStats(gridMap), + obstacleDiagnostics = BuildObstacleDiagnostics(gridMapGenerator, gridMap, objectHeightInMeters, safetyMarginInMeters) + }; + } + + private static object RunAutoPathPayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null); + if (route.Points == null || route.Points.Count < 2) + { + throw new InvalidOperationException($"路径点不足,无法执行自动路径规划: {route.Name}"); + } + + List orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); + PathPoint startPoint = orderedPoints.First(); + PathPoint endPoint = orderedPoints.Last(); + + var config = ConfigManager.Instance.Current?.PathEditing + ?? throw new InvalidOperationException("缺少 PathEditing 配置,无法执行自动路径规划"); + + BoundingBox3D bounds = InvokePathManagerPrivate(pathManager, "GetModelBounds"); + double gridSize = ParsePositiveDoubleQuery( + query, + "gridSizeInMeters", + InvokePathManagerPrivate(pathManager, "CalculateOptimalGridSize", bounds)); + double objectHeightInMeters = config.ObjectHeightMeters; + double objectLengthInMeters = ParsePositiveDoubleQuery(query, "objectLengthInMeters", config.ObjectLengthMeters); + double objectWidthInMeters = ParsePositiveDoubleQuery(query, "objectWidthInMeters", config.ObjectWidthMeters); + double safetyMarginInMeters = ParseNonNegativeDoubleQuery(query, "safetyMarginInMeters", config.SafetyMarginMeters); + double objectRadiusInMeters = Math.Max(objectLengthInMeters, objectWidthInMeters) / 2.0; + PathStrategy strategy = PathStrategy.Shortest; + + string strategyText = GetOptionalQueryValue(query, "strategy"); + if (!string.IsNullOrWhiteSpace(strategyText) && + Enum.TryParse(strategyText, true, out PathStrategy parsedStrategy)) + { + strategy = parsedStrategy; + } + + PathRoute autoRoute = pathManager + .AutoPlanPath( + startPoint, + endPoint, + objectRadiusInMeters, + safetyMarginInMeters, + gridSize, + objectHeightInMeters, + strategy) + .GetAwaiter() + .GetResult(); + + if (autoRoute == null) + { + throw new InvalidOperationException("AutoPlanPath 返回空结果"); + } + + return new + { + sourceRoute = new + { + id = route.Id, + name = route.Name, + pathType = route.PathType.ToString() + }, + generatedRoute = new + { + id = autoRoute.Id, + name = autoRoute.Name, + pathType = autoRoute.PathType.ToString(), + pointCount = autoRoute.Points?.Count ?? 0, + length = autoRoute.TotalLength + }, + planningParameters = new + { + gridSizeInMeters = gridSize, + objectHeightInMeters, + objectLengthInMeters, + objectWidthInMeters, + objectRadiusInMeters, + safetyMarginInMeters, + strategy = strategy.ToString() + }, + segmentValidation = ValidateRouteSegmentsAgainstGridMap(autoRoute, autoRoute.AssociatedGridMap) + }; + } + + private static object ValidateRouteSegmentsAgainstGridMap(PathRoute route, GridMap gridMap) + { + if (route?.Points == null || route.Points.Count < 2 || gridMap == null) + { + return new + { + isAvailable = false, + segmentCount = 0, + sampleCount = 0, + invalidSampleCount = 0, + blockedSampleCount = 0, + failures = new object[0] + }; + } + + var orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); + var failures = new List(); + int sampleCount = 0; + int invalidSampleCount = 0; + int blockedSampleCount = 0; + int blockedCellInteriorIntersectionCount = 0; + double sampleSpacing = Math.Max(gridMap.CellSize * 0.25, 1e-6); + + for (int segmentIndex = 0; segmentIndex < orderedPoints.Count - 1; segmentIndex++) + { + Point3D start = orderedPoints[segmentIndex].Position; + Point3D end = orderedPoints[segmentIndex + 1].Position; + double segmentLength = CalculateHostHorizontalDistance(start, end, gridMap.CoordinateSystemType); + int samples = Math.Max(1, (int)Math.Ceiling(segmentLength / sampleSpacing)); + + for (int sampleIndex = 0; sampleIndex <= samples; sampleIndex++) + { + double t = samples == 0 ? 0.0 : (double)sampleIndex / samples; + Point3D samplePoint = InterpolateHostPoint(start, end, t); + double sampleElevation = gridMap.GetWorldElevation(samplePoint); + GridPoint2D gridPosition = gridMap.WorldToGrid(samplePoint); + sampleCount++; + + if (!gridMap.IsValidGridPosition(gridPosition)) + { + invalidSampleCount++; + AddFailure(failures, segmentIndex, sampleIndex, samplePoint, gridPosition, "GridOutOfRange"); + continue; + } + + if (!gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1)) + { + blockedSampleCount++; + AddFailure(failures, segmentIndex, sampleIndex, samplePoint, gridPosition, "NotPassableAtElevation"); + } + } + + blockedCellInteriorIntersectionCount += CountBlockedCellInteriorIntersections( + start, + end, + gridMap, + segmentIndex, + failures); + } + + return new + { + isAvailable = true, + segmentCount = Math.Max(0, orderedPoints.Count - 1), + sampleCount, + invalidSampleCount, + blockedSampleCount, + blockedCellInteriorIntersectionCount, + failures = failures.Take(20).ToList() + }; + } + + private static object BuildRouteGridDiagnosticsPayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + string routeName = GetOptionalQueryValue(query, "routeName"); + List routes = pathManager.GetAllRoutes() ?? new List(); + + PathRoute route = string.IsNullOrWhiteSpace(routeName) + ? pathManager.CurrentRoute + : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); + + if (route == null) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(routeName) + ? "当前没有选中的路径" + : $"找不到指定路径: {routeName}"); + } + + GridMap gridMap = route.AssociatedGridMap; + if (gridMap == null) + { + if (!pathManager.TryRestoreGridMapForRoute(route, out gridMap, out string restoreMessage)) + { + throw new InvalidOperationException($"路径没有关联 GridMap,且无法按保存参数重建,无法诊断: {route.Name}。{restoreMessage}"); + } + } + + var orderedPoints = route.Points?.OrderBy(point => point.Index).ToList() ?? new List(); + var segmentDiagnostics = new List(); + var blockedIntersections = new List(); + int intersectedCellCount = 0; + + for (int segmentIndex = 0; segmentIndex < orderedPoints.Count - 1; segmentIndex++) + { + PathPoint startPathPoint = orderedPoints[segmentIndex]; + PathPoint endPathPoint = orderedPoints[segmentIndex + 1]; + Point3D start = startPathPoint.Position; + Point3D end = endPathPoint.Position; + var intersectedCells = GetSegmentIntersectedCells(start, end, gridMap); + intersectedCellCount += intersectedCells.Count; + + var blockedCells = intersectedCells + .Where(cell => !cell.isPassableAtSegmentElevation) + .ToList(); + + foreach (var blockedCell in blockedCells) + { + if (blockedIntersections.Count < 100) + { + blockedIntersections.Add(new + { + segmentIndex, + startPointIndex = startPathPoint.Index, + endPointIndex = endPathPoint.Index, + cell = blockedCell + }); + } + } + + segmentDiagnostics.Add(new + { + segmentIndex, + startPointIndex = startPathPoint.Index, + endPointIndex = endPathPoint.Index, + startGrid = SerializeGridPosition(gridMap.WorldToGrid(start)), + endGrid = SerializeGridPosition(gridMap.WorldToGrid(end)), + startHorizontal = SerializeHorizontalPoint(start, gridMap), + endHorizontal = SerializeHorizontalPoint(end, gridMap), + intersectedCellCount = intersectedCells.Count, + blockedCellCount = blockedCells.Count, + intersectedCells = intersectedCells.Take(200).ToList() + }); + } + + return new + { + route = new + { + id = route.Id, + name = route.Name, + pathType = route.PathType.ToString(), + pointCount = orderedPoints.Count, + length = route.TotalLength + }, + grid = new + { + coordinateSystemType = gridMap.CoordinateSystemType.ToString(), + width = gridMap.Width, + height = gridMap.Height, + cellSize = gridMap.CellSize, + cellSizeInMeters = UnitsConverter.ConvertToMeters(gridMap.CellSize), + origin = SerializePoint3D(gridMap.Origin), + originHorizontal = SerializeHorizontalPoint(gridMap.Origin, gridMap) + }, + points = orderedPoints.Select(point => SerializePathPointWithGrid(point, gridMap)).ToList(), + segmentCount = Math.Max(0, orderedPoints.Count - 1), + intersectedCellCount, + blockedIntersectionCount = blockedIntersections.Count, + blockedIntersections, + segments = segmentDiagnostics + }; + } + + private static double CalculateHostHorizontalDistance(Point3D start, Point3D end, CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var startHorizontal = HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(start), adapter); + var endHorizontal = HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(end), adapter); + double dh1 = endHorizontal.h1 - startHorizontal.h1; + double dh2 = endHorizontal.h2 - startHorizontal.h2; + return Math.Sqrt(dh1 * dh1 + dh2 * dh2); + } + + private static Point3D InterpolateHostPoint(Point3D start, Point3D end, double t) + { + return new Point3D( + start.X + t * (end.X - start.X), + start.Y + t * (end.Y - start.Y), + start.Z + t * (end.Z - start.Z)); + } + + private static System.Numerics.Vector3 ToVector3(Point3D point) + { + return new System.Numerics.Vector3((float)point.X, (float)point.Y, (float)point.Z); + } + + private static int CountBlockedCellInteriorIntersections( + Point3D start, + Point3D end, + GridMap gridMap, + int segmentIndex, + List failures) + { + var startHorizontal = GetHostHorizontalCoords(start, gridMap); + var endHorizontal = GetHostHorizontalCoords(end, gridMap); + var originHorizontal = GetHostHorizontalCoords(gridMap.Origin, gridMap); + + int minGridX = (int)Math.Round((Math.Min(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) - 1; + int maxGridX = (int)Math.Round((Math.Max(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) + 1; + int minGridY = (int)Math.Round((Math.Min(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) - 1; + int maxGridY = (int)Math.Round((Math.Max(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) + 1; + + minGridX = Math.Max(0, minGridX); + minGridY = Math.Max(0, minGridY); + maxGridX = Math.Min(gridMap.Width - 1, maxGridX); + maxGridY = Math.Min(gridMap.Height - 1, maxGridY); + + int blockedIntersections = 0; + + for (int x = minGridX; x <= maxGridX; x++) + { + for (int y = minGridY; y <= maxGridY; y++) + { + var gridPosition = new GridPoint2D(x, y); + + if (!gridMap.TryGetSegmentGridCellInteriorIntersection(start, end, gridPosition, out double enterT, out double exitT)) + { + continue; + } + + double sampleT = Math.Max(0.0, Math.Min(1.0, (enterT + exitT) * 0.5)); + Point3D samplePoint = InterpolateHostPoint(start, end, sampleT); + double sampleElevation = gridMap.GetWorldElevation(samplePoint); + + if (!gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1)) + { + blockedIntersections++; + AddFailure( + failures, + segmentIndex, + -1, + samplePoint, + gridPosition, + $"BlockedCellInteriorIntersection:t=[{enterT:F6},{exitT:F6}]"); + } + } + } + + return blockedIntersections; + } + + private static List GetSegmentIntersectedCells(Point3D start, Point3D end, GridMap gridMap) + { + var startHorizontal = GetHostHorizontalCoords(start, gridMap); + var endHorizontal = GetHostHorizontalCoords(end, gridMap); + var originHorizontal = GetHostHorizontalCoords(gridMap.Origin, gridMap); + + int minGridX = (int)Math.Round((Math.Min(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) - 1; + int maxGridX = (int)Math.Round((Math.Max(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) + 1; + int minGridY = (int)Math.Round((Math.Min(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) - 1; + int maxGridY = (int)Math.Round((Math.Max(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) + 1; + + minGridX = Math.Max(0, minGridX); + minGridY = Math.Max(0, minGridY); + maxGridX = Math.Min(gridMap.Width - 1, maxGridX); + maxGridY = Math.Min(gridMap.Height - 1, maxGridY); + + var result = new List(); + + for (int x = minGridX; x <= maxGridX; x++) + { + for (int y = minGridY; y <= maxGridY; y++) + { + var gridPosition = new GridPoint2D(x, y); + + if (!gridMap.TryGetSegmentGridCellInteriorIntersection(start, end, gridPosition, out double enterT, out double exitT)) + { + continue; + } + + double sampleT = Math.Max(0.0, Math.Min(1.0, (enterT + exitT) * 0.5)); + Point3D samplePoint = InterpolateHostPoint(start, end, sampleT); + double sampleElevation = gridMap.GetWorldElevation(samplePoint); + GridCell cell = gridMap.Cells[x, y]; + var bounds = gridMap.GetGridCellPlanarBounds(gridPosition); + bool isPassableAtSegmentElevation = gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1); + + result.Add(new + { + grid = SerializeGridPosition(gridPosition), + cellType = cell.CellType, + hasAnyWalkableLayer = cell.HasAnyWalkableLayer(), + isPassableAtSegmentElevation, + sampleT, + enterT, + exitT, + sampleElevation, + samplePoint = SerializePoint3D(samplePoint), + center = SerializePoint3D(gridMap.GridToWorld2D(gridPosition)), + planarBounds = new + { + bounds.minH1, + bounds.maxH1, + bounds.minH2, + bounds.maxH2 + }, + heightLayers = SerializeHeightLayers(cell) + }); + } + } + + return result + .OrderBy(cell => cell.grid.x) + .ThenBy(cell => cell.grid.y) + .ToList(); + } + + private static (double h1, double h2) GetHostHorizontalCoords(Point3D point, GridMap gridMap) + { + var adapter = new HostCoordinateAdapter(gridMap.CoordinateSystemType); + return HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(point), adapter); + } + + private static object SerializeGridPosition(GridPoint2D gridPosition) + { + return new + { + x = gridPosition.X, + y = gridPosition.Y + }; + } + + private static object SerializeHorizontalPoint(Point3D point, GridMap gridMap) + { + var horizontal = GetHostHorizontalCoords(point, gridMap); + return new + { + h1 = horizontal.h1, + h2 = horizontal.h2, + elevation = gridMap.GetWorldElevation(point) + }; + } + + private static object SerializePathPointWithGrid(PathPoint point, GridMap gridMap) + { + GridPoint2D gridPosition = gridMap.WorldToGrid(point.Position); + return new + { + id = point.Id, + name = point.Name, + type = point.Type.ToString(), + index = point.Index, + position = SerializePoint3D(point.Position), + horizontal = SerializeHorizontalPoint(point.Position, gridMap), + grid = SerializeGridPosition(gridPosition), + cell = SerializeGridCell(gridMap, gridPosition, point.Position) + }; + } + + private static object SerializeGridCell(GridMap gridMap, GridPoint2D gridPosition, Point3D referencePoint) + { + if (!gridMap.IsValidGridPosition(gridPosition)) + { + return new + { + isValid = false + }; + } + + GridCell cell = gridMap.Cells[gridPosition.X, gridPosition.Y]; + var bounds = gridMap.GetGridCellPlanarBounds(gridPosition); + double elevation = gridMap.GetWorldElevation(referencePoint); + return new + { + isValid = true, + cellType = cell.CellType, + hasAnyWalkableLayer = cell.HasAnyWalkableLayer(), + isPassableAtReferenceElevation = gridMap.IsPassableAtElevation(gridPosition, elevation, 0.1), + planarBounds = new + { + bounds.minH1, + bounds.maxH1, + bounds.minH2, + bounds.maxH2 + }, + heightLayers = SerializeHeightLayers(cell) + }; + } + + private static object SerializeHeightLayers(GridCell cell) + { + return cell.HeightLayers? + .Select(layer => new + { + z = layer.Z, + passableMin = layer.PassableHeight.MinZ, + passableMax = layer.PassableHeight.MaxZ, + isWalkable = layer.IsWalkable, + isBoundary = layer.IsBoundary, + type = layer.Type + }) + .ToList(); + } + + private static void AddFailure( + List failures, + int segmentIndex, + int sampleIndex, + Point3D samplePoint, + GridPoint2D gridPosition, + string reason) + { + if (failures.Count >= 20) + { + return; + } + + failures.Add(new + { + segmentIndex, + sampleIndex, + reason, + point = SerializePoint3D(samplePoint), + grid = new + { + x = gridPosition.X, + y = gridPosition.Y + } + }); + } + + private static PathPlanningManager RequirePathManager() + { + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + if (pathManager == null) + { + throw new InvalidOperationException("PathPlanningManager 当前不可用"); + } + + return pathManager; + } + + private static string GetRequiredQueryValue(Dictionary query, string key) + { + string value = GetOptionalQueryValue(query, key); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"缺少必填参数: {key}"); + } + + return value; + } + + private static string GetOptionalQueryValue(Dictionary query, string key) + { + if (query == null || string.IsNullOrWhiteSpace(key)) + { + return null; + } + + query.TryGetValue(key, out string value); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static double ParsePositiveDoubleQuery(Dictionary query, string key, double defaultValue) + { + double value = ParseDoubleQuery(query, key, defaultValue); + if (value <= 0) + { + throw new InvalidOperationException($"参数 {key} 必须大于 0,当前值: {value}"); + } + + return value; + } + + private static double ParseNonNegativeDoubleQuery(Dictionary query, string key, double defaultValue) + { + double value = ParseDoubleQuery(query, key, defaultValue); + if (value < 0) + { + throw new InvalidOperationException($"参数 {key} 不能小于 0,当前值: {value}"); + } + + return value; + } + + private static double ParseDoubleQuery(Dictionary query, string key, double defaultValue) + { + string raw = GetOptionalQueryValue(query, key); + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + { + throw new InvalidOperationException($"无法解析数值参数 {key}: {raw}"); + } + + return value; + } + + private static RequestTarget ParseRequestTarget(string requestTarget) + { + string path = requestTarget ?? "/"; + var query = new Dictionary(StringComparer.OrdinalIgnoreCase); + + int queryStartIndex = path.IndexOf('?'); + if (queryStartIndex >= 0) + { + string queryString = path.Substring(queryStartIndex + 1); + path = path.Substring(0, queryStartIndex); + + foreach (string pair in queryString.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + string[] kv = pair.Split(new[] { '=' }, 2); + string key = Uri.UnescapeDataString(kv[0] ?? string.Empty); + string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1] ?? string.Empty) : string.Empty; + if (!string.IsNullOrWhiteSpace(key)) + { + query[key] = value; + } + } + } + + return new RequestTarget + { + Path = string.IsNullOrWhiteSpace(path) ? "/" : path, + Query = query + }; + } + + private sealed class RequestTarget + { + public string Path { get; set; } + + public Dictionary Query { get; set; } + } + + private static object ResolveTrackedState(PathAnimationManager animationManager, ModelItem controlledObject) + { + if (animationManager == null || controlledObject == null || !animationManager.ControlsAnimatedObject(controlledObject)) + { + return new + { + isAvailable = false, + reason = animationManager == null ? "PathAnimationManagerUnavailable" : "ControlledObjectUnavailable" + }; + } + + var currentState = animationManager.GetObjectCurrentPosition(controlledObject); + return new + { + isAvailable = true, + position = SerializePoint3D(currentState.Position), + yawRadians = currentState.Yaw, + yawDegrees = currentState.Yaw * 180.0 / Math.PI, + hasTrackedRotation = animationManager.HasTrackedRotation, + trackedRotation = animationManager.HasTrackedRotation + ? SerializeRotation3D(animationManager.TrackedRotation) + : null + }; + } + + private static object ResolveGeometryState(ModelItem controlledObject) + { + if (controlledObject == null) + { + return new + { + isAvailable = false, + reason = "ControlledObjectUnavailable" + }; + } + + bool hasGeometryRotation = ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out Rotation3D geometryRotation); + bool hasOverrideRotation = ModelItemTransformHelper.TryGetCurrentOverrideRotation(controlledObject, out Rotation3D overrideRotation); + + return new + { + isAvailable = true, + geometryRotation = hasGeometryRotation ? SerializeRotation3D(geometryRotation) : null, + overrideRotation = hasOverrideRotation ? SerializeRotation3D(overrideRotation) : null + }; + } + + private static object ResolveBoundingBoxState(ModelItem controlledObject) + { + if (controlledObject == null) + { + return new + { + isAvailable = false, + reason = "ControlledObjectUnavailable" + }; + } + + BoundingBox3D boundingBox = controlledObject.BoundingBox(); + return new + { + isAvailable = true, + min = SerializePoint3D(boundingBox.Min), + max = SerializePoint3D(boundingBox.Max), + center = SerializePoint3D(boundingBox.Center) + }; + } + + private static object SerializePoint3D(Point3D point) + { + if (point == null) + { + return null; + } + + return new + { + x = point.X, + y = point.Y, + z = point.Z + }; + } + + private static object SerializePathPoint(PathPoint point) + { + if (point == null) + { + return null; + } + + return new + { + id = point.Id, + name = point.Name, + type = point.Type.ToString(), + index = point.Index, + position = SerializePoint3D(point.Position) + }; + } + + private static object SerializeGridStats(GridMap gridMap) + { + if (gridMap == null) + { + return null; + } + + int walkableCellCount = 0; + int nonWalkableCellCount = 0; + int obstacleCellCount = 0; + int holeCellCount = 0; + int boundaryLayerCount = 0; + + for (int x = 0; x < gridMap.Width; x++) + { + for (int y = 0; y < gridMap.Height; y++) + { + GridCell cell = gridMap.Cells[x, y]; + bool isWalkable = cell.HasAnyWalkableLayer(); + if (isWalkable) + { + walkableCellCount++; + } + else + { + nonWalkableCellCount++; + } + + if (string.Equals(cell.CellType, "障碍物", StringComparison.Ordinal)) + { + obstacleCellCount++; + } + + if (string.Equals(cell.CellType, "空洞", StringComparison.Ordinal)) + { + holeCellCount++; + } + + if (cell.HeightLayers != null) + { + boundaryLayerCount += cell.HeightLayers.Count(layer => layer.IsBoundary); + } + } + } + + int totalCellCount = gridMap.Width * gridMap.Height; + + return new + { + width = gridMap.Width, + height = gridMap.Height, + totalCellCount, + walkableCellCount, + nonWalkableCellCount, + obstacleCellCount, + holeCellCount, + boundaryLayerCount, + obstacleRatio = totalCellCount == 0 ? 0.0 : (double)obstacleCellCount / totalCellCount, + nonWalkableRatio = totalCellCount == 0 ? 0.0 : (double)nonWalkableCellCount / totalCellCount + }; + } + + private static object BuildObstacleDiagnostics(GridMapGenerator gridMapGenerator, GridMap gridMap, double objectHeightInMeters, double safetyMarginInMeters) + { + if (gridMapGenerator == null || gridMap == null) + { + return null; + } + + double metersToModelUnits = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); + double scanHeightInModelUnits = (objectHeightInMeters + safetyMarginInMeters) * metersToModelUnits; + + var traversableItems = CategoryAttributeManager.GetAllTraversableLogisticsItems().ToList(); + var irrelevantItems = CategoryAttributeManager.GetLogisticsItemsByType("无关项"); + + var collectRelatedItemsMethod = typeof(GridMapGenerator).GetMethod( + "CollectRelatedItems", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var postProcessGeometryItemsMethod = typeof(GridMapGenerator).GetMethod( + "PostProcessGeometryItems", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var getObstacleElevationRangeMethod = typeof(GridMapGenerator).GetMethod( + "GetObstacleElevationRange", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + new[] { typeof(Point3D), typeof(Point3D), typeof(GridMap) }, + null); + var calculateBoundingBoxGridCoverageMethod = typeof(GridMapGenerator).GetMethod( + "CalculateBoundingBoxGridCoverage", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + var traversableRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { traversableItems }); + var irrelevantRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { irrelevantItems }); + var geometryItems = ModelItemAnalysisHelper.GetAllVisibleGeometryItems(); + var itemCache = (Dictionary)postProcessGeometryItemsMethod.Invoke( + gridMapGenerator, + new object[] { geometryItems, traversableRelatedItems, irrelevantRelatedItems }); + + var validItems = itemCache + .Where(kvp => kvp.Value.HasGeometry && + !kvp.Value.IsContainer && + !kvp.Value.IsChannelItem && + !kvp.Value.IsChildOfChannel && + kvp.Value.BoundingBox != null && + kvp.Value.IsInScanHeightRange) + .ToList(); + + double walkableMinElevation = double.MaxValue; + double walkableMaxElevation = double.MinValue; + int walkableGridCount = 0; + for (int x = 0; x < gridMap.Width; x++) + { + for (int y = 0; y < gridMap.Height; y++) + { + GridCell cell = gridMap.Cells[x, y]; + if (!cell.HasAnyWalkableLayer() || cell.HeightLayers == null || cell.HeightLayers.Count == 0) + { + continue; + } + + walkableGridCount++; + double cellElevation = cell.HeightLayers[0].Z; + walkableMinElevation = Math.Min(walkableMinElevation, cellElevation); + walkableMaxElevation = Math.Max(walkableMaxElevation, cellElevation); + } + } + + if (walkableGridCount == 0) + { + walkableMinElevation = 0.0; + walkableMaxElevation = 0.0; + } + + double scanMin = walkableMinElevation; + double scanMax = walkableMaxElevation + scanHeightInModelUnits; + + var keptItems = new List(); + var filteredSampleItems = new List(); + int filteredByHeightCount = 0; + + foreach (var kvp in validItems) + { + BoundingBox3D bbox = kvp.Value.BoundingBox; + var elevationRange = ((double min, double max))getObstacleElevationRangeMethod.Invoke( + gridMapGenerator, + new object[] { bbox.Min, bbox.Max, gridMap }); + + bool isInRange = !(elevationRange.max <= scanMin || elevationRange.min > scanMax); + if (!isInRange) + { + filteredByHeightCount++; + if (filteredSampleItems.Count < 10) + { + filteredSampleItems.Add(new + { + displayName = kvp.Key.DisplayName, + bboxMin = SerializePoint3D(bbox.Min), + bboxMax = SerializePoint3D(bbox.Max), + minElevation = elevationRange.min, + maxElevation = elevationRange.max + }); + } + + continue; + } + + if (keptItems.Count < 30) + { + var coveredCells = (List<(int x, int y)>)calculateBoundingBoxGridCoverageMethod.Invoke( + gridMapGenerator, + new object[] { bbox, gridMap }); + + object triangleBounds = keptItems.Count < 8 + ? SerializeTriangleBounds(kvp.Key) + : null; + + keptItems.Add(new + { + displayName = kvp.Key.DisplayName, + bboxMin = SerializePoint3D(bbox.Min), + bboxMax = SerializePoint3D(bbox.Max), + triangleBounds, + minElevation = elevationRange.min, + maxElevation = elevationRange.max, + coveredCellCount = coveredCells.Count + }); + } + } + + return new + { + geometryItemCount = geometryItems.Count, + traversableItemCount = traversableItems.Count, + traversableRelatedItemCount = traversableRelatedItems.Count, + irrelevantItemCount = irrelevantItems.Count, + irrelevantRelatedItemCount = irrelevantRelatedItems.Count, + postProcessedItemCount = itemCache.Count, + validItemCount = validItems.Count, + walkableGridCount, + walkableMinElevation, + walkableMaxElevation, + scanHeightInModelUnits, + scanMin, + scanMax, + filteredByHeightCount, + keptItemSample = keptItems, + filteredByHeightSample = filteredSampleItems + }; + } + + private static object SerializeTriangleBounds(ModelItem item) + { + if (item == null) + { + return null; + } + + List triangles = GeometryHelper.ExtractTriangles(new[] { item }); + if (triangles == null || triangles.Count == 0) + { + return null; + } + + double minX = triangles.Min(t => Math.Min(t.Point1.X, Math.Min(t.Point2.X, t.Point3.X))); + double minY = triangles.Min(t => Math.Min(t.Point1.Y, Math.Min(t.Point2.Y, t.Point3.Y))); + double minZ = triangles.Min(t => Math.Min(t.Point1.Z, Math.Min(t.Point2.Z, t.Point3.Z))); + double maxX = triangles.Max(t => Math.Max(t.Point1.X, Math.Max(t.Point2.X, t.Point3.X))); + double maxY = triangles.Max(t => Math.Max(t.Point1.Y, Math.Max(t.Point2.Y, t.Point3.Y))); + double maxZ = triangles.Max(t => Math.Max(t.Point1.Z, Math.Max(t.Point2.Z, t.Point3.Z))); + + return new + { + min = SerializePoint3D(new Point3D(minX, minY, minZ)), + max = SerializePoint3D(new Point3D(maxX, maxY, maxZ)) + }; + } + + private static T InvokePathManagerPrivate(PathPlanningManager pathManager, string methodName, params object[] args) + { + var method = typeof(PathPlanningManager).GetMethod( + methodName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + if (method == null) + { + throw new MissingMethodException(typeof(PathPlanningManager).FullName, methodName); + } + + object result = method.Invoke(pathManager, args); + if (result == null) + { + return default(T); + } + + return (T)result; + } + + private static object SerializeRotation3D(Rotation3D rotation) + { + var linear = new Transform3D(rotation).Linear; + return new + { + quaternion = new + { + x = rotation.A, + y = rotation.B, + z = rotation.C, + w = rotation.D + }, + axes = new + { + hostX = new + { + x = linear.Get(0, 0), + y = linear.Get(1, 0), + z = linear.Get(2, 0) + }, + hostY = new + { + x = linear.Get(0, 1), + y = linear.Get(1, 1), + z = linear.Get(2, 1) + }, + hostZ = new + { + x = linear.Get(0, 2), + y = linear.Get(1, 2), + z = linear.Get(2, 2) + } + } + }; + } + + private static string WriteSnapshotToDisk(object snapshotPayload) + { + string logDirectory = Path.GetDirectoryName(LogManager.LogFilePath); + string snapshotDirectory = Path.Combine(logDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "test-automation"); + Directory.CreateDirectory(snapshotDirectory); + + string fileName = string.Format( + CultureInfo.InvariantCulture, + "debug-snapshot-{0:yyyyMMdd-HHmmss-fff}.json", + DateTime.Now); + string fullPath = Path.Combine(snapshotDirectory, fileName); + string json = JsonConvert.SerializeObject(snapshotPayload, Formatting.Indented); + File.WriteAllText(fullPath, json, new UTF8Encoding(false)); + + LogManager.Info($"[测试HTTP] 已导出调试快照: {fullPath}"); + return fullPath; + } + + private static AnimationControlViewModel RequireAnimationControlViewModel() + { + var animationViewModel = AnimationControlViewModel.Instance; + if (animationViewModel == null) + { + throw new InvalidOperationException("AnimationControlViewModel 当前不可用,请先打开插件动画页签"); + } + + return animationViewModel; + } + + private static PreparedVirtualCollisionTest PrepareVirtualCollisionTest(Dictionary query, PathType? fixedPathType) + { + PathPlanningManager pathManager = RequirePathManager(); + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + animationViewModel.SetPathPlanningManager(pathManager); + + PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, fixedPathType); + pathManager.SetCurrentRoute(route); + animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); + + animationViewModel.IsManualCollisionTargetEnabled = false; + animationViewModel.UseVirtualObject = true; + object animatedObjectPayload = new + { + mode = "VirtualObject", + displayName = "虚拟物体" + }; + + if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) + { + throw new InvalidOperationException($"{route.PathType} 虚拟物体碰撞测试准备失败:当前条件下不能生成动画"); + } + + animationViewModel.GenerateAnimationCommand.Execute(null); + + LogManager.Info( + $"[测试HTTP] 已开始准备虚拟物体碰撞测试: 路径={route.Name}, 类型={route.PathType}"); + + return new PreparedVirtualCollisionTest + { + pathType = route.PathType, + route = SerializeRoute(route, route), + animatedObject = animatedObjectPayload + }; + } + + private static void StartPreparedAnimation() + { + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + if (animationManager == null) + { + throw new InvalidOperationException("PathAnimationManager 当前不可用,无法开始播放"); + } + + if (animationManager.CurrentState == AnimationState.Paused) + { + animationManager.ResumeAnimation(); + LogManager.Info("[测试HTTP] 已从暂停状态恢复自动测试动画播放"); + return; + } + + if (animationManager.IsAnimating) + { + LogManager.Info("[测试HTTP] 动画已经在播放中,跳过重复开始"); + return; + } + + if (animationManager.CurrentState != AnimationState.Ready && + animationManager.CurrentState != AnimationState.Finished) + { + throw new InvalidOperationException($"动画尚未就绪,当前状态={animationManager.CurrentState}"); + } + + animationManager.ClearExclusionCache(); + ModelHighlightHelper.ClearCollisionHighlights(); + animationManager.StartAnimation(); + LogManager.Info("[测试HTTP] 已启动自动测试动画播放"); + } + + private static PathRoute ResolveVirtualCollisionRoute(PathPlanningManager pathManager, Dictionary query, PathType? fixedPathType) + { + string routeName = GetOptionalQueryValue(query, "routeName"); + string pathTypeText = fixedPathType.HasValue + ? fixedPathType.Value.ToString() + : GetOptionalQueryValue(query, "pathType"); + List routes = pathManager.GetAllRoutes() ?? new List(); + PathType pathType = fixedPathType ?? ParsePathTypeOrThrow(pathTypeText, "pathType"); + + string routePrefix = GetOptionalQueryValue(query, "routePrefix") + ?? GetOptionalQueryValue(query, "prefix") + ?? DefaultAutoTestRoutePrefix; + + PathRoute route = string.IsNullOrWhiteSpace(routeName) + ? TryFindAutoTestRoute(routes, pathType, routePrefix) + : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); + + if (route == null) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(routeName) + ? $"找不到默认自动测试路径: prefix={routePrefix}, pathType={pathType}" + : $"找不到指定路径: {routeName}"); + } + + if (route.PathType != pathType) + { + throw new InvalidOperationException($"指定路径类型不匹配: 期望={pathType}, 实际={route.PathType}, 路径={route.Name}"); + } + + return route; + } + + private static PathType ParsePathTypeOrThrow(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"缺少必填参数: {parameterName}"); + } + + if (!Enum.TryParse(value, true, out PathType pathType)) + { + throw new InvalidOperationException($"无法解析路径类型: {value}"); + } + + return pathType; + } + + private static ModelItem ResolveAnimatedObjectFromQuery(Dictionary query) + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (activeDocument == null) + { + throw new InvalidOperationException("当前没有活动文档,无法选择真实物体"); + } + + string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName"); + if (string.IsNullOrWhiteSpace(animatedObjectName)) + { + return ResolveSingleSelectedItem(activeDocument); + } + + var matches = new List(); + foreach (Model model in activeDocument.Models) + { + if (model?.RootItem == null) + { + continue; + } + + foreach (ModelItem item in model.RootItem.DescendantsAndSelf) + { + if (item != null && string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + } + } + } + + if (matches.Count == 0) + { + throw new InvalidOperationException($"找不到指定真实物体: {animatedObjectName}"); + } + + if (matches.Count > 1) + { + throw new InvalidOperationException($"找到多个同名真实物体,请改用更唯一的名字: {animatedObjectName}"); + } + + return matches[0]; + } + + private static ModelItem ResolveSingleSelectedItem(Document activeDocument) + { + var selectedItems = activeDocument.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() + ?? new List(); + + if (selectedItems.Count != 1) + { + throw new InvalidOperationException($"当前选择集必须且只能包含 1 个真实物体,当前数量: {selectedItems.Count}"); + } + + return selectedItems[0]; + } + + private static void SelectDocumentItem(ModelItem item) + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (activeDocument?.CurrentSelection == null) + { + throw new InvalidOperationException("当前文档选择集不可用"); + } + + activeDocument.CurrentSelection.Clear(); + activeDocument.CurrentSelection.Add(item); + } + + private static PathRouteViewModel CreatePathRouteViewModel(PathRoute route) + { + var routeViewModel = new PathRouteViewModel(isFromDatabase: true) + { + Route = route, + IsActive = true + }; + + foreach (var point in route.Points.OrderBy(p => p.Index)) + { + routeViewModel.Points.Add(new PathPointViewModel + { + Id = point.Id, + Name = point.Name, + Type = point.Type, + Index = point.Index, + X = point.X, + Y = point.Y, + Z = point.Z + }); + } + + routeViewModel.SetTimeInfo(route.CreatedTime, route.LastModified); + return routeViewModel; + } + + private static int ParseTimeoutSeconds(Dictionary query, int defaultSeconds) + { + string raw = GetOptionalQueryValue(query, "timeoutSeconds"); + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultSeconds; + } + + if (!int.TryParse(raw, out int parsedSeconds) || parsedSeconds <= 0) + { + throw new InvalidOperationException($"无法解析 timeoutSeconds: {raw}"); + } + + return parsedSeconds; + } + + private static bool ParseBooleanQuery(Dictionary query, string key, bool defaultValue) + { + string raw = GetOptionalQueryValue(query, key); + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + if (bool.TryParse(raw, out bool parsed)) + { + return parsed; + } + + if (string.Equals(raw, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "y", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "n", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + throw new InvalidOperationException($"无法解析布尔参数 {key}: {raw}"); + } + + private static IDisposable EnableAutoConfirmCollisionAnalysisDialogs() + { + Interlocked.Increment(ref _autoConfirmCollisionAnalysisDialogRequestCount); + return new ActionOnDispose(() => + { + if (Interlocked.Decrement(ref _autoConfirmCollisionAnalysisDialogRequestCount) < 0) + { + Interlocked.Exchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0); + } + }); + } + + private static IDisposable EnableAutoChooseCreateNewDetectionRecord() + { + Interlocked.Increment(ref _autoChooseCreateNewDetectionRecordRequestCount); + return new ActionOnDispose(() => + { + if (Interlocked.Decrement(ref _autoChooseCreateNewDetectionRecordRequestCount) < 0) + { + Interlocked.Exchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0); + } + }); + } + + private static async Task WaitForConditionAsync(DateTime deadlineUtc, Func condition, string timeoutMessage) + { + while (DateTime.UtcNow < deadlineUtc) + { + if (condition()) + { + return; + } + + await Task.Delay(300).ConfigureAwait(false); + } + + throw new TimeoutException(timeoutMessage); + } + + private sealed class PreparedVirtualCollisionTest + { + public PathType pathType { get; set; } + + public object route { get; set; } + + public object animatedObject { get; set; } + } + + private sealed class ActionOnDispose : IDisposable + { + private Action _disposeAction; + + public ActionOnDispose(Action disposeAction) + { + _disposeAction = disposeAction; + } + + public void Dispose() + { + Action disposeAction = Interlocked.Exchange(ref _disposeAction, null); + disposeAction?.Invoke(); + } + } + + private static async Task WriteJsonResponseAsync(StreamWriter writer, int statusCode, object payload) + { + string statusText = ResolveStatusText(statusCode); + string json = JsonConvert.SerializeObject(payload, Formatting.Indented); + byte[] bodyBytes = Encoding.UTF8.GetBytes(json); + + await writer.WriteLineAsync($"HTTP/1.1 {statusCode} {statusText}").ConfigureAwait(false); + await writer.WriteLineAsync("Content-Type: application/json; charset=utf-8").ConfigureAwait(false); + await writer.WriteLineAsync($"Content-Length: {bodyBytes.Length}").ConfigureAwait(false); + await writer.WriteLineAsync("Connection: close").ConfigureAwait(false); + await writer.WriteLineAsync().ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + + Stream baseStream = writer.BaseStream; + await baseStream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false); + await baseStream.FlushAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + private static string ResolveStatusText(int statusCode) + { + switch (statusCode) + { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 404: + return "Not Found"; + case 405: + return "Method Not Allowed"; + case 500: + return "Internal Server Error"; + default: + return "OK"; + } + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/Core/UIStateManager.cs b/src/Core/UIStateManager.cs index 6fe44f6..826dd0b 100644 --- a/src/Core/UIStateManager.cs +++ b/src/Core/UIStateManager.cs @@ -778,46 +778,51 @@ namespace NavisworksTransport.Core try { - var operations = new List(); - - // 收集当前队列中的所有操作 - while (_updateQueue.TryDequeue(out var operation)) + while (true) { - operations.Add(operation); - } + var operations = new List(); - if (operations.Count == 0) - { - return; - } + // 收集当前队列中的所有操作 + while (_updateQueue.TryDequeue(out var operation)) + { + operations.Add(operation); + } - // 按优先级排序 - operations = operations.OrderByDescending(op => op.Priority).ToList(); + if (operations.Count == 0) + { + break; + } - LogManager.Info($"开始处理{operations.Count}个排队的UI更新操作,强制同步: {forcedSync}"); + // 按优先级排序 + operations = operations.OrderByDescending(op => op.Priority).ToList(); - // 批量执行操作 - var actions = operations.Select(op => op.Operation).ToArray(); - - if (forcedSync) - { - // 使用同步版本的批量更新 - LogManager.Info("使用同步方式处理UI更新队列"); - ExecuteBatchUIUpdateSync(actions); - LogManager.Info($"同步处理{operations.Count}个UI更新操作完成"); - } - else - { - // 使用异步版本的批量更新 - LogManager.Info("使用异步方式处理UI更新队列"); - await ExecuteBatchUIUpdate(actions); - LogManager.Info($"异步处理{operations.Count}个UI更新操作完成"); - } - - // 处理完成后,如果队列为空,停止保底定时器 - if (_updateQueue.IsEmpty) - { - StopIdleProcessor(); + LogManager.Info($"开始处理{operations.Count}个排队的UI更新操作,强制同步: {forcedSync}"); + + // 批量执行操作 + var actions = operations.Select(op => op.Operation).ToArray(); + + if (forcedSync) + { + // 使用同步版本的批量更新 + LogManager.Info("使用同步方式处理UI更新队列"); + ExecuteBatchUIUpdateSync(actions); + LogManager.Info($"同步处理{operations.Count}个UI更新操作完成"); + } + else + { + // 使用异步版本的批量更新 + LogManager.Info("使用异步方式处理UI更新队列"); + await ExecuteBatchUIUpdate(actions); + LogManager.Info($"异步处理{operations.Count}个UI更新操作完成"); + } + + if (_updateQueue.IsEmpty) + { + StopIdleProcessor(); + break; + } + + LogManager.Info($"检测到执行过程中新增 {_updateQueue.Count} 个UI更新操作,继续处理后续批次"); } } catch (Exception ex) @@ -952,4 +957,4 @@ namespace NavisworksTransport.Core } #endregion -} \ No newline at end of file +} diff --git a/src/Core/VirtualObjectManager.cs b/src/Core/VirtualObjectManager.cs index 4152169..f30096a 100644 --- a/src/Core/VirtualObjectManager.cs +++ b/src/Core/VirtualObjectManager.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using Autodesk.Navisworks.Api; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.Core { @@ -17,6 +18,7 @@ namespace NavisworksTransport.Core /// public class VirtualObjectManager { + private static readonly ModelAxisConvention VirtualObjectAxisConvention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); private static VirtualObjectManager _instance; private static readonly object _lock = new object(); @@ -92,7 +94,6 @@ namespace NavisworksTransport.Core var doc = Application.ActiveDocument; var unitCubePath = GetUnitCubeFilePath(); - if (string.IsNullOrEmpty(unitCubePath) || !File.Exists(unitCubePath)) { throw new FileNotFoundException($"找不到单位立方体文件: {unitCubePath}"); @@ -119,6 +120,8 @@ namespace NavisworksTransport.Core _virtualObjectModelItem = geometryItem; } + LogVirtualObjectGeometry("[虚拟物体创建] Append后缩放前"); + _currentLengthMeters = lengthMeters; _currentWidthMeters = widthMeters; _currentHeightMeters = heightMeters; @@ -186,62 +189,15 @@ namespace NavisworksTransport.Core try { - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { _virtualObjectModelItem }; - - // 获取当前缩放 - var currentTransform = _virtualObjectModelItem.Transform; - var currentComponents = currentTransform.Factor(); - var currentScale = currentComponents.Scale; - - // 重置到CAD原始状态 - doc.Models.ResetPermanentTransform(modelItems); - - // 获取CAD原始状态 - var originalBounds = _virtualObjectModelItem.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); - var originalYaw = GetYawFromTransform(_virtualObjectModelItem.Transform); - - // 计算增量 - var deltaPos = new Vector3D( - position.X - originalGroundPos.X, - position.Y - originalGroundPos.Y, - position.Z - originalGroundPos.Z - ); - double deltaYaw = yawRadians - originalYaw; - - // 构建变换,保留当前缩放 - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Scale = currentScale; - - if (Math.Abs(deltaYaw) > 0.001) - { - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; - - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = new Vector3D( - position.X - rotatedX, - position.Y - rotatedY, - deltaPos.Z - ); - } - else - { - components.Translation = deltaPos; - } - - Transform3D transform = components.Combine(); - doc.Models.OverridePermanentTransform(modelItems, transform, false); - - LogManager.Debug($"虚拟物体已移动"); + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var hostForward = adapter.FromCanonicalVector(new Vector3D(Math.Cos(yawRadians), Math.Sin(yawRadians), 0)); + var hostUp = adapter.FromCanonicalVector(new Vector3D( + HostCoordinateAdapter.CanonicalUpVector3.X, + HostCoordinateAdapter.CanonicalUpVector3.Y, + HostCoordinateAdapter.CanonicalUpVector3.Z)); + var rotation = VirtualObjectAxisConvention.CreateRotation(hostForward, hostUp); + MoveVirtualObject(position, rotation); + LogManager.Debug($"虚拟物体已按Canonical平面yaw应用完整姿态"); } catch (Exception ex) { @@ -249,6 +205,29 @@ namespace NavisworksTransport.Core } } + public void MoveVirtualObject(Point3D position, Rotation3D rotation) + { + if (_virtualObjectModelItem == null) return; + + try + { + LogVirtualObjectGeometry("[虚拟物体姿态] 应用前"); + ModelItemTransformHelper.MoveItemToPositionAndRotation(_virtualObjectModelItem, position, rotation); + var actualBounds = _virtualObjectModelItem.BoundingBox(); + Point3D actualCenter = actualBounds?.Center ?? new Point3D(0, 0, 0); + LogManager.Debug( + $"[虚拟物体姿态] 应用后中心=({actualCenter.X:F3},{actualCenter.Y:F3},{actualCenter.Z:F3}), " + + $"目标中心=({position.X:F3},{position.Y:F3},{position.Z:F3}), " + + $"偏差=({actualCenter.X - position.X:F3},{actualCenter.Y - position.Y:F3},{actualCenter.Z - position.Z:F3})"); + LogVirtualObjectGeometry("[虚拟物体姿态] 应用后"); + LogManager.Debug("虚拟物体已应用完整三维姿态"); + } + catch (Exception ex) + { + LogManager.Error($"移动虚拟物体并应用完整三维姿态失败: {ex.Message}"); + } + } + public void ResetToCADPosition() { if (_virtualObjectModelItem == null) return; @@ -257,24 +236,8 @@ namespace NavisworksTransport.Core { var doc = Application.ActiveDocument; var modelItems = new ModelItemCollection { _virtualObjectModelItem }; - - // 获取当前缩放 - var currentTransform = _virtualObjectModelItem.Transform; - var currentComponents = currentTransform.Factor(); - var currentScale = currentComponents.Scale; - - // 重置到CAD原始状态 doc.Models.ResetPermanentTransform(modelItems); - - // 重新应用缩放 - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Scale = currentScale; - - Transform3D newTransform = components.Combine(); - doc.Models.OverridePermanentTransform(modelItems, newTransform, false); - - LogManager.Info("虚拟物体已恢复到CAD原始位置"); + LogManager.Info("虚拟物体已恢复到CAD原始位置(仅清除Item姿态,保留Model尺寸)"); } catch (Exception ex) { @@ -297,35 +260,8 @@ namespace NavisworksTransport.Core _currentLengthMeters = lengthMeters; _currentWidthMeters = widthMeters; _currentHeightMeters = heightMeters; - - // 获取当前位置和旋转 - var currentTransform = _virtualObjectModelItem.Transform; - var currentComponents = currentTransform.Factor(); - var currentTranslation = currentComponents.Translation; - var currentRotation = currentComponents.Rotation; - - // 应用新缩放 ScaleVirtualObject(lengthMeters, widthMeters, heightMeters); - - // 如果之前有位置/旋转,重新应用 - bool hasTranslation = currentTranslation.X != 0 || currentTranslation.Y != 0 || currentTranslation.Z != 0; - // 使用 ToAxisAndAngle 检查是否有旋转 - var rotationResult = currentRotation.ToAxisAndAngle(); - bool hasRotation = Math.Abs(rotationResult.Angle) > 0.001; - if (hasTranslation || hasRotation) - { - var newTransform = _virtualObjectModelItem.Transform; - var newComponents = newTransform.Factor(); - newComponents.Translation = currentTranslation; - newComponents.Rotation = currentRotation; - - Transform3D combinedTransform = newComponents.Combine(); - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { _virtualObjectModelItem }; - doc.Models.SetModelUnitsAndTransform(_virtualObjectModel, _virtualObjectModel.Units, combinedTransform, false); - } - - LogManager.Info($"虚拟物体尺寸已更新"); + LogManager.Info("虚拟物体尺寸已更新(仅更新Model尺寸)"); } finally { @@ -342,33 +278,43 @@ namespace NavisworksTransport.Core if (_virtualObjectModel == null || _virtualObjectModelItem == null) return; var doc = Application.ActiveDocument; - double metersToUnits = Utils.UnitsConverter.GetMetersToUnitsConversionFactor(doc.Units); - double baseSizeMeters = 0.01; - double scaleX = lengthMeters / baseSizeMeters; - double scaleY = widthMeters / baseSizeMeters; - double scaleZ = heightMeters / baseSizeMeters; var currentTransform = _virtualObjectModel.Transform; var transformComponents = currentTransform.Factor(); - transformComponents.Scale = new Vector3D(scaleX, scaleY, scaleZ); - + transformComponents.Scale = VirtualObjectAxisConvention.CreateScaleVector( + lengthMeters / baseSizeMeters, + widthMeters / baseSizeMeters, + heightMeters / baseSizeMeters); Transform3D newTransform = transformComponents.Combine(); doc.Models.SetModelUnitsAndTransform(_virtualObjectModel, _virtualObjectModel.Units, newTransform, false); + + LogVirtualObjectGeometry("[虚拟物体缩放] 完成后"); } - /// - /// 从Transform中提取Yaw角度(简化版本) - /// - private double GetYawFromTransform(Transform3D transform) + private void LogVirtualObjectGeometry(string prefix) { + if (_virtualObjectModelItem == null) + { + return; + } + try { - // 使用ModelItemTransformHelper中的方法 - return ModelItemTransformHelper.GetYawFromTransform(transform); + var itemBounds = _virtualObjectModelItem.BoundingBox(); + + if (itemBounds != null) + { + LogManager.Debug( + $"{prefix} Item包围盒: Min=({itemBounds.Min.X:F3},{itemBounds.Min.Y:F3},{itemBounds.Min.Z:F3}), " + + $"Max=({itemBounds.Max.X:F3},{itemBounds.Max.Y:F3},{itemBounds.Max.Z:F3}), " + + $"Center=({itemBounds.Center.X:F3},{itemBounds.Center.Y:F3},{itemBounds.Center.Z:F3})"); + } + } + catch (Exception ex) + { + LogManager.Warning($"{prefix} 记录虚拟物体几何失败: {ex.Message}"); } - catch { } - return 0.0; } /// @@ -418,8 +364,7 @@ namespace NavisworksTransport.Core string[] possiblePaths = new[] { Path.Combine(pluginDir, "resources", "unit_cube.nwc"), - Path.Combine(pluginDir, "..", "..", "resources", "unit_cube.nwc"), - @"c:\Users\Tellme\apps\NavisworksTransport\resources\unit_cube.nwc" + Path.Combine(pluginDir, "..", "..", "resources", "unit_cube.nwc") }; foreach (var path in possiblePaths) diff --git a/src/PathPlanning/AerialPathGenerator.cs b/src/PathPlanning/AerialPathGenerator.cs index 8130002..4867e9b 100644 --- a/src/PathPlanning/AerialPathGenerator.cs +++ b/src/PathPlanning/AerialPathGenerator.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Autodesk.Navisworks.Api; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.PathPlanning { @@ -12,12 +13,24 @@ namespace NavisworksTransport.PathPlanning /// public class AerialPathGenerator { + private readonly HostCoordinateAdapter _adapter; + /// /// 方向判断容差(米) /// 当两个方向的距离差小于此值时,默认选择纵向 /// private const double DIRECTION_TOLERANCE = 0.001; + public AerialPathGenerator() + : this(HostCoordinateAdapter.CreateFromManager()) + { + } + + public AerialPathGenerator(HostCoordinateAdapter adapter) + { + _adapter = adapter ?? throw new ArgumentNullException(nameof(adapter)); + } + /// /// 生成吊装路径(起点吊起 → 平移 → 终点吊下) /// 保留原有方法以保持向后兼容 @@ -49,10 +62,7 @@ namespace NavisworksTransport.PathPlanning pathPoints.Add(startPointPath); // 点2:提升点(起点正上方,达到提升高度) - var liftPoint = new Point3D( - startPoint.X, - startPoint.Y, - startPoint.Z + liftHeightModelUnits); + var liftPoint = HoistingCoordinateHelper.OffsetAlongUp(startPoint, liftHeightModelUnits, _adapter); var liftPointPath = new PathPoint( liftPoint, "提升点", @@ -61,10 +71,7 @@ namespace NavisworksTransport.PathPlanning pathPoints.Add(liftPointPath); // 点3:平移终点(终点正上方,保持提升高度) - var moveEndPoint = new Point3D( - endPoint.X, - endPoint.Y, - liftPoint.Z); // 使用提升点的Z坐标,保持提升高度一致 + var moveEndPoint = HoistingCoordinateHelper.ProjectToAerialElevation(endPoint, liftPoint, _adapter); var moveEndPointPath = new PathPoint( moveEndPoint, "平移终点", @@ -109,10 +116,10 @@ namespace NavisworksTransport.PathPlanning double deltaY = Math.Abs(clickedGroundPoint.Y - previousPoint.Y); // 使用目标高度作为Z坐标,XY使用地面点击位置 - Point3D newPoint = new Point3D( - clickedGroundPoint.X, - clickedGroundPoint.Y, - targetHeightModelUnits); // 使用传入的目标高度 + Point3D newPoint = HoistingCoordinateHelper.SetElevation( + clickedGroundPoint, + targetHeightModelUnits, + _adapter); // 判断移动方向(仅用于记录,不影响位置) HoistingPointDirection direction; @@ -165,6 +172,7 @@ namespace NavisworksTransport.PathPlanning var pathPoints = new List(); double liftHeightModelUnits = UnitsConverter.ConvertFromMeters(liftHeightMeters); + double liftElevation = HoistingCoordinateHelper.GetElevation(startPoint, _adapter) + liftHeightModelUnits; // 点1:起吊点(地面起点) var startPathPoint = new PathPoint( @@ -175,10 +183,7 @@ namespace NavisworksTransport.PathPlanning pathPoints.Add(startPathPoint); // 点2:提升点(起点正上方,吊装高度) - var liftPoint = new Point3D( - startPoint.X, - startPoint.Y, - startPoint.Z + liftHeightModelUnits); + var liftPoint = HoistingCoordinateHelper.OffsetAlongUp(startPoint, liftHeightModelUnits, _adapter); var liftPathPoint = new PathPoint( liftPoint, "提升点", @@ -194,7 +199,7 @@ namespace NavisworksTransport.PathPlanning var newPoint = GenerateSmartHoistingPoint( previousPoint, groundIntermediatePoints[i], - liftHeightModelUnits, // 使用模型单位的高度 + liftElevation, i + 1); pathPoints.Add(newPoint); @@ -258,13 +263,10 @@ namespace NavisworksTransport.PathPlanning { var level = levels[i]; double levelHeightModelUnits = UnitsConverter.ConvertFromMeters(level.HeightInMeters); - double currentHeight = startPoint.Z + levelHeightModelUnits; + double currentHeight = HoistingCoordinateHelper.GetElevation(startPoint, _adapter) + levelHeightModelUnits; // 垂直移动点(上升或下降到该层级高度) - var verticalPoint = new Point3D( - currentPoint.X, - currentPoint.Y, - currentHeight); + var verticalPoint = HoistingCoordinateHelper.SetElevation(currentPoint, currentHeight, _adapter); string verticalPointName = level.IsRise ? $"提升点{i + 1}" : $"下降点{i + 1}"; var verticalPathPoint = new PathPoint( @@ -275,10 +277,7 @@ namespace NavisworksTransport.PathPlanning pathPoints.Add(verticalPathPoint); // 水平移动点(移动到该层级的目标位置,保持高度) - var horizontalPoint = new Point3D( - level.HorizontalTarget.X, - level.HorizontalTarget.Y, - currentHeight); + var horizontalPoint = HoistingCoordinateHelper.SetElevation(level.HorizontalTarget, currentHeight, _adapter); string horizontalPointName = $"平移点{i + 1}"; var horizontalPathPoint = new PathPoint( @@ -293,10 +292,7 @@ namespace NavisworksTransport.PathPlanning } // 最后:垂直下降到地面终点 - var descendPoint = new Point3D( - currentPoint.X, - currentPoint.Y, - endPoint.Z); + var descendPoint = HoistingCoordinateHelper.ProjectToAerialElevation(endPoint, currentPoint, _adapter); var descendPathPoint = new PathPoint( descendPoint, "最终下降点", @@ -315,4 +311,4 @@ namespace NavisworksTransport.PathPlanning return pathPoints; } } -} \ No newline at end of file +} diff --git a/src/PathPlanning/AutoPathFinder.cs b/src/PathPlanning/AutoPathFinder.cs index 415c0dc..32c6bab 100644 --- a/src/PathPlanning/AutoPathFinder.cs +++ b/src/PathPlanning/AutoPathFinder.cs @@ -7,6 +7,7 @@ using Roy_T.AStar.Primitives; using Roy_T.AStar.Paths; using NavisworksTransport.Utils; using NavisworksTransport.Core.Config; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.PathPlanning { @@ -495,8 +496,11 @@ namespace NavisworksTransport.PathPlanning var endGridPos = gridMap.WorldToGrid(end); // 找到起点和终点最近的3D节点 - var startNode = FindClosest3DNode(graph3D, startGridPos, start.Z); - var endNode = FindClosest3DNode(graph3D, endGridPos, end.Z); + double startElevation = gridMap.GetWorldElevation(start); + double endElevation = gridMap.GetWorldElevation(end); + + var startNode = FindClosest3DNode(graph3D, startGridPos, startElevation); + var endNode = FindClosest3DNode(graph3D, endGridPos, endElevation); // 起点和终点必须检查可达性 bool startOnObstacle = (startNode == null); @@ -524,8 +528,7 @@ namespace NavisworksTransport.PathPlanning foreach (var node in graph3D.AllNodes) { var info = graph3D.NodeInfo[node]; - var nodeWorldPos = gridMap.GridToWorld3D(new GridPoint2D(info.X, info.Y)); - nodeWorldPos = new Point3D(nodeWorldPos.X, nodeWorldPos.Y, info.Z); + var nodeWorldPos = gridMap.CreateWorldPoint(new GridPoint2D(info.X, info.Y), info.Z); double distance = Math.Sqrt( Math.Pow(nodeWorldPos.X - end.X, 2) + @@ -554,8 +557,8 @@ namespace NavisworksTransport.PathPlanning var startInfo = graph3D.NodeInfo[startNode]; var endInfo = graph3D.NodeInfo[endNode]; - LogManager.Info($"[A*执行-3D] 起点映射: ({startGridPos.X},{startGridPos.Y},Z={start.Z:F2}) -> 节点({startInfo.X},{startInfo.Y},层{startInfo.LayerIndex},Z={startInfo.Z:F2})"); - LogManager.Info($"[A*执行-3D] 终点映射: ({endGridPos.X},{endGridPos.Y},Z={end.Z:F2}) -> 节点({endInfo.X},{endInfo.Y},层{endInfo.LayerIndex},Z={endInfo.Z:F2})"); + LogManager.Info($"[A*执行-3D] 起点映射: ({startGridPos.X},{startGridPos.Y},高程={startElevation:F2}) -> 节点({startInfo.X},{startInfo.Y},层{startInfo.LayerIndex},高程={startInfo.Z:F2})"); + LogManager.Info($"[A*执行-3D] 终点映射: ({endGridPos.X},{endGridPos.Y},高程={endElevation:F2}) -> 节点({endInfo.X},{endInfo.Y},层{endInfo.LayerIndex},高程={endInfo.Z:F2})"); // 执行A* var pathfinder = new Roy_T.AStar.Paths.PathFinder(); @@ -699,7 +702,7 @@ namespace NavisworksTransport.PathPlanning path3D.Add(originalStart); int pointIndex = 0; - LogManager.Debug($"[路径点{pointIndex}] 原始起点: ({originalStart.X:F2}, {originalStart.Y:F2}, Z={originalStart.Z:F2})"); + LogManager.Debug($"[路径点{pointIndex}] 原始起点: ({originalStart.X:F2}, {originalStart.Y:F2}, {originalStart.Z:F2}), 高程={gridMap.GetWorldElevation(originalStart):F2}"); pointIndex++; // 添加第一条边的起点(如果与originalStart不同) @@ -710,14 +713,13 @@ namespace NavisworksTransport.PathPlanning if (firstNode != null && graph3D.NodeInfo.ContainsKey(firstNode)) { var firstInfo = graph3D.NodeInfo[firstNode]; - var firstPos = gridMap.GridToWorld2D(new GridPoint2D(firstInfo.X, firstInfo.Y)); - var firstPoint = new Point3D(firstPos.X, firstPos.Y, firstInfo.Z); + var firstPoint = gridMap.CreateWorldPoint(new GridPoint2D(firstInfo.X, firstInfo.Y), firstInfo.Z); // 如果与起点高度不同,添加这个节点(避免直接跳跃) - if (Math.Abs(firstPoint.Z - originalStart.Z) > 0.01) + if (Math.Abs(gridMap.GetWorldElevation(firstPoint) - gridMap.GetWorldElevation(originalStart)) > 0.01) { path3D.Add(firstPoint); - LogManager.Debug($"[路径点{pointIndex}] A*起点: 网格({firstInfo.X},{firstInfo.Y}), 层{firstInfo.LayerIndex}, Z={firstInfo.Z:F2}, 类型={firstInfo.CellType}"); + LogManager.Debug($"[路径点{pointIndex}] A*起点: 网格({firstInfo.X},{firstInfo.Y}), 层{firstInfo.LayerIndex}, 高程={firstInfo.Z:F2}, 类型={firstInfo.CellType}"); pointIndex++; } } @@ -731,10 +733,9 @@ namespace NavisworksTransport.PathPlanning continue; var nodeInfo = graph3D.NodeInfo[endNode]; - var worldPos = gridMap.GridToWorld2D(new GridPoint2D(nodeInfo.X, nodeInfo.Y)); - path3D.Add(new Point3D(worldPos.X, worldPos.Y, nodeInfo.Z)); + path3D.Add(gridMap.CreateWorldPoint(new GridPoint2D(nodeInfo.X, nodeInfo.Y), nodeInfo.Z)); - LogManager.Debug($"[路径点{pointIndex}] 网格({nodeInfo.X},{nodeInfo.Y}), 层{nodeInfo.LayerIndex}, Z={nodeInfo.Z:F2}, 类型={nodeInfo.CellType}"); + LogManager.Debug($"[路径点{pointIndex}] 网格({nodeInfo.X},{nodeInfo.Y}), 层{nodeInfo.LayerIndex}, 高程={nodeInfo.Z:F2}, 类型={nodeInfo.CellType}"); pointIndex++; } @@ -829,10 +830,10 @@ namespace NavisworksTransport.PathPlanning // 初始化激活层字典 var activeLayerZ = new Dictionary(); - double targetZ = endPos.Z; // 使用终点Z作为目标高度 + double targetZ = gridMap.GetWorldElevation(endPos); // 使用宿主坐标语义下的终点高程作为目标高度 double baseSpeed = 5.0; // km/h - LogManager.Info($"[A*转换-2.5D] 目标高度: {targetZ:F2}m,基础速度: {baseSpeed}km/h"); + LogManager.Info($"[A*转换-2.5D] 目标高程: {targetZ:F2}m,基础速度: {baseSpeed}km/h"); // 输出详细的网格统计信息 LogManager.Info($"[A*转换-2.5D] 输入网格统计信息:\n{gridMap.GetStatistics()}"); @@ -1083,7 +1084,7 @@ namespace NavisworksTransport.PathPlanning // 初始化激活高度层字典和目标高度 var activeLayerZ = new Dictionary(); - double targetZ = endPos.Z; + double targetZ = gridMap.GetWorldElevation(endPos); // 1. 创建网格基础结构 double cellSizeInMeters = UnitsConverter.ConvertToMeters(gridMap.CellSize); @@ -1354,7 +1355,7 @@ namespace NavisworksTransport.PathPlanning { foreach (var layer in cell.HeightLayers) { - if (layer.PassableHeight.GetSpan() >= objectHeight) + if (layer.IsWalkable && layer.PassableHeight.GetSpan() >= objectHeight) return true; } return false; @@ -1379,7 +1380,7 @@ namespace NavisworksTransport.PathPlanning foreach (var layer in cell.HeightLayers) { - if (layer.PassableHeight.GetSpan() >= objectHeight) + if (layer.IsWalkable && layer.PassableHeight.GetSpan() >= objectHeight) return true; } @@ -1403,6 +1404,9 @@ namespace NavisworksTransport.PathPlanning foreach (var layer in layers) { + if (!layer.IsWalkable) + continue; + // 必须满足物体高度 if (layer.PassableHeight.GetSpan() < objectHeight) continue; @@ -1564,19 +1568,19 @@ namespace NavisworksTransport.PathPlanning if (endCell.HasValue) { // 调试:检查门网格的Z坐标 - double endZ = endCell.Value.HeightLayers != null && endCell.Value.HeightLayers.Count > 0 + double endElevation = endCell.Value.HeightLayers != null && endCell.Value.HeightLayers.Count > 0 ? endCell.Value.HeightLayers[0].Z : 0; if (endCell.Value.CellType == "门") { - LogManager.Info($"[路径转换-门] 门网格({endGridPos.X},{endGridPos.Y}) HeightLayer[0].Z={endZ:F3}, 位置({endWorldPos.X:F2},{endWorldPos.Y:F2})"); + LogManager.Info($"[路径转换-门] 门网格({endGridPos.X},{endGridPos.Y}) HeightLayer[0].高程={endElevation:F3}, 位置({endWorldPos.X:F2},{endWorldPos.Y:F2})"); } - endWorldPos = new Point3D(endWorldPos.X, endWorldPos.Y, endZ); + endWorldPos = gridMap.SetWorldElevation(endWorldPos, endElevation); } else { - LogManager.Warning($"[路径转换] 网格({endGridPos.X},{endGridPos.Y})获取失败,位置({endWorldPos.X:F2},{endWorldPos.Y:F2}),Z坐标保持为0"); + LogManager.Warning($"[路径转换] 网格({endGridPos.X},{endGridPos.Y})获取失败,位置({endWorldPos.X:F2},{endWorldPos.Y:F2}),高程保持为默认值"); } // 检查是否与上一个点重复(A*可能在转弯点有重复) @@ -1623,8 +1627,7 @@ namespace NavisworksTransport.PathPlanning { var startPoint = optimizedPath[currentIndex]; var endPoint = optimizedPath[testIndex]; - var distance = Math.Sqrt(Math.Pow(endPoint.X - startPoint.X, 2) + - Math.Pow(endPoint.Y - startPoint.Y, 2)); + var distance = CalculateHostHorizontalDistance(startPoint, endPoint, gridMap); // 🔥 修复:检查高度变化,有高度变化就不能优化掉 // 1. 起终点高度差不能超过阈值(原有逻辑:防止跨越大高度差,例如楼梯) @@ -1632,8 +1635,8 @@ namespace NavisworksTransport.PathPlanning bool canSkip = false; double maxAllowedHeightDiff = ConfigManager.Instance.Current.PathEditing.MaxHeightDiff; // 从配置文件读取高度差阈值(模型单位) - double startZ = startPoint.Z; - double endZ = endPoint.Z; + double startZ = gridMap.GetWorldElevation(startPoint); + double endZ = gridMap.GetWorldElevation(endPoint); double totalHeightDiff = Math.Abs(endZ - startZ); // 检查1:起终点高度差必须在阈值内 @@ -1645,7 +1648,7 @@ namespace NavisworksTransport.PathPlanning for (int midIndex = currentIndex + 1; midIndex < testIndex; midIndex++) { - double midZ = optimizedPath[midIndex].Z; + double midZ = gridMap.GetWorldElevation(optimizedPath[midIndex]); // 如果中间点高度与起点或终点不同,说明有高度变化,不能优化掉 if (Math.Abs(midZ - startZ) > heightTolerance || Math.Abs(midZ - endZ) > heightTolerance) @@ -1725,7 +1728,7 @@ namespace NavisworksTransport.PathPlanning try { // 计算线段长度 - var distance = Math.Sqrt(Math.Pow(end.X - start.X, 2) + Math.Pow(end.Y - start.Y, 2)); + var distance = CalculateHostHorizontalDistance(start, end, gridMap); // 如果距离太小,直接检查起点和终点 if (distance < gridMap.CellSize * 0.1) @@ -1746,31 +1749,34 @@ namespace NavisworksTransport.PathPlanning { // 线性插值计算采样点 double t = samples > 0 ? (double)i / samples : 0; - var samplePoint = new Point3D( - start.X + t * (end.X - start.X), - start.Y + t * (end.Y - start.Y), - start.Z + t * (end.Z - start.Z) - ); + double interpolatedElevation = + gridMap.GetWorldElevation(start) + + t * (gridMap.GetWorldElevation(end) - gridMap.GetWorldElevation(start)); + + var samplePoint = gridMap.SetWorldElevation( + new Point3D( + start.X + t * (end.X - start.X), + start.Y + t * (end.Y - start.Y), + start.Z + t * (end.Z - start.Z)), + interpolatedElevation); // 🔥 修复:检查采样点的具体Z高度是否在可通行层范围内 // 使用IsPassableAt3DPoint而不是IsWalkable,确保不穿过障碍物层 var gridPos = gridMap.WorldToGrid(samplePoint); double tolerance = 0.1; // Z坐标容差(模型单位) - if (!gridMap.IsValidGridPosition(gridPos) || !gridMap.IsPassableAt3DPoint(gridPos, samplePoint.Z, tolerance)) + if (!gridMap.IsValidGridPosition(gridPos) || !gridMap.IsPassableAtElevation(gridPos, gridMap.GetWorldElevation(samplePoint), tolerance)) { // 🔥 修复:使用Origin计算网格左下角,而不是Bounds.Min - var gridMinX = gridMap.Origin.X + gridPos.X * gridMap.CellSize; - var gridMinY = gridMap.Origin.Y + gridPos.Y * gridMap.CellSize; - - // 计算网格中心点世界坐标(基于左下角) - var gridCenterX = gridMinX + 0.5 * gridMap.CellSize; - var gridCenterY = gridMinY + 0.5 * gridMap.CellSize; - var gridCenterZ = samplePoint.Z; // Z保持不变 + var (gridMinH1, gridMinH2) = GetGridCellMinHorizontalCoords(gridMap, gridPos); + var gridCenterH1 = gridMinH1 + 0.5 * gridMap.CellSize; + var gridCenterH2 = gridMinH2 + 0.5 * gridMap.CellSize; + var gridCenterZ = gridMap.GetWorldElevation(samplePoint); // 计算偏差 - var deltaX = samplePoint.X - gridCenterX; - var deltaY = samplePoint.Y - gridCenterY; + var sampleHorizontal = GetHostHorizontalCoords(samplePoint, gridMap); + var deltaH1 = sampleHorizontal.h1 - gridCenterH1; + var deltaH2 = sampleHorizontal.h2 - gridCenterH2; // LogManager.Debug($"[斜线检查] 采样点{i}/{samples}失败:" + // $"采样点({samplePoint.X:F3},{samplePoint.Y:F3},{samplePoint.Z:F3})," + @@ -1785,14 +1791,12 @@ namespace NavisworksTransport.PathPlanning if (!IsSamplePointSafeFromNeighborObstacles(samplePoint, gridPos, gridMap)) { // 🔥 修复:使用Origin计算网格左下角 - var gridMinX = gridMap.Origin.X + gridPos.X * gridMap.CellSize; - var gridMinY = gridMap.Origin.Y + gridPos.Y * gridMap.CellSize; - - // 计算网格中心点世界坐标 - var gridCenterX = gridMinX + 0.5 * gridMap.CellSize; - var gridCenterY = gridMinY + 0.5 * gridMap.CellSize; - var deltaX = samplePoint.X - gridCenterX; - var deltaY = samplePoint.Y - gridCenterY; + var (gridMinH1, gridMinH2) = GetGridCellMinHorizontalCoords(gridMap, gridPos); + var gridCenterH1 = gridMinH1 + 0.5 * gridMap.CellSize; + var gridCenterH2 = gridMinH2 + 0.5 * gridMap.CellSize; + var sampleHorizontal = GetHostHorizontalCoords(samplePoint, gridMap); + var deltaH1 = sampleHorizontal.h1 - gridCenterH1; + var deltaH2 = sampleHorizontal.h2 - gridCenterH2; // LogManager.Debug($"[斜线检查] 采样点{i}/{samples}邻居障碍检查失败:" + // $"采样点({samplePoint.X:F3},{samplePoint.Y:F3})," + @@ -1803,6 +1807,11 @@ namespace NavisworksTransport.PathPlanning } } + if (SegmentIntersectsBlockedGridCellInterior(start, end, gridMap)) + { + return false; + } + return true; } catch (Exception ex) @@ -1825,11 +1834,11 @@ namespace NavisworksTransport.PathPlanning { // 🔥 修复:使用Origin计算网格左下角,而不是Bounds.Min // 计算采样点在所在网格内的相对位置 (0.0 到 1.0) - var gridMinX = gridMap.Origin.X + gridPos.X * gridMap.CellSize; - var gridMinY = gridMap.Origin.Y + gridPos.Y * gridMap.CellSize; + var (gridMinH1, gridMinH2) = GetGridCellMinHorizontalCoords(gridMap, gridPos); + var sampleHorizontal = GetHostHorizontalCoords(samplePoint, gridMap); - var relativeX = (samplePoint.X - gridMinX) / gridMap.CellSize; - var relativeY = (samplePoint.Y - gridMinY) / gridMap.CellSize; + var relativeX = (sampleHorizontal.h1 - gridMinH1) / gridMap.CellSize; + var relativeY = (sampleHorizontal.h2 - gridMinH2) / gridMap.CellSize; // 确保相对位置在有效范围内 relativeX = Math.Max(0.0, Math.Min(1.0, relativeX)); @@ -1863,18 +1872,18 @@ namespace NavisworksTransport.PathPlanning if (!constraintSatisfied) { // 🔥 修复:基于正确的网格左下角计算网格中心点 - var gridCenterX = gridMinX + 0.5 * gridMap.CellSize; - var gridCenterY = gridMinY + 0.5 * gridMap.CellSize; - var deltaX = samplePoint.X - gridCenterX; - var deltaY = samplePoint.Y - gridCenterY; + var gridCenterH1 = gridMinH1 + 0.5 * gridMap.CellSize; + var gridCenterH2 = gridMinH2 + 0.5 * gridMap.CellSize; + var deltaH1 = sampleHorizontal.h1 - gridCenterH1; + var deltaH2 = sampleHorizontal.h2 - gridCenterH2; LogManager.Debug($"[邻居障碍] 位置约束失败:" + $"{directionNames[i]}方向有障碍," + $"网格({gridPos.X},{gridPos.Y})," + $"采样点({samplePoint.X:F3},{samplePoint.Y:F3})," + - $"网格左下角({gridMinX:F3},{gridMinY:F3})," + - $"网格中心({gridCenterX:F3},{gridCenterY:F3})," + - $"偏差(ΔX={deltaX:F3}, ΔY={deltaY:F3})," + + $"网格左下角H=({gridMinH1:F3},{gridMinH2:F3})," + + $"网格中心H=({gridCenterH1:F3},{gridCenterH2:F3})," + + $"偏差(ΔH1={deltaH1:F3}, ΔH2={deltaH2:F3})," + $"相对位置({relativeX:F3},{relativeY:F3})"); return false; } @@ -1953,6 +1962,76 @@ namespace NavisworksTransport.PathPlanning return result; } + private static double CalculateHostHorizontalDistance(Point3D start, Point3D end, GridMap gridMap) + { + var startHorizontal = GetHostHorizontalCoords(start, gridMap); + var endHorizontal = GetHostHorizontalCoords(end, gridMap); + double dh1 = endHorizontal.h1 - startHorizontal.h1; + double dh2 = endHorizontal.h2 - startHorizontal.h2; + return Math.Sqrt(dh1 * dh1 + dh2 * dh2); + } + + private static (double h1, double h2) GetGridCellMinHorizontalCoords(GridMap gridMap, GridPoint2D gridPosition) + { + var (minH1, _, minH2, _) = gridMap.GetGridCellPlanarBounds(gridPosition); + return (minH1, minH2); + } + + private static (double h1, double h2) GetHostHorizontalCoords(Point3D point, GridMap gridMap) + { + var adapter = new HostCoordinateAdapter(gridMap.CoordinateSystemType); + return HostPlanarGridHelper.GetHorizontalCoords3( + new System.Numerics.Vector3((float)point.X, (float)point.Y, (float)point.Z), + adapter); + } + + private bool SegmentIntersectsBlockedGridCellInterior(Point3D start, Point3D end, GridMap gridMap) + { + var startHorizontal = GetHostHorizontalCoords(start, gridMap); + var endHorizontal = GetHostHorizontalCoords(end, gridMap); + var originHorizontal = GetHostHorizontalCoords(gridMap.Origin, gridMap); + + int minGridX = (int)Math.Round((Math.Min(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) - 1; + int maxGridX = (int)Math.Round((Math.Max(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) + 1; + int minGridY = (int)Math.Round((Math.Min(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) - 1; + int maxGridY = (int)Math.Round((Math.Max(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) + 1; + + minGridX = Math.Max(0, minGridX); + minGridY = Math.Max(0, minGridY); + maxGridX = Math.Min(gridMap.Width - 1, maxGridX); + maxGridY = Math.Min(gridMap.Height - 1, maxGridY); + + for (int x = minGridX; x <= maxGridX; x++) + { + for (int y = minGridY; y <= maxGridY; y++) + { + var gridPosition = new GridPoint2D(x, y); + + if (!gridMap.TryGetSegmentGridCellInteriorIntersection(start, end, gridPosition, out double enterT, out double exitT)) + { + continue; + } + + double sampleT = Math.Max(0.0, Math.Min(1.0, (enterT + exitT) * 0.5)); + Point3D samplePoint = new Point3D( + start.X + sampleT * (end.X - start.X), + start.Y + sampleT * (end.Y - start.Y), + start.Z + sampleT * (end.Z - start.Z)); + double sampleElevation = gridMap.GetWorldElevation(samplePoint); + + if (!gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1)) + { + LogManager.Debug( + $"[斜线检查] 线段穿过不可通行网格内部: 网格({x},{y}), " + + $"t=[{enterT:F3},{exitT:F3}], sampleT={sampleT:F3}"); + return true; + } + } + } + + return false; + } + /// /// 计算路径总长度 @@ -2015,7 +2094,7 @@ namespace NavisworksTransport.PathPlanning /// 原始起点坐标 /// 原始终点坐标 /// 修正后的路径 - private List CorrectStartEndPoints(List path, Point3D originalStart, Point3D originalEnd) + private List CorrectStartEndPoints(List path, Point3D originalStart, Point3D originalEnd, GridMap gridMap) { if (path == null || path.Count == 0) return path; @@ -2025,21 +2104,23 @@ namespace NavisworksTransport.PathPlanning LogManager.Info($"[坐标修正] 开始修正路径起终点坐标"); var correctedPath = new List(path); - // 修正起点:保持原始XY坐标,使用路径的Z坐标 + // 修正起点:保持原始宿主水平位置,复用路径求得的高程 if (correctedPath.Count > 0) { - var originalStartWithZ = new Point3D(originalStart.X, originalStart.Y, correctedPath[0].Z); - LogManager.Info($"[坐标修正] 起点: ({correctedPath[0].X:F3}, {correctedPath[0].Y:F3}, {correctedPath[0].Z:F3}) -> ({originalStartWithZ.X:F3}, {originalStartWithZ.Y:F3}, {originalStartWithZ.Z:F3})"); - correctedPath[0] = originalStartWithZ; + double startElevation = gridMap.GetWorldElevation(correctedPath[0]); + var correctedStart = gridMap.SetWorldElevation(originalStart, startElevation); + LogManager.Info($"[坐标修正] 起点: ({correctedPath[0].X:F3}, {correctedPath[0].Y:F3}, {correctedPath[0].Z:F3}) -> ({correctedStart.X:F3}, {correctedStart.Y:F3}, {correctedStart.Z:F3}), 高程={startElevation:F3}"); + correctedPath[0] = correctedStart; } - // 修正终点:保持原始XY坐标,使用路径的Z坐标 + // 修正终点:保持原始宿主水平位置,复用路径求得的高程 if (correctedPath.Count > 1) { var lastIndex = correctedPath.Count - 1; - var originalEndWithZ = new Point3D(originalEnd.X, originalEnd.Y, correctedPath[lastIndex].Z); - LogManager.Info($"[坐标修正] 终点: ({correctedPath[lastIndex].X:F3}, {correctedPath[lastIndex].Y:F3}, {correctedPath[lastIndex].Z:F3}) -> ({originalEndWithZ.X:F3}, {originalEndWithZ.Y:F3}, {originalEndWithZ.Z:F3})"); - correctedPath[lastIndex] = originalEndWithZ; + double endElevation = gridMap.GetWorldElevation(correctedPath[lastIndex]); + var correctedEnd = gridMap.SetWorldElevation(originalEnd, endElevation); + LogManager.Info($"[坐标修正] 终点: ({correctedPath[lastIndex].X:F3}, {correctedPath[lastIndex].Y:F3}, {correctedPath[lastIndex].Z:F3}) -> ({correctedEnd.X:F3}, {correctedEnd.Y:F3}, {correctedEnd.Z:F3}), 高程={endElevation:F3}"); + correctedPath[lastIndex] = correctedEnd; } LogManager.Info($"[坐标修正] 路径起终点坐标修正完成"); @@ -2087,7 +2168,7 @@ namespace NavisworksTransport.PathPlanning if (cell.HeightLayers != null && cell.HeightLayers.Count > 0) { // 查找包含指定Z坐标且满足物体高度的层 - var matchingLayer = gridMap.FindLayerContainingZ(gridPos, point.Z, tolerance: 0.5); + var matchingLayer = gridMap.FindLayerContainingElevation(gridPos, gridMap.GetWorldElevation(point), tolerance: 0.5); if (matchingLayer.HasValue) { @@ -2098,12 +2179,12 @@ namespace NavisworksTransport.PathPlanning } else { - LogManager.Warning($"[高度检查] ❌ 找到匹配Z={point.Z:F2}的层Z={matchingLayer.Value.Z:F2},但高度不足:物体高度{objectHeight:F2},层高度跨度{matchingLayer.Value.PassableHeight.GetSpan():F2}"); + LogManager.Warning($"[高度检查] ❌ 找到匹配高程={gridMap.GetWorldElevation(point):F2}的层高程={matchingLayer.Value.Z:F2},但高度不足:物体高度{objectHeight:F2},层高度跨度{matchingLayer.Value.PassableHeight.GetSpan():F2}"); } } else { - LogManager.Warning($"[高度检查] ❌ 单元格({gridX}, {gridY})有{cell.HeightLayers.Count}个高度层,但无法找到包含Z={point.Z:F2}的层"); + LogManager.Warning($"[高度检查] ❌ 单元格({gridX}, {gridY})有{cell.HeightLayers.Count}个高度层,但无法找到包含高程={gridMap.GetWorldElevation(point):F2}的层"); } return false; } @@ -2136,17 +2217,17 @@ namespace NavisworksTransport.PathPlanning var adjustedPath = new List(); - // 1. 使用用户指定的原始起点和终点Z坐标(而不是从转换后的路径中提取) - double startZ = originalStart.Z; - double endZ = originalEnd.Z; + // 1. 使用用户指定的原始起点和终点高程(而不是从转换后的路径中提取) + double startZ = gridMap.GetWorldElevation(originalStart); + double endZ = gridMap.GetWorldElevation(originalEnd); // 对比日志:显示原始坐标与路径坐标的差异 - var pathStartZ = pathWithGridCoords[0].point.Z; - var pathEndZ = pathWithGridCoords[pathWithGridCoords.Count - 1].point.Z; + var pathStartZ = gridMap.GetWorldElevation(pathWithGridCoords[0].point); + var pathEndZ = gridMap.GetWorldElevation(pathWithGridCoords[pathWithGridCoords.Count - 1].point); - LogManager.Info($"[智能选层] 原始起点Z={startZ:F3}, 原始终点Z={endZ:F3}"); - LogManager.Info($"[智能选层] 路径起点Z={pathStartZ:F3}, 路径终点Z={pathEndZ:F3}"); - LogManager.Info($"[智能选层] 使用原始Z坐标进行智能选层,高度变化={endZ - startZ:F3}"); + LogManager.Info($"[智能选层] 原始起点高程={startZ:F3}, 原始终点高程={endZ:F3}"); + LogManager.Info($"[智能选层] 路径起点高程={pathStartZ:F3}, 路径终点高程={pathEndZ:F3}"); + LogManager.Info($"[智能选层] 使用原始高程进行智能选层,高程变化={endZ - startZ:F3}"); // 2. 为每个路径点选择高度层 for (int i = 0; i < pathWithGridCoords.Count; i++) @@ -2175,8 +2256,8 @@ namespace NavisworksTransport.PathPlanning var selectedLayer = SelectBestLayer(cell.HeightLayers, startZ, objectHeight, startZ, endZ, i, pathWithGridCoords.Count, true); if (selectedLayer.HasValue) { - adjustedPath.Add(new Point3D(point.X, point.Y, selectedLayer.Value.Z)); - LogManager.Debug($"[智能选层] 起点({gridPos.X},{gridPos.Y}) Z={selectedLayer.Value.Z:F3}"); + adjustedPath.Add(gridMap.SetWorldElevation(point, selectedLayer.Value.Z)); + LogManager.Debug($"[智能选层] 起点({gridPos.X},{gridPos.Y}) 高程={selectedLayer.Value.Z:F3}"); } else { @@ -2188,8 +2269,8 @@ namespace NavisworksTransport.PathPlanning var selectedLayer = SelectBestLayer(cell.HeightLayers, endZ, objectHeight, startZ, endZ, i, pathWithGridCoords.Count, true); if (selectedLayer.HasValue) { - adjustedPath.Add(new Point3D(point.X, point.Y, selectedLayer.Value.Z)); - LogManager.Debug($"[智能选层] 终点({gridPos.X},{gridPos.Y}) Z={selectedLayer.Value.Z:F3}"); + adjustedPath.Add(gridMap.SetWorldElevation(point, selectedLayer.Value.Z)); + LogManager.Debug($"[智能选层] 终点({gridPos.X},{gridPos.Y}) 高程={selectedLayer.Value.Z:F3}"); } else { @@ -2199,12 +2280,12 @@ namespace NavisworksTransport.PathPlanning else { // 中间点:根据高度趋势选择最佳层 - var prevZ = adjustedPath[i - 1].Z; + var prevZ = gridMap.GetWorldElevation(adjustedPath[i - 1]); var selectedLayer = SelectBestLayer(cell.HeightLayers, prevZ, objectHeight, startZ, endZ, i, pathWithGridCoords.Count, false); if (selectedLayer.HasValue) { - adjustedPath.Add(new Point3D(point.X, point.Y, selectedLayer.Value.Z)); + adjustedPath.Add(gridMap.SetWorldElevation(point, selectedLayer.Value.Z)); } else { @@ -2243,36 +2324,34 @@ namespace NavisworksTransport.PathPlanning for (int i = 0; i < gridPath.Count; i++) { var gridPos = gridPath[i]; - var worldXY = gridMap.GridToWorld3D(gridPos); - - double z; + double elevation; // 起点:使用原始起点Z if (i == 0) { - z = originalStart.Z; - LogManager.Info($"[3D还原] 起点 ({gridPos.X},{gridPos.Y}) Z={z:F2}m (原始起点)"); + elevation = gridMap.GetWorldElevation(originalStart); + LogManager.Info($"[3D还原] 起点 ({gridPos.X},{gridPos.Y}) 高程={elevation:F2}m (原始起点)"); } // 终点:使用原始终点Z else if (i == gridPath.Count - 1) { - z = originalEnd.Z; - LogManager.Info($"[3D还原] 终点 ({gridPos.X},{gridPos.Y}) Z={z:F2}m (原始终点)"); + elevation = gridMap.GetWorldElevation(originalEnd); + LogManager.Info($"[3D还原] 终点 ({gridPos.X},{gridPos.Y}) 高程={elevation:F2}m (原始终点)"); } // 中间点:使用A*阶段记录的激活层 else if (activeLayerZ.TryGetValue(gridPos, out double activeZ)) { - z = activeZ; - LogManager.Debug($"[3D还原] 点{i} ({gridPos.X},{gridPos.Y}) Z={z:F2}m (激活层)"); + elevation = activeZ; + LogManager.Debug($"[3D还原] 点{i} ({gridPos.X},{gridPos.Y}) 高程={elevation:F2}m (激活层)"); } else { // 降级:无激活层记录,使用前一点高度 - z = path3D[i - 1].Z; - LogManager.Warning($"[3D还原] 点{i} ({gridPos.X},{gridPos.Y}) 无激活层记录,使用前点Z={z:F2}m"); + elevation = gridMap.GetWorldElevation(path3D[i - 1]); + LogManager.Warning($"[3D还原] 点{i} ({gridPos.X},{gridPos.Y}) 无激活层记录,使用前点高程={elevation:F2}m"); } - path3D.Add(new Point3D(worldXY.X, worldXY.Y, z)); + path3D.Add(gridMap.CreateWorldPoint(gridPos, elevation)); } LogManager.Info($"[3D还原] 完成,生成了 {path3D.Count} 个3D路径点"); @@ -2301,7 +2380,7 @@ namespace NavisworksTransport.PathPlanning if (layers.Count == 1) { var layer = layers[0]; - if (layer.PassableHeight.GetSpan() >= objectHeight) + if (layer.IsWalkable && layer.PassableHeight.GetSpan() >= objectHeight) return layer; return null; } @@ -2337,6 +2416,9 @@ namespace NavisworksTransport.PathPlanning foreach (var layer in layers) { + if (!layer.IsWalkable) + continue; + if (layer.PassableHeight.GetSpan() < objectHeight) continue; @@ -2577,9 +2659,6 @@ namespace NavisworksTransport.PathPlanning // 更新路径结果 pathResult.PathPoints = optimizedPoints; - // 🔥 步骤2:在现有优化基础上应用专门的斜线优化 - // 2D场景:高度差为0,不影响原有逻辑 - // 3D场景:通过高度差检查保护楼梯路径 var diagonalOptimizedPoints = ApplyDiagonalOptimization(optimizedPoints, gridMap); if (diagonalOptimizedPoints.Count != optimizedPoints.Count) @@ -2627,7 +2706,7 @@ namespace NavisworksTransport.PathPlanning // 初始化激活高度层字典和目标高度 var activeLayerZ = new Dictionary(); - double targetZ = endPos.Z; + double targetZ = gridMap.GetWorldElevation(endPos); // 清空日志记录集合,确保每次执行都能看到映射关系 _loggedDistances.Clear(); diff --git a/src/PathPlanning/ChannelBasedGridBuilder.cs b/src/PathPlanning/ChannelBasedGridBuilder.cs index 3d82c76..baee609 100644 --- a/src/PathPlanning/ChannelBasedGridBuilder.cs +++ b/src/PathPlanning/ChannelBasedGridBuilder.cs @@ -101,7 +101,9 @@ namespace NavisworksTransport.PathPlanning LogManager.Info($"[通道网格构建器] 通道总边界: {FormatBounds(totalBounds)}"); // 3. 创建网格地图 - var gridMap = new GridMap(totalBounds, gridSize); + // 必须继续传递当前构建器绑定的坐标系,避免同一轮流程里 + // ChannelBasedGridBuilder、GridMap、ChannelHeightDetector 各自重新抓取 Current。 + var gridMap = new GridMap(totalBounds, gridSize, _coordinateSystem); // 4. 为每个通道生成精确投影 var processedChannels = new List(); @@ -145,6 +147,7 @@ namespace NavisworksTransport.PathPlanning double minZ = double.MaxValue; double maxZ = double.MinValue; int walkableCount = 0; + var elevationCounts = new Dictionary(); LogManager.Info("[通道Z值计算] 开始计算可通行网格Z值范围"); @@ -166,6 +169,16 @@ namespace NavisworksTransport.PathPlanning minZ = cellZ; if (cellZ > maxZ) maxZ = cellZ; + + double roundedElevation = Math.Round(cellZ, 3); + if (elevationCounts.ContainsKey(roundedElevation)) + { + elevationCounts[roundedElevation]++; + } + else + { + elevationCounts[roundedElevation] = 1; + } } } } @@ -179,6 +192,17 @@ namespace NavisworksTransport.PathPlanning } LogManager.Info($"[通道Z值计算] 计算完成 - 可通行网格: {walkableCount}个, Z范围: [{minZ:F2}, {maxZ:F2}], 高度差: {maxZ - minZ:F2}"); + if (elevationCounts.Count > 0) + { + string distributionSummary = string.Join( + ", ", + elevationCounts + .OrderByDescending(kvp => kvp.Value) + .ThenBy(kvp => kvp.Key) + .Take(8) + .Select(kvp => $"{kvp.Key:F3}:{kvp.Value}")); + LogManager.Info($"[通道Z值计算] 可通行格高程分布样本(Top8): {distributionSummary}"); + } return (minZ, maxZ, walkableCount); } @@ -205,6 +229,9 @@ namespace NavisworksTransport.PathPlanning return; } + double upwardTriangleMinElevation = double.MaxValue; + double upwardTriangleMaxElevation = double.MinValue; + // 统一投影处理 int processedTriangles = 0; foreach (var triangle in triangles) @@ -214,6 +241,17 @@ namespace NavisworksTransport.PathPlanning // 根据法向量决定处理方式(使用坐标系判断向上) if (GetUpComponent(normal) > 0) // 朝上的面 { + upwardTriangleMinElevation = Math.Min( + upwardTriangleMinElevation, + Math.Min( + _coordinateSystem.GetElevation(triangle.Point1), + Math.Min(_coordinateSystem.GetElevation(triangle.Point2), _coordinateSystem.GetElevation(triangle.Point3)))); + upwardTriangleMaxElevation = Math.Max( + upwardTriangleMaxElevation, + Math.Max( + _coordinateSystem.GetElevation(triangle.Point1), + Math.Max(_coordinateSystem.GetElevation(triangle.Point2), _coordinateSystem.GetElevation(triangle.Point3)))); + RasterizeTriangleToGrid(gridMap, triangle, channel, channelSpeedLimit); processedTriangles++; } @@ -221,6 +259,11 @@ namespace NavisworksTransport.PathPlanning } LogManager.Info($"[通道网格构建器] 投影完成:{processedTriangles}/{triangles.Count} 个三角形"); + if (processedTriangles > 0) + { + LogManager.Info( + $"[通道网格构建器] 朝上三角高程范围: [{upwardTriangleMinElevation:F2}, {upwardTriangleMaxElevation:F2}]"); + } } /// @@ -397,7 +440,7 @@ namespace NavisworksTransport.PathPlanning var emptyBounds = new BoundingBox3D(new Point3D(0, 0, 0), new Point3D(0, 0, 0)); return new ChannelCoverage { - GridMap = new GridMap(emptyBounds, 1.0), + GridMap = new GridMap(emptyBounds, 1.0, _coordinateSystem), ChannelItems = new List(), TotalBounds = emptyBounds }; diff --git a/src/PathPlanning/ChannelHeightDetector.cs b/src/PathPlanning/ChannelHeightDetector.cs index 16ff639..c9dbf68 100644 --- a/src/PathPlanning/ChannelHeightDetector.cs +++ b/src/PathPlanning/ChannelHeightDetector.cs @@ -65,8 +65,9 @@ namespace NavisworksTransport.PathPlanning var containingChannel = FindContainingChannel(position, channelItems); if (containingChannel == null) { - LogManager.Info($"[高度检测] ❌ 位置 ({position.X:F2}, {position.Y:F2}) 未找到包含的通道,使用原始Z坐标: {position.Z:F2}"); - return position.Z; + double fallbackElevation = _coordinateSystem.GetElevation(position); + LogManager.Info($"[高度检测] ❌ 位置 ({position.X:F2}, {position.Y:F2}, {position.Z:F2}) 未找到包含的通道,使用当前宿主高程: {fallbackElevation:F2}"); + return fallbackElevation; } //LogManager.Info($"[高度检测] ✅ 找到包含通道: {containingChannel.DisplayName}"); @@ -101,7 +102,7 @@ namespace NavisworksTransport.PathPlanning catch (Exception ex) { LogManager.Error($"[高度检测] 检测高度时发生错误: {ex.Message}"); - return position.Z; // 发生错误时使用原始Z坐标 + return _coordinateSystem.GetElevation(position); } } @@ -127,8 +128,9 @@ namespace NavisworksTransport.PathPlanning var containingChannel = FindContainingChannel(position, channelItems); if (containingChannel == null) { - LogManager.Info($"[高度检测] ❌ 位置 ({position.X:F2}, {position.Y:F2}) 未找到包含的通道,使用原始Z坐标: {position.Z:F2}"); - return position.Z; + double fallbackElevation = _coordinateSystem.GetElevation(position); + LogManager.Info($"[高度检测] ❌ 位置 ({position.X:F2}, {position.Y:F2}, {position.Z:F2}) 未找到包含的通道,使用当前宿主高程: {fallbackElevation:F2}"); + return fallbackElevation; } //LogManager.Info($"[高度检测] ✅ 找到包含通道: {containingChannel.DisplayName}"); @@ -159,19 +161,20 @@ namespace NavisworksTransport.PathPlanning var bounds = containingChannel.BoundingBox(); if (bounds != null) { - var fallbackTopHeight = bounds.Max.Z; + var (_, fallbackTopHeight) = GetBoundsElevationRange(bounds, _coordinateSystem); LogManager.Info($"[高度检测] 使用边界框顶面高度: {fallbackTopHeight:F2}"); return fallbackTopHeight; } // 最后的备选方案 - LogManager.Warning($"[高度检测] ⚠️ 无法获取通道顶面高度,使用原始Z坐标: {position.Z:F2}"); - return position.Z; + double finalFallbackElevation = _coordinateSystem.GetElevation(position); + LogManager.Warning($"[高度检测] ⚠️ 无法获取通道顶面高度,使用当前宿主高程: {finalFallbackElevation:F2}"); + return finalFallbackElevation; } catch (Exception ex) { LogManager.Error($"[高度检测] 检测顶面高度时发生错误: {ex.Message}"); - return position.Z; // 发生错误时使用原始Z坐标 + return _coordinateSystem.GetElevation(position); } } @@ -198,11 +201,13 @@ namespace NavisworksTransport.PathPlanning return null; } + var (floorHeight, ceilingHeight) = GetBoundsElevationRange(bounds, _coordinateSystem); + // 创建高度信息对象 var heightInfo = new ChannelHeightInfo { - FloorHeight = bounds.Min.Z, - CeilingHeight = bounds.Max.Z, + FloorHeight = floorHeight, + CeilingHeight = ceilingHeight, Type = ChannelType.Other, // 不再依赖类型判断 HeightProfile = new List() }; @@ -255,11 +260,7 @@ namespace NavisworksTransport.PathPlanning return false; var bounds = channel.Geometry.BoundingBox; - - // 检查XY平面是否在边界内(Z方向放宽检查) - return position.X >= bounds.Min.X && position.X <= bounds.Max.X && - position.Y >= bounds.Min.Y && position.Y <= bounds.Max.Y && - position.Z >= bounds.Min.Z - 2.0 && position.Z <= bounds.Max.Z + 2.0; // Z方向容差2米 + return IsPositionWithinBounds(position, bounds, _coordinateSystem, 2.0); } catch { @@ -283,14 +284,14 @@ namespace NavisworksTransport.PathPlanning for (int i = 0; i < sampleCount; i++) { double ratio = (double)i / (sampleCount - 1); - var sampleX = bounds.Min.X + (bounds.Max.X - bounds.Min.X) * ratio; - var sampleY = bounds.Min.Y + (bounds.Max.Y - bounds.Min.Y) * ratio; + var samplePosition = CreateHeightProfileSamplePoint(bounds, _coordinateSystem, ratio); + var (floorHeight, ceilingHeight) = GetBoundsElevationRange(bounds, _coordinateSystem); var sample = new HeightSample { - Position = new Point3D(sampleX, sampleY, bounds.Min.Z), - FloorHeight = bounds.Min.Z, - CeilingHeight = bounds.Max.Z + Position = samplePosition, + FloorHeight = floorHeight, + CeilingHeight = ceilingHeight }; heightInfo.HeightProfile.Add(sample); @@ -346,7 +347,7 @@ namespace NavisworksTransport.PathPlanning // 找到最近的两个采样点进行插值 var closestSamples = heightProfile - .OrderBy(s => Math.Sqrt(Math.Pow(s.Position.X - position.X, 2) + Math.Pow(s.Position.Y - position.Y, 2))) + .OrderBy(s => CalculatePlanarDistance(s.Position, position, _coordinateSystem)) .Take(2) .ToList(); @@ -359,8 +360,8 @@ namespace NavisworksTransport.PathPlanning var sample1 = closestSamples[0]; var sample2 = closestSamples[1]; - var distance1 = Math.Sqrt(Math.Pow(sample1.Position.X - position.X, 2) + Math.Pow(sample1.Position.Y - position.Y, 2)); - var distance2 = Math.Sqrt(Math.Pow(sample2.Position.X - position.X, 2) + Math.Pow(sample2.Position.Y - position.Y, 2)); + var distance1 = CalculatePlanarDistance(sample1.Position, position, _coordinateSystem); + var distance2 = CalculatePlanarDistance(sample2.Position, position, _coordinateSystem); var totalDistance = distance1 + distance2; if (totalDistance < 0.001) return sample1.CeilingHeight; @@ -386,29 +387,29 @@ namespace NavisworksTransport.PathPlanning { var bbox = channel.Geometry.BoundingBox; - // 检查位置是否在通道的X,Y范围内 - if (position.X >= bbox.Min.X && position.X <= bbox.Max.X && - position.Y >= bbox.Min.Y && position.Y <= bbox.Max.Y) + if (IsPositionWithinBounds(position, bbox, _coordinateSystem, 0.0)) { // 位置在通道范围内,尝试分析表面高度 - var surfaceHeight = AnalyzeSurfaceHeightAtPosition(position, channel, bbox.Min.Z, bbox.Max.Z); + var (floorHeight, ceilingHeight) = GetBoundsElevationRange(bbox, _coordinateSystem); + var surfaceHeight = AnalyzeSurfaceHeightAtPosition(position, channel, floorHeight, ceilingHeight); LogManager.Debug($"[射线投射] 表面高度分析结果: {surfaceHeight:F2}"); return surfaceHeight; } else { - LogManager.Warning($"[射线投射] 位置({position.X:F2}, {position.Y:F2})不在通道范围内"); - return position.Z; + LogManager.Warning($"[射线投射] 位置({position.X:F2}, {position.Y:F2}, {position.Z:F2})不在通道范围内"); + return _coordinateSystem.GetElevation(position); } } - LogManager.Warning($"[射线投射] 通道几何信息无效,使用原始Z坐标: {position.Z:F2}"); - return position.Z; + double fallbackElevation = _coordinateSystem.GetElevation(position); + LogManager.Warning($"[射线投射] 通道几何信息无效,使用当前宿主高程: {fallbackElevation:F2}"); + return fallbackElevation; } catch (Exception ex) { LogManager.Error($"[射线投射] 射线投射计算失败: {ex.Message}"); - return position.Z; + return _coordinateSystem.GetElevation(position); } } @@ -489,7 +490,11 @@ namespace NavisworksTransport.PathPlanning // 验证拾取到的模型是否为目标通道(或其子项) if (pickResult.ModelItem.Equals(channel) || ModelItemAnalysisHelper.IsChildOf(pickResult.ModelItem, channel)) { - height = pickResult.Point.Z; + height = GetPickedSurfaceElevation( + pickResult.Point.X, + pickResult.Point.Y, + pickResult.Point.Z, + _coordinateSystem.Type); LogManager.Info($"[几何分析] ✅ 使用投影法获得精确表面高度: {height:F2},通道: {channel.DisplayName}"); return true; } @@ -556,9 +561,14 @@ namespace NavisworksTransport.PathPlanning /// private Point3D CalculateRelativePositionInChannel(Point3D position, BoundingBox3D bbox) { - var relativeX = (position.X - bbox.Min.X) / (bbox.Max.X - bbox.Min.X); - var relativeY = (position.Y - bbox.Min.Y) / (bbox.Max.Y - bbox.Min.Y); - var relativeZ = 0.0; // Z方向待计算 + var (positionH1, positionH2) = _coordinateSystem.GetHorizontalCoords(position); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bbox); + var (minElevation, maxElevation) = GetBoundsElevationRange(bbox, _coordinateSystem); + + var relativeX = Math.Abs(maxH1 - minH1) < 1e-9 ? 0.0 : (positionH1 - minH1) / (maxH1 - minH1); + var relativeY = Math.Abs(maxH2 - minH2) < 1e-9 ? 0.0 : (positionH2 - minH2) / (maxH2 - minH2); + var currentElevation = _coordinateSystem.GetElevation(position); + var relativeZ = Math.Abs(maxElevation - minElevation) < 1e-9 ? 0.0 : (currentElevation - minElevation) / (maxElevation - minElevation); return new Point3D(relativeX, relativeY, relativeZ); } @@ -583,8 +593,9 @@ namespace NavisworksTransport.PathPlanning private string GenerateCacheKey(ModelItem channel, Point3D position) { // 使用通道的实例ID和位置的网格坐标作为缓存键 - var gridX = (int)(position.X / 1.0); // 1米网格 - var gridY = (int)(position.Y / 1.0); + var (h1, h2) = _coordinateSystem.GetHorizontalCoords(position); + var gridX = (int)(h1 / 1.0); + var gridY = (int)(h2 / 1.0); return $"{channel.InstanceGuid}_{gridX}_{gridY}"; } @@ -632,7 +643,7 @@ namespace NavisworksTransport.PathPlanning foreach (var testHeight in testHeights) { - var testPoint = new Point3D(position.X, position.Y, testHeight); + var testPoint = _coordinateSystem.SetElevation(position, testHeight); // 将3D点投影到屏幕 if (TryProjectWorldToScreen(testPoint, activeView, out GridPoint2D screenPoint)) @@ -645,8 +656,9 @@ namespace NavisworksTransport.PathPlanning var pickResult = activeView.PickItemFromPoint(screenPoint.X, screenPoint.Y); if (pickResult != null && (pickResult.ModelItem.Equals(channel) || ModelItemAnalysisHelper.IsChildOf(pickResult.ModelItem, channel))) { - LogManager.Info($"[射线投射] ✅ 成功检测到表面点: Z={pickResult.Point.Z:F2}"); - return pickResult.Point.Z; + double detectedElevation = _coordinateSystem.GetElevation(pickResult.Point); + LogManager.Info($"[射线投射] ✅ 成功检测到表面点高程: {detectedElevation:F2}"); + return detectedElevation; } } } @@ -790,11 +802,11 @@ namespace NavisworksTransport.PathPlanning var sampleRadius = 50.0; // 50单位半径内采样 var samplePoints = new[] { - new Point3D(position.X, position.Y, 0), // 中心点 - new Point3D(position.X + sampleRadius, position.Y, 0), // 右 - new Point3D(position.X - sampleRadius, position.Y, 0), // 左 - new Point3D(position.X, position.Y + sampleRadius, 0), // 上 - new Point3D(position.X, position.Y - sampleRadius, 0), // 下 + CreatePlanarOffsetPoint(position, _coordinateSystem, 0.0, 0.0), + CreatePlanarOffsetPoint(position, _coordinateSystem, sampleRadius, 0.0), + CreatePlanarOffsetPoint(position, _coordinateSystem, -sampleRadius, 0.0), + CreatePlanarOffsetPoint(position, _coordinateSystem, 0.0, sampleRadius), + CreatePlanarOffsetPoint(position, _coordinateSystem, 0.0, -sampleRadius), }; foreach (var samplePoint in samplePoints) @@ -807,8 +819,9 @@ namespace NavisworksTransport.PathPlanning var pickResult = activeView.PickItemFromPoint((int)screenPoint.X, (int)screenPoint.Y); if (pickResult != null && (pickResult.ModelItem.Equals(channel) || ModelItemAnalysisHelper.IsChildOf(pickResult.ModelItem, channel))) { - validHeights.Add(pickResult.Point.Z); - LogManager.Debug($"[多点采样] 采样点({samplePoint.X:F1}, {samplePoint.Y:F1})高度: {pickResult.Point.Z:F2}"); + double detectedElevation = _coordinateSystem.GetElevation(pickResult.Point); + validHeights.Add(detectedElevation); + LogManager.Debug($"[多点采样] 采样点({samplePoint.X:F1}, {samplePoint.Y:F1}, {samplePoint.Z:F1})高程: {detectedElevation:F2}"); } } } @@ -832,6 +845,132 @@ namespace NavisworksTransport.PathPlanning return null; } } + + private static (double min, double max) GetBoundsElevationRange(BoundingBox3D bounds, ICoordinateSystem coordinateSystem) + { + if (bounds == null) + { + throw new ArgumentNullException(nameof(bounds)); + } + + if (coordinateSystem == null) + { + throw new ArgumentNullException(nameof(coordinateSystem)); + } + + return GetBoundsElevationRange( + bounds.Min.X, bounds.Min.Y, bounds.Min.Z, + bounds.Max.X, bounds.Max.Y, bounds.Max.Z, + coordinateSystem.Type); + } + + private static Point3D CreateHeightProfileSamplePoint(BoundingBox3D bounds, ICoordinateSystem coordinateSystem, double ratio) + { + if (bounds == null) + { + throw new ArgumentNullException(nameof(bounds)); + } + + if (coordinateSystem == null) + { + throw new ArgumentNullException(nameof(coordinateSystem)); + } + + return CreateHeightProfileSamplePoint( + bounds.Min.X, bounds.Min.Y, bounds.Min.Z, + bounds.Max.X, bounds.Max.Y, bounds.Max.Z, + coordinateSystem.Type, + ratio); + } + + private static bool IsPositionWithinBounds(Point3D position, BoundingBox3D bounds, ICoordinateSystem coordinateSystem, double elevationTolerance) + { + if (position == null || bounds == null || coordinateSystem == null) + { + return false; + } + + var (positionH1, positionH2) = coordinateSystem.GetHorizontalCoords(position); + var (minH1, maxH1, minH2, maxH2) = coordinateSystem.GetHorizontalRange(bounds); + var (minElevation, maxElevation) = coordinateSystem.GetHeightRange(bounds); + var elevation = coordinateSystem.GetElevation(position); + + return positionH1 >= minH1 && positionH1 <= maxH1 && + positionH2 >= minH2 && positionH2 <= maxH2 && + elevation >= minElevation - elevationTolerance && + elevation <= maxElevation + elevationTolerance; + } + + private static double CalculatePlanarDistance(Point3D pointA, Point3D pointB, ICoordinateSystem coordinateSystem) + { + var (aH1, aH2) = coordinateSystem.GetHorizontalCoords(pointA); + var (bH1, bH2) = coordinateSystem.GetHorizontalCoords(pointB); + return Math.Sqrt(Math.Pow(aH1 - bH1, 2) + Math.Pow(aH2 - bH2, 2)); + } + + private static Point3D CreatePlanarOffsetPoint(Point3D position, ICoordinateSystem coordinateSystem, double deltaH1, double deltaH2) + { + var (h1, h2) = coordinateSystem.GetHorizontalCoords(position); + var elevation = coordinateSystem.GetElevation(position); + return coordinateSystem.CreatePoint(h1 + deltaH1, h2 + deltaH2, elevation); + } + + private static (double min, double max) GetBoundsElevationRange( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + double elevation1 = HostPlanarGridHelper.GetElevation3( + new System.Numerics.Vector3((float)minX, (float)minY, (float)minZ), + adapter); + double elevation2 = HostPlanarGridHelper.GetElevation3( + new System.Numerics.Vector3((float)maxX, (float)maxY, (float)maxZ), + adapter); + return (Math.Min(elevation1, elevation2), Math.Max(elevation1, elevation2)); + } + + private static Point3D CreateHeightProfileSamplePoint( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + CoordinateSystemType coordinateSystemType, + double ratio) + { + var samplePoint = CreateHeightProfileSamplePoint3( + minX, minY, minZ, + maxX, maxY, maxZ, + coordinateSystemType, + ratio); + return new Point3D(samplePoint.X, samplePoint.Y, samplePoint.Z); + } + + private static System.Numerics.Vector3 CreateHeightProfileSamplePoint3( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + CoordinateSystemType coordinateSystemType, + double ratio) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var hostMin = new System.Numerics.Vector3((float)minX, (float)minY, (float)minZ); + var hostMax = new System.Numerics.Vector3((float)maxX, (float)maxY, (float)maxZ); + var (minH1, maxH1, minH2, maxH2) = HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + var (minElevation, _) = GetBoundsElevationRange(minX, minY, minZ, maxX, maxY, maxZ, coordinateSystemType); + double sampleH1 = minH1 + (maxH1 - minH1) * ratio; + double sampleH2 = minH2 + (maxH2 - minH2) * ratio; + return HostPlanarGridHelper.CreateHostPoint3(sampleH1, sampleH2, minElevation, adapter); + } + + private static double GetPickedSurfaceElevation( + double pointX, + double pointY, + double pointZ, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + return HostPlanarGridHelper.GetElevation3( + new System.Numerics.Vector3((float)pointX, (float)pointY, (float)pointZ), + adapter); + } } /// @@ -912,4 +1051,4 @@ namespace NavisworksTransport.PathPlanning Other } -} \ No newline at end of file +} diff --git a/src/PathPlanning/GridMap.cs b/src/PathPlanning/GridMap.cs index 458f317..810bbb6 100644 --- a/src/PathPlanning/GridMap.cs +++ b/src/PathPlanning/GridMap.cs @@ -22,6 +22,11 @@ namespace NavisworksTransport.PathPlanning /// 当前使用的坐标系 /// private readonly ICoordinateSystem _coordinateSystem; + + /// + /// 当前网格采用的宿主坐标系类型。 + /// + public CoordinateSystemType CoordinateSystemType => _coordinateSystem.Type; /// /// 网格宽度(单元格数量) /// @@ -137,6 +142,36 @@ namespace NavisworksTransport.PathPlanning LogManager.Info($"[网格地图] 创建完成: {Width}x{Height}, 坐标系: {_coordinateSystem.Type}"); } + /// + /// 轻量构造函数,主要用于单元测试与脱离 Navisworks 原生边界句柄的纯坐标语义验证。 + /// + public GridMap(Point3D origin, int width, int height, double cellSize, ICoordinateSystem coordinateSystem) + { + if (origin == null) + throw new ArgumentNullException(nameof(origin)); + if (width <= 0) + throw new ArgumentException("网格宽度必须大于0", nameof(width)); + if (height <= 0) + throw new ArgumentException("网格高度必须大于0", nameof(height)); + if (cellSize <= 0) + throw new ArgumentException("网格单元格大小必须大于0", nameof(cellSize)); + if (coordinateSystem == null) + throw new ArgumentNullException(nameof(coordinateSystem)); + + Origin = origin; + Width = width; + Height = height; + CellSize = cellSize; + _coordinateSystem = coordinateSystem; + Bounds = null; + + Cells = new GridCell[Width, Height]; + InitializeCells(); + InitializeHeightCalculation(); + + LogManager.Info($"[网格地图] 轻量创建完成: {Width}x{Height}, 坐标系: {_coordinateSystem.Type}"); + } + /// /// 初始化高度计算组件 /// @@ -195,10 +230,11 @@ namespace NavisworksTransport.PathPlanning } /// - /// 将网格坐标转换为世界2D坐标(纯坐标转换,不涉及高度计算) + /// 将网格坐标转换为世界2D坐标(纯坐标转换,不涉及高度计算)。 + /// 当前网格索引语义是采样节点:WorldToGrid 使用 Round 将点归属到最近节点。 /// /// 网格坐标 - /// 世界2D坐标点(网格左下角),高度设为0 + /// 世界2D坐标点(网格采样节点),高度设为0 public Point3D GridToWorld2D(GridPoint2D gridPosition) { var (originH1, originH2) = _coordinateSystem.GetHorizontalCoords(Origin); @@ -230,6 +266,174 @@ namespace NavisworksTransport.PathPlanning return _coordinateSystem.SetElevation(world2D, elevation); } + /// + /// 获取宿主坐标点在当前网格坐标语义下的高程值。 + /// 注意: + /// - 对 ZUp 文档,高程是 point.Z + /// - 对 YUp 文档,高程是 point.Y + /// + public double GetWorldElevation(Point3D worldPoint) + { + if (worldPoint == null) + { + throw new ArgumentNullException(nameof(worldPoint)); + } + + return _coordinateSystem.GetElevation(worldPoint); + } + + /// + /// 在当前网格坐标语义下,为一个宿主点设置高程。 + /// + public Point3D SetWorldElevation(Point3D worldPoint, double elevation) + { + if (worldPoint == null) + { + throw new ArgumentNullException(nameof(worldPoint)); + } + + return _coordinateSystem.SetElevation(worldPoint, elevation); + } + + /// + /// 根据网格坐标和指定高程创建宿主坐标点。 + /// + public Point3D CreateWorldPoint(GridPoint2D gridPosition, double elevation) + { + Point3D world2D = GridToWorld2D(gridPosition); + return _coordinateSystem.SetElevation(world2D, elevation); + } + + /// + /// 获取网格索引在宿主水平平面上的最近节点归属边界。 + /// 该边界必须与 WorldToGrid 的 Round 语义一致:索引点前后各半个 CellSize。 + /// + public (double minH1, double maxH1, double minH2, double maxH2) GetGridCellPlanarBounds(GridPoint2D gridPosition) + { + Point3D centerPoint = GridToWorld2D(gridPosition); + var (centerH1, centerH2) = _coordinateSystem.GetHorizontalCoords(centerPoint); + return CalculateNearestNodePlanarBounds(centerH1, centerH2, CellSize); + } + + /// + /// 检查宿主空间线段是否穿过指定网格归属区域的内部。 + /// 只检查宿主水平投影;返回相交线段的参数范围,便于调用方按高度层再判定可通行性。 + /// + public bool TryGetSegmentGridCellInteriorIntersection( + Point3D start, + Point3D end, + GridPoint2D gridPosition, + out double enterT, + out double exitT) + { + if (start == null) + throw new ArgumentNullException(nameof(start)); + if (end == null) + throw new ArgumentNullException(nameof(end)); + + enterT = 0.0; + exitT = 1.0; + + var (startH1, startH2) = _coordinateSystem.GetHorizontalCoords(start); + var (endH1, endH2) = _coordinateSystem.GetHorizontalCoords(end); + var (minH1, maxH1, minH2, maxH2) = GetGridCellPlanarBounds(gridPosition); + + return TryGetSegmentRectangleInteriorIntersection( + startH1, + startH2, + endH1, + endH2, + minH1, + maxH1, + minH2, + maxH2, + CellSize, + out enterT, + out exitT); + } + + public static (double minH1, double maxH1, double minH2, double maxH2) CalculateNearestNodePlanarBounds( + double centerH1, + double centerH2, + double cellSize) + { + double halfCell = cellSize * 0.5; + + return ( + centerH1 - halfCell, + centerH1 + halfCell, + centerH2 - halfCell, + centerH2 + halfCell); + } + + public static bool TryGetSegmentRectangleInteriorIntersection( + double startH1, + double startH2, + double endH1, + double endH2, + double minH1, + double maxH1, + double minH2, + double maxH2, + double cellSize, + out double enterT, + out double exitT) + { + enterT = 0.0; + exitT = 1.0; + + double epsilon = Math.Max(cellSize * 1e-9, 1e-9); + minH1 += epsilon; + maxH1 -= epsilon; + minH2 += epsilon; + maxH2 -= epsilon; + + if (minH1 > maxH1 || minH2 > maxH2) + { + return false; + } + + double directionH1 = endH1 - startH1; + double directionH2 = endH2 - startH2; + + return ClipSegmentToAxis(startH1, directionH1, minH1, maxH1, ref enterT, ref exitT) && + ClipSegmentToAxis(startH2, directionH2, minH2, maxH2, ref enterT, ref exitT) && + enterT <= exitT; + } + + private static bool ClipSegmentToAxis( + double start, + double direction, + double min, + double max, + ref double enterT, + ref double exitT) + { + const double parallelTolerance = 1e-12; + + if (Math.Abs(direction) < parallelTolerance) + { + return start >= min && start <= max; + } + + double t1 = (min - start) / direction; + double t2 = (max - start) / direction; + + if (t1 > t2) + { + double temp = t1; + t1 = t2; + t2 = temp; + } + + if (t1 > enterT) + enterT = t1; + if (t2 < exitT) + exitT = t2; + + return enterT <= exitT; + } + /// @@ -344,7 +548,7 @@ namespace NavisworksTransport.PathPlanning /// Z坐标(米) /// 容差(米),默认0.1m /// 符合条件的高度层,如果没有则返回null - public HeightLayer? FindLayerContainingZ(GridPoint2D gridPosition, double z, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) + public HeightLayer? FindLayerContainingElevation(GridPoint2D gridPosition, double elevation, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) { if (!IsValidGridPosition(gridPosition)) return null; @@ -359,7 +563,7 @@ namespace NavisworksTransport.PathPlanning foreach (var layer in cell.HeightLayers) { - double distance = Math.Abs(layer.Z - z); + double distance = Math.Abs(layer.Z - elevation); if (distance < minDistance && distance <= tolerance) { minDistance = distance; @@ -370,6 +574,11 @@ namespace NavisworksTransport.PathPlanning return bestLayer; } + public HeightLayer? FindLayerContainingZ(GridPoint2D gridPosition, double z, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) + { + return FindLayerContainingElevation(gridPosition, z, tolerance); + } + /// /// 获取指定网格位置的所有高度层 /// @@ -441,7 +650,7 @@ namespace NavisworksTransport.PathPlanning /// Z坐标(模型单位) /// Z坐标容差(模型单位),默认0.1 /// 是否可通行 - public bool IsPassableAt3DPoint(GridPoint2D gridPosition, double zCoord, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) + public bool IsPassableAtElevation(GridPoint2D gridPosition, double elevation, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) { if (!IsValidGridPosition(gridPosition)) return false; @@ -463,7 +672,7 @@ namespace NavisworksTransport.PathPlanning double layerMinZ = layer.Z; double layerMaxZ = layer.Z + layer.PassableHeight.GetSpan(); - if (zCoord >= layerMinZ - tolerance && zCoord <= layerMaxZ + tolerance) + if (elevation >= layerMinZ - tolerance && elevation <= layerMaxZ + tolerance) { return true; } @@ -473,6 +682,11 @@ namespace NavisworksTransport.PathPlanning return false; } + public bool IsPassableAt3DPoint(GridPoint2D gridPosition, double zCoord, double tolerance = DEFAULT_LAYER_TOLERANCE_METERS) + { + return IsPassableAtElevation(gridPosition, zCoord, tolerance); + } + /// /// 获取所有通道类型的网格单元 /// diff --git a/src/PathPlanning/GridMapGenerator.cs b/src/PathPlanning/GridMapGenerator.cs index 934c945..a92165a 100644 --- a/src/PathPlanning/GridMapGenerator.cs +++ b/src/PathPlanning/GridMapGenerator.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using Autodesk.Navisworks.Api; using NavisworksTransport.Utils; using NavisworksTransport.Core.Config; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.PathPlanning { @@ -279,8 +281,13 @@ namespace NavisworksTransport.PathPlanning string heightLimitStr = CategoryAttributeManager.GetLogisticsPropertyValue(doorItem, CategoryAttributeManager.LogisticsProperties.HEIGHT_LIMIT); double configuredHeight = CategoryAttributeManager.ParseLogisticsLimitValue(heightLimitStr, "限高"); + var (doorBottomElevation, doorTopElevation) = GetObstacleElevationRange( + bbox.Min.X, bbox.Min.Y, bbox.Min.Z, + bbox.Max.X, bbox.Max.Y, bbox.Max.Z, + gridMap.CoordinateSystemType); + // 计算门的实际物理高度 - double actualDoorHeight = bbox.Max.Z - bbox.Min.Z; + double actualDoorHeight = doorTopElevation - doorBottomElevation; double doorHeight; if (configuredHeight <= 0) @@ -300,9 +307,6 @@ namespace NavisworksTransport.PathPlanning LogManager.Debug($"【门元素处理】门元素 {doorItem.DisplayName} 实际高度: {actualDoorHeight:F2}, 限高:{configuredHeightInModelUnits:F2}, 有效高度: {doorHeight:F2}"); } - // 获取门底部的Z坐标 - double doorBottomZ = bbox.Min.Z; - // 获取门的限速 double doorSpeedLimit = CategoryAttributeManager.GetSpeedLimit(doorItem); LogManager.Info($"[门网格限速] 门物品 '{doorItem.DisplayName}' 限速: {doorSpeedLimit:F2}m/s"); @@ -314,7 +318,7 @@ namespace NavisworksTransport.PathPlanning // 计算门网格的精确世界坐标 var world2D = gridMap.GridToWorld2D(gridPos); - var preciseWorldPosition = new Point3D(world2D.X, world2D.Y, doorBottomZ); + var preciseWorldPosition = gridMap.SetWorldElevation(world2D, doorBottomElevation); // 使用GridCellBuilder创建完整配置的门GridCell var cell = GridCellBuilder.Door(preciseWorldPosition, doorItem, doorSpeedLimit); @@ -331,15 +335,15 @@ namespace NavisworksTransport.PathPlanning var doorLayer = new HeightLayer { Type = "门", - Z = doorBottomZ, - PassableHeight = new HeightInterval(doorBottomZ, doorBottomZ + doorHeight), + Z = doorBottomElevation, + PassableHeight = new HeightInterval(doorBottomElevation, doorBottomElevation + doorHeight), IsWalkable = true, // 关键:门是可通行的 SpeedLimit = doorSpeedLimit, SourceItem = doorItem, IsBoundary = false }; gridMap.AddHeightLayer(gridPos, doorLayer); - LogManager.Info($"[门高度层添加] 网格({gridPos.X},{gridPos.Y}) 高度范围: [{doorBottomZ:F2}, {doorBottomZ + doorHeight:F2}], 可通行高度: {doorHeight:F2}"); + LogManager.Info($"[门高度层添加] 网格({gridPos.X},{gridPos.Y}) 高度范围: [{doorBottomElevation:F2}, {doorBottomElevation + doorHeight:F2}], 可通行高度: {doorHeight:F2}"); } processedCount++; @@ -538,7 +542,13 @@ namespace NavisworksTransport.PathPlanning else { // 邻居无层(Unknown):使用网格底部Z值 - neighborZ = gridMap.Bounds.Min.Z; + neighborZ = gridMap.Bounds != null + ? ResolveBoundsMinElevation( + gridMap.Bounds.Min.X, + gridMap.Bounds.Min.Y, + gridMap.Bounds.Min.Z, + gridMap.CoordinateSystemType) + : 0.0; } // 计算层间高度差 @@ -624,9 +634,9 @@ namespace NavisworksTransport.PathPlanning // 重要修复:使用通道顶面作为垂直扫描的起点,而不是底面 // 从通道构建器的日志可知通道总边界MaxZ = 38.8,这是正确的扫描起点 - double channelTopZ = GetChannelTopZ(worldPos, gridMap); - - points.Add(new Point3D(worldPos.X, worldPos.Y, channelTopZ)); + double channelTopElevation = GetChannelTopZ(worldPos, gridMap); + + points.Add(gridMap.SetWorldElevation(worldPos, channelTopElevation)); } } } @@ -644,28 +654,25 @@ namespace NavisworksTransport.PathPlanning { try { - // 从通道构建器的日志信息可知: - // 通道总边界: [-242.7,-62.3,35.4] - [10.8,70.9,38.8] - // 通道顶面Z坐标为38.8,这应该作为垂直扫描的起点 - - // 方法1: 优先使用网格边界的MaxZ(通道顶面) - double channelTopZ = gridMap.Bounds.Max.Z; - - // 方法2: 备选方案 - 如果网格边界不可用,使用固定的通道高度 - if (channelTopZ <= gridMap.Bounds.Min.Z) + if (gridMap.Bounds != null) { - // 基于日志观察到的通道Z范围 [35.4, 38.8],使用顶面 - channelTopZ = worldPos.Z + 3.4; // 假设通道高度为3.4米 - LogManager.Debug($"[通道顶面计算] 使用备选方案: 底面{worldPos.Z:F2} + 3.4m = 顶面{channelTopZ:F2}"); + var (minElevation, maxElevation) = + GetObstacleElevationRange(gridMap.Bounds.Min, gridMap.Bounds.Max, gridMap); + + if (maxElevation > minElevation) + { + return maxElevation; + } } - - // LogManager.Debug($"[通道顶面计算] 世界位置({worldPos.X:F2}, {worldPos.Y:F2}) -> 通道顶面Z={channelTopZ:F2}"); // 调试完成,日志已删除 - return channelTopZ; + + double fallbackTopElevation = gridMap.GetWorldElevation(worldPos) + 3.4; + LogManager.Debug($"[通道顶面计算] 使用备选方案: 底面高程{gridMap.GetWorldElevation(worldPos):F2} + 3.4m = 顶面高程{fallbackTopElevation:F2}"); + return fallbackTopElevation; } catch (Exception ex) { - LogManager.Warning($"[通道顶面计算] 计算失败: {ex.Message},使用世界位置Z坐标"); - return worldPos.Z; + LogManager.Warning($"[通道顶面计算] 计算失败: {ex.Message},使用世界位置高程"); + return gridMap.GetWorldElevation(worldPos); } } @@ -686,7 +693,7 @@ namespace NavisworksTransport.PathPlanning var interval = kvp.Value; // 将世界坐标转换为网格坐标 - var gridPos = gridMap.WorldToGrid(new Point3D(point.X, point.Y, 0)); + var gridPos = gridMap.WorldToGrid(point); int gridX = gridPos.X; int gridY = gridPos.Y; @@ -1306,7 +1313,10 @@ namespace NavisworksTransport.PathPlanning var filterElapsed = (DateTime.Now - filterStart).TotalMilliseconds; LogManager.Info($"[高性能障碍物处理] 阶段3完成: 从缓存中筛选出 {validItems.Count} 个有效项,耗时: {filterElapsed:F1}ms"); - + + int horizontalOverlapCount = validItems.Count(kvp => IntersectsGridPlanarExtents(kvp.Value.BoundingBox, gridMap)); + LogManager.Info($"[高性能障碍物处理] 阶段3.5: 与当前网格平面范围相交的有效几何体: {horizontalOverlapCount}/{validItems.Count}"); + // 获取可通行网格的实际高度范围信息 var walkableMinZ = channelCoverage.WalkableMinZ; var walkableMaxZ = channelCoverage.WalkableMaxZ; @@ -1325,10 +1335,12 @@ namespace NavisworksTransport.PathPlanning { // 精确高度检查:使用几何体中心点对应的网格高度 var bbox = kvp.Value.BoundingBox; - var centerX = (bbox.Min.X + bbox.Max.X) / 2; - var centerY = (bbox.Min.Y + bbox.Max.Y) / 2; + var centerPoint = new Point3D( + (bbox.Min.X + bbox.Max.X) / 2, + (bbox.Min.Y + bbox.Max.Y) / 2, + (bbox.Min.Z + bbox.Max.Z) / 2); - var centerGridPos = gridMap.WorldToGrid(new Point3D(centerX, centerY, 0)); + var centerGridPos = gridMap.WorldToGrid(centerPoint); if (!gridMap.IsValidGridPosition(centerGridPos)) { return false; @@ -1342,7 +1354,9 @@ namespace NavisworksTransport.PathPlanning var scanMax = walkableMaxZ + scanHeightInModelUnits; // 只有与该网格高度范围重叠的几何体才进入处理 - bool isInRange = !(bbox.Max.Z <= scanMin || bbox.Min.Z > scanMax); + var (obstacleMinElevation, obstacleMaxElevation) = + GetObstacleElevationRange(bbox.Min, bbox.Max, gridMap); + bool isInRange = !(obstacleMaxElevation <= scanMin || obstacleMinElevation > scanMax); return isInRange; }) @@ -1366,9 +1380,12 @@ namespace NavisworksTransport.PathPlanning var gridCalcElapsed = (DateTime.Now - gridCalcStart).TotalMilliseconds; var totalCheckedItems = validItems.Count; var filteredItems = totalCheckedItems - heightCheckedItems.Count; + int overlappingItemsRetained = heightCheckedItems.Count(kvp => IntersectsGridPlanarExtents(kvp.Value.BoundingBox, gridMap)); + int overlappingItemsFiltered = validItems.Count(kvp => IntersectsGridPlanarExtents(kvp.Value.BoundingBox, gridMap)) - overlappingItemsRetained; LogManager.Info($"[高性能障碍物处理] 阶段4完成: 生成 {gridUpdates.Count} 个网格更新操作,耗时: {gridCalcElapsed:F1}ms"); LogManager.Info($"[精确高度检查] 检查了 {totalCheckedItems} 个几何体,过滤掉 {filteredItems} 个高度范围外的几何体,保留 {heightCheckedItems.Count} 个"); + LogManager.Info($"[精确高度检查] 其中与当前网格平面范围相交的有效几何体: 过滤 {overlappingItemsFiltered} 个, 保留 {overlappingItemsRetained} 个"); // 阶段5:网格状态更新(单线程写入,3D高度检查) LogManager.Info("[高性能障碍物处理] 阶段5: 单线程网格状态更新(3D高度检查)"); @@ -1409,8 +1426,7 @@ namespace NavisworksTransport.PathPlanning double layerMaxZ = layer.Z + layer.PassableHeight.GetSpan(); // 障碍物的高度范围 - double obstacleMinZ = bbox.Min.Z; - double obstacleMaxZ = bbox.Max.Z; + var (obstacleMinZ, obstacleMaxZ) = GetObstacleElevationRange(bbox.Min, bbox.Max, gridMap); // 检查是否重叠 bool overlaps = !(obstacleMaxZ <= layerMinZ || obstacleMinZ > layerMaxZ); @@ -1466,6 +1482,138 @@ namespace NavisworksTransport.PathPlanning } + /// + /// 获取世界坐标范围在当前网格坐标语义下的高程区间。 + /// + private (double min, double max) GetObstacleElevationRange(Point3D minPoint, Point3D maxPoint, GridMap gridMap) + { + return GetObstacleElevationRange( + minPoint.X, minPoint.Y, minPoint.Z, + maxPoint.X, maxPoint.Y, maxPoint.Z, + gridMap.CoordinateSystemType); + } + + /// + /// 根据世界坐标最小/最大点,计算其在当前网格坐标语义下覆盖的平面网格范围。 + /// + private List<(int x, int y)> CalculateGridCoverageFromWorldExtents(Point3D minPoint, Point3D maxPoint, GridMap gridMap) + { + return CalculateGridCoverageFromWorldExtents( + minPoint.X, minPoint.Y, minPoint.Z, + maxPoint.X, maxPoint.Y, maxPoint.Z, + gridMap.Origin.X, gridMap.Origin.Y, gridMap.Origin.Z, + gridMap.Width, gridMap.Height, gridMap.CellSize, + gridMap.CoordinateSystemType); + } + + /// + /// 获取世界坐标范围在当前宿主坐标语义下的高程区间。 + /// 仅使用纯数值输入,便于单元测试验证多坐标系语义。 + /// + private (double min, double max) GetObstacleElevationRange( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + double elevation1 = HostPlanarGridHelper.GetElevation3(new Vector3((float)minX, (float)minY, (float)minZ), adapter); + double elevation2 = HostPlanarGridHelper.GetElevation3(new Vector3((float)maxX, (float)maxY, (float)maxZ), adapter); + return (Math.Min(elevation1, elevation2), Math.Max(elevation1, elevation2)); + } + + /// + /// 解析边界最小点在当前宿主坐标语义下的高程值。 + /// 仅修正“高程轴”的解释,不改变原有边界层业务逻辑。 + /// + private static double ResolveBoundsMinElevation( + double minX, double minY, double minZ, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + return HostPlanarGridHelper.GetElevation3(new Vector3((float)minX, (float)minY, (float)minZ), adapter); + } + + /// + /// 根据世界坐标最小/最大点与网格元数据,计算覆盖的平面网格范围。 + /// 仅使用纯数值输入,便于单元测试验证多坐标系语义。 + /// + private List<(int x, int y)> CalculateGridCoverageFromWorldExtents( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + double originX, double originY, double originZ, + int width, int height, double cellSize, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var minPoint = new Vector3((float)minX, (float)minY, (float)minZ); + var maxPoint = new Vector3((float)maxX, (float)maxY, (float)maxZ); + var originPoint = new Vector3((float)originX, (float)originY, (float)originZ); + + var (minH1, minH2) = HostPlanarGridHelper.GetHorizontalCoords3(minPoint, adapter); + var (maxH1, maxH2) = HostPlanarGridHelper.GetHorizontalCoords3(maxPoint, adapter); + var (originH1, originH2) = HostPlanarGridHelper.GetHorizontalCoords3(originPoint, adapter); + + int minGridX = (int)Math.Round((minH1 - originH1) / cellSize); + int minGridY = (int)Math.Round((minH2 - originH2) / cellSize); + int maxGridX = (int)Math.Round((maxH1 - originH1) / cellSize); + int maxGridY = (int)Math.Round((maxH2 - originH2) / cellSize); + + int clampedMinX = Math.Max(0, Math.Min(minGridX, maxGridX)); + int clampedMinY = Math.Max(0, Math.Min(minGridY, maxGridY)); + int clampedMaxX = Math.Min(width - 1, Math.Max(minGridX, maxGridX)); + int clampedMaxY = Math.Min(height - 1, Math.Max(minGridY, maxGridY)); + + if (clampedMaxX < clampedMinX) clampedMaxX = clampedMinX; + if (clampedMaxY < clampedMinY) clampedMaxY = clampedMinY; + + var coveredCells = new List<(int x, int y)>(); + for (int x = clampedMinX; x <= clampedMaxX; x++) + { + for (int y = clampedMinY; y <= clampedMaxY; y++) + { + coveredCells.Add((x, y)); + } + } + + return coveredCells; + } + + /// + /// 判断包围盒在当前宿主平面语义下,是否与当前网格的平面范围真实相交。 + /// 这里只做诊断,不改变原有网格覆盖或裁剪逻辑。 + /// + private bool IntersectsGridPlanarExtents(BoundingBox3D bbox, GridMap gridMap) + { + if (bbox == null || gridMap == null || gridMap.Bounds == null) + { + return false; + } + + var adapter = new HostCoordinateAdapter(gridMap.CoordinateSystemType); + var hostMin = new Vector3((float)bbox.Min.X, (float)bbox.Min.Y, (float)bbox.Min.Z); + var hostMax = new Vector3((float)bbox.Max.X, (float)bbox.Max.Y, (float)bbox.Max.Z); + var gridHostMin = new Vector3((float)gridMap.Bounds.Min.X, (float)gridMap.Bounds.Min.Y, (float)gridMap.Bounds.Min.Z); + var gridHostMax = new Vector3((float)gridMap.Bounds.Max.X, (float)gridMap.Bounds.Max.Y, (float)gridMap.Bounds.Max.Z); + + var (bboxMinH1, bboxMaxH1, bboxMinH2, bboxMaxH2) = HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + var (gridMinH1, gridMaxH1, gridMinH2, gridMaxH2) = HostPlanarGridHelper.GetHorizontalRange3(gridHostMin, gridHostMax, adapter); + + bool overlapH1 = !(bboxMaxH1 < gridMinH1 || bboxMinH1 > gridMaxH1); + bool overlapH2 = !(bboxMaxH2 < gridMinH2 || bboxMinH2 > gridMaxH2); + return overlapH1 && overlapH2; + } + + private static (double minH1, double maxH1, double minH2, double maxH2) GetPlanarRange( + Point3D minPoint, + Point3D maxPoint, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var hostMin = new Vector3((float)minPoint.X, (float)minPoint.Y, (float)minPoint.Z); + var hostMax = new Vector3((float)maxPoint.X, (float)maxPoint.Y, (float)maxPoint.Z); + return HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + } + /// /// 计算包围盒在网格上覆盖的单元坐标 /// 严格按照"网格坐标代表左下角"的设计原则进行边界处理 @@ -1475,36 +1623,88 @@ namespace NavisworksTransport.PathPlanning /// 覆盖的网格单元坐标列表 private List<(int x, int y)> CalculateBoundingBoxGridCoverage(BoundingBox3D bbox, GridMap gridMap) { - var coveredCells = new List<(int x, int y)>(); - try { - // 直接使用WorldToGrid进行坐标转换 - var minGridPos = gridMap.WorldToGrid(new Point3D(bbox.Min.X, bbox.Min.Y, 0)); - var maxGridPos = gridMap.WorldToGrid(new Point3D(bbox.Max.X, bbox.Max.Y, 0)); - - // 确保坐标在有效范围内 - int minX = Math.Max(0, minGridPos.X); - int minY = Math.Max(0, minGridPos.Y); - int maxX = Math.Min(gridMap.Width - 1, maxGridPos.X); - int maxY = Math.Min(gridMap.Height - 1, maxGridPos.Y); - - // 确保至少覆盖一个网格(防止空包围盒) - if (maxX < minX) maxX = minX; - if (maxY < minY) maxY = minY; - - // 遍历覆盖的矩形区域 - for (int x = minX; x <= maxX; x++) - { - for (int y = minY; y <= maxY; y++) - { - coveredCells.Add((x, y)); - } - } + return CalculateGridCoverageFromWorldExtents(bbox.Min, bbox.Max, gridMap); } catch (Exception ex) { LogManager.Debug($"[包围盒覆盖计算] 计算失败: {ex.Message}"); + return new List<(int x, int y)>(); + } + } + + /// + /// 根据门包围盒、限宽和当前宿主平面语义,计算门开口在网格上的覆盖范围。 + /// 关键约束: + /// - 仅替换“哪两个轴组成水平平面”的解释,不改变历史限宽业务规则。 + /// - 门宽仍取平面上较大的那一维;限宽仍只裁剪门宽方向。 + /// + private List<(int x, int y)> CalculateDoorOpeningCoverageFromWorldExtents( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + double limitWidthInModelUnits, + double originX, double originY, double originZ, + int width, int height, double cellSize, + CoordinateSystemType coordinateSystemType) + { + var adapter = new HostCoordinateAdapter(coordinateSystemType); + var hostMin = new Vector3((float)minX, (float)minY, (float)minZ); + var hostMax = new Vector3((float)maxX, (float)maxY, (float)maxZ); + var hostOrigin = new Vector3((float)originX, (float)originY, (float)originZ); + var (minH1, maxH1, minH2, maxH2) = HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + var (originH1, originH2) = HostPlanarGridHelper.GetHorizontalCoords3(hostOrigin, adapter); + + double planarWidth = maxH1 - minH1; + double planarDepth = maxH2 - minH2; + bool widthAlongH1 = planarWidth > planarDepth; + double doorWidth = Math.Max(planarWidth, planarDepth); + double effectiveWidth = Math.Min(limitWidthInModelUnits, doorWidth); + + double openingMinH1; + double openingMaxH1; + double openingMinH2; + double openingMaxH2; + + if (widthAlongH1) + { + double centerH1 = (minH1 + maxH1) / 2.0; + double halfWidth = effectiveWidth / 2.0; + openingMinH1 = centerH1 - halfWidth; + openingMaxH1 = centerH1 + halfWidth; + openingMinH2 = minH2; + openingMaxH2 = maxH2; + } + else + { + double centerH2 = (minH2 + maxH2) / 2.0; + double halfWidth = effectiveWidth / 2.0; + openingMinH1 = minH1; + openingMaxH1 = maxH1; + openingMinH2 = centerH2 - halfWidth; + openingMaxH2 = centerH2 + halfWidth; + } + + int minGridX = (int)Math.Round((openingMinH1 - originH1) / cellSize); + int minGridY = (int)Math.Round((openingMinH2 - originH2) / cellSize); + int maxGridX = (int)Math.Round((openingMaxH1 - originH1) / cellSize); + int maxGridY = (int)Math.Round((openingMaxH2 - originH2) / cellSize); + + int clampedMinX = Math.Max(0, Math.Min(minGridX, maxGridX)); + int clampedMinY = Math.Max(0, Math.Min(minGridY, maxGridY)); + int clampedMaxX = Math.Min(width - 1, Math.Max(minGridX, maxGridX)); + int clampedMaxY = Math.Min(height - 1, Math.Max(minGridY, maxGridY)); + + if (clampedMaxX < clampedMinX) clampedMaxX = clampedMinX; + if (clampedMaxY < clampedMinY) clampedMaxY = clampedMinY; + + var coveredCells = new List<(int x, int y)>(); + for (int x = clampedMinX; x <= clampedMaxX; x++) + { + for (int y = clampedMinY; y <= clampedMaxY; y++) + { + coveredCells.Add((x, y)); + } } return coveredCells; @@ -1545,74 +1745,35 @@ namespace NavisworksTransport.PathPlanning double metersToModelUnits = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); double limitWidthInModelUnits = limitWidthMeters * metersToModelUnits; - // 4. 分析门的方向:哪个数大,哪个就是门的宽度 - double width = bbox.Max.X - bbox.Min.X; // X方向宽度 - double depth = bbox.Max.Y - bbox.Min.Y; // Y方向深度 - double doorWidth = Math.Max(width, depth); // 门宽(较大的那个方向) - bool isDoorWidthInXDirection = width > depth; // 门宽是否在X方向 - - LogManager.Debug($"【门开口计算】{doorItem.DisplayName} - 尺寸: {width:F2}x{depth:F2}, 门宽: {doorWidth:F2}, 门宽方向: {(isDoorWidthInXDirection ? "X" : "Y")}, 限宽: {limitWidthMeters}m"); - - // 5. 限宽约束:限宽不能超过门宽 - double effectiveWidth = Math.Min(limitWidthInModelUnits, doorWidth); - + var adapter = new HostCoordinateAdapter(gridMap.CoordinateSystemType); + var hostMin = new Vector3((float)bbox.Min.X, (float)bbox.Min.Y, (float)bbox.Min.Z); + var hostMax = new Vector3((float)bbox.Max.X, (float)bbox.Max.Y, (float)bbox.Max.Z); + var (minH1, maxH1, minH2, maxH2) = HostPlanarGridHelper.GetHorizontalRange3(hostMin, hostMax, adapter); + double planarWidth = maxH1 - minH1; + double planarDepth = maxH2 - minH2; + double doorWidth = Math.Max(planarWidth, planarDepth); + + coveredCells = CalculateDoorOpeningCoverageFromWorldExtents( + bbox.Min.X, bbox.Min.Y, bbox.Min.Z, + bbox.Max.X, bbox.Max.Y, bbox.Max.Z, + limitWidthInModelUnits, + gridMap.Origin.X, gridMap.Origin.Y, gridMap.Origin.Z, + gridMap.Width, gridMap.Height, gridMap.CellSize, + gridMap.CoordinateSystemType); + if (limitWidthInModelUnits > doorWidth) { LogManager.Info($"【门开口计算】{doorItem.DisplayName} 限宽({limitWidthMeters}m)超过门宽({doorWidth / metersToModelUnits:F2}m),按门宽处理"); } - - // 6. 直接计算开口区域坐标范围 - double minX, maxX, minY, maxY; - - if (isDoorWidthInXDirection) - { - // 门宽在X方向:在X方向应用限宽,Y方向保持完整 - double centerX = (bbox.Min.X + bbox.Max.X) / 2; - double halfWidth = effectiveWidth / 2; - minX = centerX - halfWidth; - maxX = centerX + halfWidth; - minY = bbox.Min.Y; - maxY = bbox.Max.Y; - } - else - { - // 门宽在Y方向:在Y方向应用限宽,X方向保持完整 - double centerY = (bbox.Min.Y + bbox.Max.Y) / 2; - double halfWidth = effectiveWidth / 2; - minX = bbox.Min.X; - maxX = bbox.Max.X; - minY = centerY - halfWidth; - maxY = centerY + halfWidth; - } - - LogManager.Debug($"【门开口计算】{doorItem.DisplayName} 开口区域坐标: [{minX:F2},{minY:F2}]-[{maxX:F2},{maxY:F2}]"); - - // 7. 将开口区域坐标转换为网格坐标(严格按照左下角原则) - var minGridPos = gridMap.WorldToGrid(new Point3D(minX, minY, 0)); - - var maxGridPos = gridMap.WorldToGrid(new Point3D(maxX, maxY, 0)); - - // 确保坐标在有效范围内 - int gridMinX = Math.Max(0, minGridPos.X); - int gridMinY = Math.Max(0, minGridPos.Y); - int gridMaxX = Math.Min(gridMap.Width - 1, maxGridPos.X); - int gridMaxY = Math.Min(gridMap.Height - 1, maxGridPos.Y); - - // 确保至少有1个网格可通行 - if (gridMaxX < gridMinX) gridMaxX = gridMinX; - if (gridMaxY < gridMinY) gridMaxY = gridMinY; - - // 8. 生成覆盖的网格单元列表 - for (int x = gridMinX; x <= gridMaxX; x++) + if (coveredCells.Any()) { - for (int y = gridMinY; y <= gridMaxY; y++) - { - coveredCells.Add((x, y)); - } + int gridMinX = coveredCells.Min(cell => cell.x); + int gridMinY = coveredCells.Min(cell => cell.y); + int gridMaxX = coveredCells.Max(cell => cell.x); + int gridMaxY = coveredCells.Max(cell => cell.y); + LogManager.Info($"【门开口计算】{doorItem.DisplayName} 成功处理 - 门宽: {doorWidth / metersToModelUnits:F2}m, 限宽: {limitWidthMeters}m, 开口覆盖: {coveredCells.Count}个网格 (范围: [{gridMinX},{gridMinY}]-[{gridMaxX},{gridMaxY}])"); } - - LogManager.Info($"【门开口计算】{doorItem.DisplayName} 成功处理 - 门宽: {doorWidth / metersToModelUnits:F2}m, 限宽: {limitWidthMeters}m, 开口覆盖: {coveredCells.Count}个网格 (范围: [{gridMinX},{gridMinY}]-[{gridMaxX},{gridMaxY}])"); } catch (Exception ex) { diff --git a/src/PathPlanning/OptimizedHeightCalculator.cs b/src/PathPlanning/OptimizedHeightCalculator.cs index 7683e85..87e2af2 100644 --- a/src/PathPlanning/OptimizedHeightCalculator.cs +++ b/src/PathPlanning/OptimizedHeightCalculator.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.PathPlanning { @@ -14,6 +15,7 @@ namespace NavisworksTransport.PathPlanning private readonly Dictionary _heightSamples; private readonly double _sampleInterval; private readonly ChannelHeightDetector _heightDetector; + private readonly ICoordinateSystem _coordinateSystem; /// /// 构造函数 @@ -23,7 +25,8 @@ namespace NavisworksTransport.PathPlanning { _heightSamples = new Dictionary(); _sampleInterval = sampleInterval; - _heightDetector = new ChannelHeightDetector(); + _coordinateSystem = CoordinateSystemManager.Instance.Current; + _heightDetector = new ChannelHeightDetector(_coordinateSystem); LogManager.Info($"[优化高度计算] 初始化完成,采样间隔: {sampleInterval}米"); } @@ -50,8 +53,9 @@ namespace NavisworksTransport.PathPlanning // 计算采样网格 double sampleIntervalModel = _sampleInterval * 39.37; // 转换为模型单位 - int samplesX = (int)Math.Ceiling((bounds.Max.X - bounds.Min.X) / sampleIntervalModel); - int samplesY = (int)Math.Ceiling((bounds.Max.Y - bounds.Min.Y) / sampleIntervalModel); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + int samplesX = (int)Math.Ceiling((maxH1 - minH1) / sampleIntervalModel); + int samplesY = (int)Math.Ceiling((maxH2 - minH2) / sampleIntervalModel); LogManager.Info($"[优化高度计算] 采样网格: {samplesX}x{samplesY} = {samplesX * samplesY}个采样点"); @@ -63,13 +67,13 @@ namespace NavisworksTransport.PathPlanning { for (int y = 0; y < samplesY; y++) { - double worldX = bounds.Min.X + x * sampleIntervalModel; - double worldY = bounds.Min.Y + y * sampleIntervalModel; + double worldH1 = minH1 + x * sampleIntervalModel; + double worldH2 = minH2 + y * sampleIntervalModel; - var sampleKey = GenerateSampleKey(worldX, worldY); + var sampleKey = GenerateSampleKey(worldH1, worldH2); // 只对通道区域进行精确计算 - var position = new Point3D(worldX, worldY, 0); + var position = _coordinateSystem.CreatePoint(worldH1, worldH2, 0); var containingChannel = FindNearestChannel(position, channels); if (containingChannel != null) @@ -156,7 +160,8 @@ namespace NavisworksTransport.PathPlanning // 对于大多数建筑元素,表面接近顶面 double surfaceRatio = DetermineChannelSurfaceRatio(channel); - double height = bbox.Min.Z + (bbox.Max.Z - bbox.Min.Z) * surfaceRatio; + var (minElevation, maxElevation) = _coordinateSystem.GetHeightRange(bbox); + double height = minElevation + (maxElevation - minElevation) * surfaceRatio; return height; } @@ -166,7 +171,7 @@ namespace NavisworksTransport.PathPlanning LogManager.Debug($"[优化高度计算] 几何高度计算失败: {ex.Message}"); } - return position.Z; + return _coordinateSystem.GetElevation(position); } /// @@ -187,8 +192,9 @@ namespace NavisworksTransport.PathPlanning var pickResult = view.PickItemFromPoint((int)screenPoint.X, (int)screenPoint.Y); if (pickResult?.Point != null && pickResult.ModelItem == channel) { - LogManager.Debug($"[优化高度计算] View API精确检测: {worldPosition} -> {pickResult.Point.Z:F3}"); - return pickResult.Point.Z; + double detectedElevation = _coordinateSystem.GetElevation(pickResult.Point); + LogManager.Debug($"[优化高度计算] View API精确检测: {worldPosition} -> {detectedElevation:F3}"); + return detectedElevation; } } } @@ -245,13 +251,15 @@ namespace NavisworksTransport.PathPlanning var bbox = channel.Geometry.BoundingBox; // 检查位置是否在通道边界内(带容差) - if (position.X >= bbox.Min.X - 50 && position.X <= bbox.Max.X + 50 && - position.Y >= bbox.Min.Y - 50 && position.Y <= bbox.Max.Y + 50) + var (positionH1, positionH2) = _coordinateSystem.GetHorizontalCoords(position); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bbox); + if (positionH1 >= minH1 - 50 && positionH1 <= maxH1 + 50 && + positionH2 >= minH2 - 50 && positionH2 <= maxH2 + 50) { // 计算到边界中心的距离 - double centerX = (bbox.Min.X + bbox.Max.X) / 2; - double centerY = (bbox.Min.Y + bbox.Max.Y) / 2; - double distance = Math.Sqrt(Math.Pow(position.X - centerX, 2) + Math.Pow(position.Y - centerY, 2)); + double centerH1 = (minH1 + maxH1) / 2; + double centerH2 = (minH2 + maxH2) / 2; + double distance = Math.Sqrt(Math.Pow(positionH1 - centerH1, 2) + Math.Pow(positionH2 - centerH2, 2)); if (distance < minDistance) { @@ -368,4 +376,4 @@ namespace NavisworksTransport.PathPlanning return $"采样点数: {_heightSamples.Count}, 采样间隔: {_sampleInterval}米"; } } -} \ No newline at end of file +} diff --git a/src/PathPlanning/PathOptimizer.cs b/src/PathPlanning/PathOptimizer.cs index e5cd381..4df1a70 100644 --- a/src/PathPlanning/PathOptimizer.cs +++ b/src/PathPlanning/PathOptimizer.cs @@ -118,7 +118,7 @@ namespace NavisworksTransport.PathPlanning } else { - optimizedPoints = SimplifyCollinearPoints(optimizedPoints); + optimizedPoints = SimplifyCollinearPoints(optimizedPoints, gridMap); LogManager.Info($"[路径优化] 简化完成,点数:{originalCount} -> {optimizedPoints.Count}"); } } @@ -170,7 +170,7 @@ namespace NavisworksTransport.PathPlanning // 3D场景:保留同网格不同高度的点 bool hasGridChange = i == 0 || !gridPath[i].Equals(gridPath[i-1]); bool hasSpeedChange = i > 0 && HasSpeedLimitChange(points[i-1], points[i]); - bool hasHeightChange = i > 0 && Math.Abs(points[i].Position.Z - points[i-1].Position.Z) > 1e-6; + bool hasHeightChange = i > 0 && HasHeightChange(points[i], points[i - 1], gridMap); bool shouldKeep = hasGridChange || hasSpeedChange || hasHeightChange; @@ -213,7 +213,8 @@ namespace NavisworksTransport.PathPlanning bool hasHeightChange = HasHeightChange( dedupedPoints[i-2], dedupedPoints[i-1], - dedupedPoints[i] + dedupedPoints[i], + gridMap ); // 检查限速变化 @@ -264,7 +265,7 @@ namespace NavisworksTransport.PathPlanning /// /// 原始路径点列表 /// 简化后的路径点列表 - private List SimplifyCollinearPoints(List points) + private List SimplifyCollinearPoints(List points, GridMap gridMap = null) { if (points.Count <= 2) return points; @@ -281,7 +282,7 @@ namespace NavisworksTransport.PathPlanning var nextPoint = points[i + 1]; // 检查是否在同一直线上 - bool isCollinear = IsCollinear(prevPoint.Position, currPoint.Position, nextPoint.Position); + bool isCollinear = IsCollinear(prevPoint.Position, currPoint.Position, nextPoint.Position, gridMap); // 🔥 关键修复:检查限速变化,即使共线也不能移除限速边界点 bool hasSpeedLimitChange = HasSpeedLimitChange(prevPoint, currPoint, nextPoint); @@ -316,10 +317,12 @@ namespace NavisworksTransport.PathPlanning /// 第一个路径点 /// 第二个路径点 /// 如果有高度变化返回true,需要保留中间点 - private bool HasHeightChange(PathPoint point1, PathPoint point2) + private bool HasHeightChange(PathPoint point1, PathPoint point2, GridMap gridMap = null) { const double heightTolerance = 1e-6; // 仅覆盖浮点数精度误差 - return Math.Abs(point1.Position.Z - point2.Position.Z) > heightTolerance; + double elevation1 = GetElevation(point1.Position, gridMap); + double elevation2 = GetElevation(point2.Position, gridMap); + return Math.Abs(elevation1 - elevation2) > heightTolerance; } /// @@ -329,13 +332,17 @@ namespace NavisworksTransport.PathPlanning /// 中间点的3D坐标 /// 第三个点的3D坐标 /// 如果有高度变化返回true,不能跳过中间点 - private bool HasHeightChange(Point3D p1, Point3D p2, Point3D p3) + private bool HasHeightChange(Point3D p1, Point3D p2, Point3D p3, GridMap gridMap = null) { const double heightTolerance = 1e-6; // 仅覆盖浮点数精度误差 // 检查任意两点间是否有高度变化 - if (Math.Abs(p1.Z - p2.Z) > heightTolerance || - Math.Abs(p2.Z - p3.Z) > heightTolerance) + double elevation1 = GetElevation(p1, gridMap); + double elevation2 = GetElevation(p2, gridMap); + double elevation3 = GetElevation(p3, gridMap); + + if (Math.Abs(elevation1 - elevation2) > heightTolerance || + Math.Abs(elevation2 - elevation3) > heightTolerance) { return true; } @@ -349,9 +356,9 @@ namespace NavisworksTransport.PathPlanning /// 中间路径点 /// 第三个路径点 /// 如果有高度变化返回true,不能跳过中间点 - private bool HasHeightChange(PathPoint point1, PathPoint point2, PathPoint point3) + private bool HasHeightChange(PathPoint point1, PathPoint point2, PathPoint point3, GridMap gridMap = null) { - return HasHeightChange(point1.Position, point2.Position, point3.Position); + return HasHeightChange(point1.Position, point2.Position, point3.Position, gridMap); } /// @@ -395,25 +402,41 @@ namespace NavisworksTransport.PathPlanning /// 第二个点(中间点) /// 第三个点 /// 如果三点在同一直线上且方向一致返回true - private bool IsCollinear(Point3D p1, Point3D p2, Point3D p3) + private bool IsCollinear(Point3D p1, Point3D p2, Point3D p3, GridMap gridMap = null) { const double tolerance = COLLINEAR_TOLERANCE_METERS; // 容差值,单位:模型单位 // 🔥 修复:使用统一的高度检查函数 - if (HasHeightChange(p1, p2, p3)) + if (HasHeightChange(p1, p2, p3, gridMap)) { LogManager.Debug($"[共线检测] ❌ 拒绝Z方向变化:({p1.X:F3},{p1.Y:F3},{p1.Z:F3}) -> ({p2.X:F3},{p2.Y:F3},{p2.Z:F3}) -> ({p3.X:F3},{p3.Y:F3},{p3.Z:F3})"); return false; } - // 🔥 关键修复:分别检查两个线段的方向 - // 线段1: p1 -> p2 - double dx12 = p2.X - p1.X; - double dy12 = p2.Y - p1.Y; - - // 线段2: p2 -> p3 - double dx23 = p3.X - p2.X; - double dy23 = p3.Y - p2.Y; + double dx12; + double dy12; + double dx23; + double dy23; + + if (gridMap != null) + { + var g1 = gridMap.WorldToGrid(p1); + var g2 = gridMap.WorldToGrid(p2); + var g3 = gridMap.WorldToGrid(p3); + + dx12 = g2.X - g1.X; + dy12 = g2.Y - g1.Y; + dx23 = g3.X - g2.X; + dy23 = g3.Y - g2.Y; + } + else + { + // 回退到历史 ZUp 语义,仅用于未提供网格地图的旧调用链。 + dx12 = p2.X - p1.X; + dy12 = p2.Y - p1.Y; + dx23 = p3.X - p2.X; + dy23 = p3.Y - p2.Y; + } // 🔥 修复:使用更严格的容差检查真正的重复点(坐标完全相同) const double duplicatePointTolerance = DUPLICATE_POINT_TOLERANCE_METERS; // 用很小的值检查真正的重复点 @@ -453,6 +476,11 @@ namespace NavisworksTransport.PathPlanning } } + private static double GetElevation(Point3D point, GridMap gridMap) + { + return gridMap?.GetWorldElevation(point) ?? point.Z; + } + /// /// 创建优化后的路径对象 /// @@ -521,4 +549,4 @@ namespace NavisworksTransport.PathPlanning #endregion } -} \ No newline at end of file +} diff --git a/src/PathPlanning/RailGeometryHelper.cs b/src/PathPlanning/RailGeometryHelper.cs index e74e717..3a3b932 100644 --- a/src/PathPlanning/RailGeometryHelper.cs +++ b/src/PathPlanning/RailGeometryHelper.cs @@ -106,9 +106,14 @@ namespace NavisworksTransport.PathPlanning public class RailBaselinePath { /// - /// 完整的基准路径点列表(下表面中心线) + /// 完整的基准路径点列表 /// public List PathPoints { get; set; } + + /// + /// 基准路径的定义方式 + /// + public RailPathDefinitionMode DefinitionMode { get; set; } /// /// 路径总长度(模型单位) @@ -162,13 +167,28 @@ namespace NavisworksTransport.PathPlanning public static RailBaselinePath ExtractRailBaselinePath( ModelItem railModel, double samplingInterval = 0.5) + { + return ExtractRailBaselinePath(railModel, RailPathDefinitionMode.RailCenterLine, samplingInterval); + } + + /// + /// 步骤1: 提取空轨完整基准路径(用于可视化和吸附) + /// + /// 空轨模型项 + /// 基准线定义模式 + /// 采样间隔(模型单位) + /// 空轨基准路径信息 + public static RailBaselinePath ExtractRailBaselinePath( + ModelItem railModel, + RailPathDefinitionMode definitionMode, + double samplingInterval) { try { - LogManager.Debug($"[空轨] 步骤1: 开始提取完整基准路径: {railModel.DisplayName}"); + LogManager.Debug($"[空轨] 步骤1: 开始提取完整基准路径: {railModel.DisplayName}, 模式={definitionMode}"); // 检查缓存 - var cacheKey = $"baseline_{railModel.DisplayName}_{railModel.GetHashCode()}"; + var cacheKey = $"baseline_{definitionMode}_{railModel.DisplayName}_{railModel.GetHashCode()}"; if (_baselinePathCache.TryGetValue(cacheKey, out var cachedPath)) { // 检查缓存是否过期 @@ -185,7 +205,9 @@ namespace NavisworksTransport.PathPlanning } // 使用 OBB 方法提取基准路径 - var pathPoints = ExtractRailBottomCenterLineByOBB(railModel, samplingInterval); + var pathPoints = definitionMode == RailPathDefinitionMode.RailCenterLine + ? ExtractRailCenterLineByOBB(railModel, samplingInterval) + : ExtractRailBottomCenterLineByOBB(railModel, samplingInterval); LogManager.Debug($"[空轨] 生成 {pathPoints.Count} 个采样点"); // 计算路径长度 @@ -195,6 +217,7 @@ namespace NavisworksTransport.PathPlanning var baselinePath = new RailBaselinePath { PathPoints = pathPoints, + DefinitionMode = definitionMode, TotalLength = totalLength, StartPoint = pathPoints.First(), EndPoint = pathPoints.Last(), @@ -205,7 +228,7 @@ namespace NavisworksTransport.PathPlanning // 加入缓存 _baselinePathCache[cacheKey] = baselinePath; - LogManager.Debug($"[空轨] 步骤1完成: 基准路径总长度 {totalLength:F2},{pathPoints.Count}个采样点"); + LogManager.Debug($"[空轨] 步骤1完成: 基准路径总长度 {totalLength:F2},{pathPoints.Count}个采样点,模式={definitionMode}"); return baselinePath; } catch (Exception ex) @@ -241,19 +264,8 @@ namespace NavisworksTransport.PathPlanning LogManager.Debug($"[空轨] 步骤3: 吸附点到基准路径,输入点=({inputPoint.X:F2}, {inputPoint.Y:F2}, {inputPoint.Z:F2})"); - Point3D nearestPoint = baselinePath.PathPoints[0]; - double minDistance = double.MaxValue; - - // 找到最近的路径点 - foreach (var pathPoint in baselinePath.PathPoints) - { - double distance = CalculateDistance2D(inputPoint, pathPoint); - if (distance < minDistance) - { - minDistance = distance; - nearestPoint = pathPoint; - } - } + Point3D nearestPoint = FindNearestPointOnPath(baselinePath.PathPoints, inputPoint); + double minDistance = CalculateDistance2D(inputPoint, nearestPoint); // 检查是否在最大吸附距离内 if (minDistance <= maxSnapDistance) @@ -361,6 +373,66 @@ namespace NavisworksTransport.PathPlanning return nearestIndex; } + /// + /// 找到路径上距离目标点最近的投影点 + /// + private static Point3D FindNearestPointOnPath(List pathPoints, Point3D targetPoint) + { + if (pathPoints == null || pathPoints.Count == 0) + { + return new Point3D(); + } + + if (pathPoints.Count == 1) + { + return pathPoints[0]; + } + + Point3D nearestPoint = pathPoints[0]; + double minDistance = double.MaxValue; + + for (int i = 0; i < pathPoints.Count - 1; i++) + { + var projectedPoint = ProjectPointToSegment(targetPoint, pathPoints[i], pathPoints[i + 1]); + double distance = CalculateDistance2D(targetPoint, projectedPoint); + if (distance < minDistance) + { + minDistance = distance; + nearestPoint = projectedPoint; + } + } + + return nearestPoint; + } + + /// + /// 将点投影到线段上 + /// + private static Point3D ProjectPointToSegment(Point3D point, Point3D segmentStart, Point3D segmentEnd) + { + double segmentX = segmentEnd.X - segmentStart.X; + double segmentY = segmentEnd.Y - segmentStart.Y; + double segmentZ = segmentEnd.Z - segmentStart.Z; + + double segmentLengthSquared = segmentX * segmentX + segmentY * segmentY + segmentZ * segmentZ; + if (segmentLengthSquared < 1e-9) + { + return segmentStart; + } + + double pointX = point.X - segmentStart.X; + double pointY = point.Y - segmentStart.Y; + double pointZ = point.Z - segmentStart.Z; + + double projection = (pointX * segmentX + pointY * segmentY + pointZ * segmentZ) / segmentLengthSquared; + projection = Math.Max(0.0, Math.Min(1.0, projection)); + + return new Point3D( + segmentStart.X + projection * segmentX, + segmentStart.Y + projection * segmentY, + segmentStart.Z + projection * segmentZ); + } + /// /// 重采样路径 /// @@ -395,8 +467,10 @@ namespace NavisworksTransport.PathPlanning /// public static void ClearBaselinePathCache(ModelItem railModel) { - var cacheKey = $"baseline_{railModel.DisplayName}_{railModel.GetHashCode()}"; - _baselinePathCache.Remove(cacheKey); + var legacyCacheKey = $"baseline_{RailPathDefinitionMode.LegacySuspensionPoint}_{railModel.DisplayName}_{railModel.GetHashCode()}"; + var centerCacheKey = $"baseline_{RailPathDefinitionMode.RailCenterLine}_{railModel.DisplayName}_{railModel.GetHashCode()}"; + _baselinePathCache.Remove(legacyCacheKey); + _baselinePathCache.Remove(centerCacheKey); LogManager.Debug($"[空轨] 已清除模型 {railModel.DisplayName} 的基准路径缓存"); } @@ -465,7 +539,114 @@ namespace NavisworksTransport.PathPlanning } /// - /// 使用 OBB 包围体提取空轨下表面中心线(新方法,支持倾斜) + /// 使用 OBB 包围体提取空轨参考中心线 + /// + /// 空轨模型项 + /// 采样间隔(模型单位),默认0.5 + /// 路径点列表(轨道参考中心线) + public static List ExtractRailCenterLineByOBB( + ModelItem railModel, + double samplingInterval = 0.5) + { + try + { + LogManager.Debug($"[空轨] 使用 OBB 方法提取轨道参考中心线: {railModel.DisplayName}"); + + var obb = ComputeOrientedBoundingBox(railModel); + var geometryCenterLine = TryExtractDualRailCenterLine(railModel, obb, samplingInterval); + if (geometryCenterLine != null && geometryCenterLine.Count >= 2) + { + LogManager.Debug($"[空轨] 双轨几何中心线提取完成: 采样点数={geometryCenterLine.Count}"); + return geometryCenterLine; + } + + LogManager.Warning("[空轨] 双轨几何中心线提取失败,退回 OBB 主轴中线"); + return BuildCenterLineFromOffsets(obb, 0.0, 0.0, samplingInterval); + } + catch (Exception ex) + { + LogManager.Error($"[空轨] 提取轨道参考中心线失败: {ex.Message}"); + throw; + } + } + + private static List TryExtractDualRailCenterLine( + ModelItem railModel, + OrientedBoundingBox obb, + double samplingInterval) + { + var triangles = GeometryHelper.ExtractTriangles(new[] { railModel }); + var pointSet = new HashSet(); + foreach (var triangle in triangles) + { + pointSet.Add(triangle.Point1); + pointSet.Add(triangle.Point2); + pointSet.Add(triangle.Point3); + } + + var points = pointSet.ToList(); + if (points.Count < 20) + { + LogManager.Warning($"[空轨] 双轨几何中心线样本不足: {points.Count}"); + return null; + } + + int verticalAxisIndex = GetMostVerticalAxisIndex(obb); + int acrossAxisIndex = verticalAxisIndex == 1 ? 2 : 1; + + var localPoints = points + .Select(point => ProjectPointToObbLocal(point, obb)) + .ToList(); + + var railBandPoints = ExtractDominantRailBand(localPoints, verticalAxisIndex); + if (railBandPoints.Count < 10) + { + LogManager.Warning($"[空轨] 双轨几何中心线有效轨面点不足: {railBandPoints.Count}"); + return null; + } + + double centerAcross = EstimateDualRailAcrossCenter(railBandPoints, acrossAxisIndex, obb.GetAxisLength(acrossAxisIndex)); + double centerVertical = railBandPoints.Average(point => GetAxisValue(point, verticalAxisIndex)); + + LogManager.Debug($"[空轨] 双轨几何中心估计: across={centerAcross:F3}, vertical={centerVertical:F3}, verticalAxis={verticalAxisIndex}, acrossAxis={acrossAxisIndex}"); + return BuildCenterLineFromOffsets(obb, centerAcross, centerVertical, samplingInterval, acrossAxisIndex, verticalAxisIndex); + } + + private static List BuildCenterLineFromOffsets( + OrientedBoundingBox obb, + double acrossOffset, + double verticalOffset, + double samplingInterval, + int acrossAxisIndex = 1, + int verticalAxisIndex = 2) + { + var principalAxis = obb.GetAxis(0); + var principalLength = obb.GetAxisLength(0); + var halfLength = principalLength / 2.0; + var acrossAxis = obb.GetAxis(acrossAxisIndex); + var verticalAxis = obb.GetAxis(verticalAxisIndex); + + var offsetCenter = new Point3D( + obb.Center.X + acrossAxis.X * acrossOffset + verticalAxis.X * verticalOffset, + obb.Center.Y + acrossAxis.Y * acrossOffset + verticalAxis.Y * verticalOffset, + obb.Center.Z + acrossAxis.Z * acrossOffset + verticalAxis.Z * verticalOffset); + + var start = new Point3D( + offsetCenter.X - principalAxis.X * halfLength, + offsetCenter.Y - principalAxis.Y * halfLength, + offsetCenter.Z - principalAxis.Z * halfLength); + var end = new Point3D( + offsetCenter.X + principalAxis.X * halfLength, + offsetCenter.Y + principalAxis.Y * halfLength, + offsetCenter.Z + principalAxis.Z * halfLength); + + var sampledPath = SampleLine(start, end, samplingInterval); + LogManager.Debug($"[空轨] 轨道参考中心线提取完成: 主轴长度={principalLength:F2}, 采样点数={sampledPath.Count}"); + return sampledPath; + } + + /// + /// 使用 OBB 包围体提取空轨下表面中心线(旧方法,支持倾斜) /// /// 空轨模型项 /// 采样间隔(模型单位),默认0.5 @@ -794,6 +975,193 @@ namespace NavisworksTransport.PathPlanning return result; } + private static int GetMostVerticalAxisIndex(OrientedBoundingBox obb) + { + double bestAbsDot = double.MinValue; + int bestIndex = 2; + + for (int i = 1; i < 3; i++) + { + var axis = obb.GetAxis(i); + double absDot = Math.Abs(axis.Z); + if (absDot > bestAbsDot) + { + bestAbsDot = absDot; + bestIndex = i; + } + } + + return bestIndex; + } + + private static Point3D ProjectPointToObbLocal(Point3D point, OrientedBoundingBox obb) + { + double dx = point.X - obb.Center.X; + double dy = point.Y - obb.Center.Y; + double dz = point.Z - obb.Center.Z; + + return new Point3D( + dx * obb.Axes[0].X + dy * obb.Axes[0].Y + dz * obb.Axes[0].Z, + dx * obb.Axes[1].X + dy * obb.Axes[1].Y + dz * obb.Axes[1].Z, + dx * obb.Axes[2].X + dy * obb.Axes[2].Y + dz * obb.Axes[2].Z); + } + + private static List ExtractDominantRailBand(List localPoints, int verticalAxisIndex) + { + const int BinCount = 24; + + double minValue = localPoints.Min(point => GetAxisValue(point, verticalAxisIndex)); + double maxValue = localPoints.Max(point => GetAxisValue(point, verticalAxisIndex)); + double range = maxValue - minValue; + if (range < 1e-6) + { + return new List(localPoints); + } + + double binSize = range / BinCount; + var bins = new List[BinCount]; + for (int i = 0; i < BinCount; i++) + { + bins[i] = new List(); + } + + foreach (var point in localPoints) + { + double value = GetAxisValue(point, verticalAxisIndex); + int binIndex = (int)((value - minValue) / binSize); + binIndex = Math.Max(0, Math.Min(BinCount - 1, binIndex)); + bins[binIndex].Add(point); + } + + int dominantBin = 0; + int dominantCount = bins[0].Count; + for (int i = 1; i < bins.Length; i++) + { + if (bins[i].Count > dominantCount) + { + dominantBin = i; + dominantCount = bins[i].Count; + } + } + + var dominantPoints = new List(); + int startBin = Math.Max(0, dominantBin - 1); + int endBin = Math.Min(BinCount - 1, dominantBin + 1); + for (int i = startBin; i <= endBin; i++) + { + dominantPoints.AddRange(bins[i]); + } + + return dominantPoints.Count > 0 ? dominantPoints : localPoints; + } + + private static double EstimateDualRailAcrossCenter(List railBandPoints, int acrossAxisIndex, double acrossAxisLength) + { + var acrossValues = railBandPoints + .Select(point => GetAxisValue(point, acrossAxisIndex)) + .OrderBy(value => value) + .ToList(); + + if (acrossValues.Count < 2) + { + return acrossValues.Count == 1 ? acrossValues[0] : 0.0; + } + + double minAcross = acrossValues.First(); + double maxAcross = acrossValues.Last(); + double totalWidth = maxAcross - minAcross; + if (totalWidth < Math.Max(1e-6, acrossAxisLength * 0.05)) + { + return (minAcross + maxAcross) / 2.0; + } + + double centerA = Percentile(acrossValues, 0.25); + double centerB = Percentile(acrossValues, 0.75); + + for (int iteration = 0; iteration < 8; iteration++) + { + double sumA = 0.0; + double sumB = 0.0; + int countA = 0; + int countB = 0; + + foreach (double value in acrossValues) + { + if (Math.Abs(value - centerA) <= Math.Abs(value - centerB)) + { + sumA += value; + countA++; + } + else + { + sumB += value; + countB++; + } + } + + if (countA == 0 || countB == 0) + { + break; + } + + centerA = sumA / countA; + centerB = sumB / countB; + } + + if (Math.Abs(centerA - centerB) < acrossAxisLength * 0.1) + { + return Median(acrossValues); + } + + return (centerA + centerB) / 2.0; + } + + private static double GetAxisValue(Point3D point, int axisIndex) + { + switch (axisIndex) + { + case 0: + return point.X; + case 1: + return point.Y; + case 2: + return point.Z; + default: + throw new ArgumentOutOfRangeException(nameof(axisIndex)); + } + } + + private static double Percentile(List sortedValues, double percentile) + { + if (sortedValues == null || sortedValues.Count == 0) + { + return 0.0; + } + + if (sortedValues.Count == 1) + { + return sortedValues[0]; + } + + double clamped = Math.Max(0.0, Math.Min(1.0, percentile)); + double position = clamped * (sortedValues.Count - 1); + int lowerIndex = (int)Math.Floor(position); + int upperIndex = (int)Math.Ceiling(position); + + if (lowerIndex == upperIndex) + { + return sortedValues[lowerIndex]; + } + + double t = position - lowerIndex; + return sortedValues[lowerIndex] + (sortedValues[upperIndex] - sortedValues[lowerIndex]) * t; + } + + private static double Median(List sortedValues) + { + return Percentile(sortedValues, 0.5); + } + /// /// 筛选下表面三角形(法线向下,Z < 0) /// @@ -1070,4 +1438,4 @@ namespace NavisworksTransport.PathPlanning } } } -} \ No newline at end of file +} diff --git a/src/PathPlanning/SlopeAnalyzer.cs b/src/PathPlanning/SlopeAnalyzer.cs index afd9454..09faeb2 100644 --- a/src/PathPlanning/SlopeAnalyzer.cs +++ b/src/PathPlanning/SlopeAnalyzer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.PathPlanning { @@ -13,6 +14,7 @@ namespace NavisworksTransport.PathPlanning { private readonly Dictionary _slopeCache; private readonly double _minSlopeAngle; // 最小坡度角度(弧度) + private readonly ICoordinateSystem _coordinateSystem; /// /// 构造函数 @@ -22,6 +24,7 @@ namespace NavisworksTransport.PathPlanning { _slopeCache = new Dictionary(); _minSlopeAngle = minSlopeAngle * Math.PI / 180.0; // 转换为弧度 + _coordinateSystem = CoordinateSystemManager.Instance.Current; } /// @@ -157,8 +160,10 @@ namespace NavisworksTransport.PathPlanning // 根据几何特征判断 var bounds = channel.Geometry.BoundingBox; - var heightDiff = bounds.Max.Z - bounds.Min.Z; - var horizontalLength = Math.Max(bounds.Max.X - bounds.Min.X, bounds.Max.Y - bounds.Min.Y); + var (minElevation, maxElevation) = _coordinateSystem.GetHeightRange(bounds); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + var heightDiff = maxElevation - minElevation; + var horizontalLength = Math.Max(maxH1 - minH1, maxH2 - minH2); if (heightDiff > 0.1 && horizontalLength > 0.1) // 有明显高度差 { @@ -185,25 +190,26 @@ namespace NavisworksTransport.PathPlanning private void AddBoundaryPoints(ChannelSlopeInfo slopeInfo, BoundingBox3D bounds) { // 添加8个边界框顶点 - slopeInfo.SlopePoints.Add(new Point3D(bounds.Min.X, bounds.Min.Y, bounds.Min.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Max.X, bounds.Min.Y, bounds.Min.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Min.X, bounds.Max.Y, bounds.Min.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Max.X, bounds.Max.Y, bounds.Min.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Min.X, bounds.Min.Y, bounds.Max.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Max.X, bounds.Min.Y, bounds.Max.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Min.X, bounds.Max.Y, bounds.Max.Z)); - slopeInfo.SlopePoints.Add(new Point3D(bounds.Max.X, bounds.Max.Y, bounds.Max.Z)); + var (minElevation, maxElevation) = _coordinateSystem.GetHeightRange(bounds); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(minH1, minH2, minElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(maxH1, minH2, minElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(minH1, maxH2, minElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(maxH1, maxH2, minElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(minH1, minH2, maxElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(maxH1, minH2, maxElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(minH1, maxH2, maxElevation)); + slopeInfo.SlopePoints.Add(_coordinateSystem.CreatePoint(maxH1, maxH2, maxElevation)); // 添加中心线上的采样点 int sampleCount = 5; for (int i = 0; i < sampleCount; i++) { double ratio = (double)i / (sampleCount - 1); - var samplePoint = new Point3D( - bounds.Min.X + (bounds.Max.X - bounds.Min.X) * ratio, - bounds.Min.Y + (bounds.Max.Y - bounds.Min.Y) * ratio, - bounds.Min.Z + (bounds.Max.Z - bounds.Min.Z) * ratio - ); + var samplePoint = _coordinateSystem.CreatePoint( + minH1 + (maxH1 - minH1) * ratio, + minH2 + (maxH2 - minH2) * ratio, + minElevation + (maxElevation - minElevation) * ratio); slopeInfo.SlopePoints.Add(samplePoint); } } @@ -216,26 +222,29 @@ namespace NavisworksTransport.PathPlanning private void CalculateSlopeAngleAndDirection(ChannelSlopeInfo slopeInfo, BoundingBox3D bounds) { // 计算主要方向向量 - var directionX = bounds.Max.X - bounds.Min.X; - var directionY = bounds.Max.Y - bounds.Min.Y; - var directionZ = bounds.Max.Z - bounds.Min.Z; + var (minElevation, maxElevation) = _coordinateSystem.GetHeightRange(bounds); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + var directionH1 = maxH1 - minH1; + var directionH2 = maxH2 - minH2; + var directionElevation = maxElevation - minElevation; // 选择主要的水平方向 - var horizontalLength = Math.Max(Math.Abs(directionX), Math.Abs(directionY)); + var horizontalLength = Math.Max(Math.Abs(directionH1), Math.Abs(directionH2)); if (horizontalLength > 0.001) { // 计算坡度角度 - slopeInfo.SlopeAngle = Math.Atan(Math.Abs(directionZ) / horizontalLength); + slopeInfo.SlopeAngle = Math.Atan(Math.Abs(directionElevation) / horizontalLength); // 计算坡度方向向量 - var length = Math.Sqrt(directionX * directionX + directionY * directionY + directionZ * directionZ); + var directionPoint = _coordinateSystem.CreatePoint(directionH1, directionH2, directionElevation); + var length = Math.Sqrt(directionPoint.X * directionPoint.X + directionPoint.Y * directionPoint.Y + directionPoint.Z * directionPoint.Z); if (length > 0.001) { slopeInfo.SlopeDirection = new Vector3D( - directionX / length, - directionY / length, - directionZ / length + directionPoint.X / length, + directionPoint.Y / length, + directionPoint.Z / length ); } else @@ -246,7 +255,8 @@ namespace NavisworksTransport.PathPlanning else { slopeInfo.SlopeAngle = 0; - slopeInfo.SlopeDirection = new Vector3D(0, 0, 1); + var up = _coordinateSystem.UpVector; + slopeInfo.SlopeDirection = new Vector3D(up.X, up.Y, up.Z); } } @@ -279,8 +289,10 @@ namespace NavisworksTransport.PathPlanning try { var bounds = channel.Geometry.BoundingBox; - var totalHeight = bounds.Max.Z - bounds.Min.Z; - var totalLength = Math.Max(bounds.Max.X - bounds.Min.X, bounds.Max.Y - bounds.Min.Y); + var (minElevation, maxElevation) = _coordinateSystem.GetHeightRange(bounds); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + var totalHeight = maxElevation - minElevation; + var totalLength = Math.Max(maxH1 - minH1, maxH2 - minH2); // 估算楼梯级数(假设每级高度15-20cm) var estimatedSteps = (int)(totalHeight / 0.175); // 平均17.5cm每级 @@ -310,8 +322,9 @@ namespace NavisworksTransport.PathPlanning var bounds = channel.Geometry.BoundingBox; // 检查是否为弯曲坡道(通过长宽比判断) - var width = Math.Min(bounds.Max.X - bounds.Min.X, bounds.Max.Y - bounds.Min.Y); - var length = Math.Max(bounds.Max.X - bounds.Min.X, bounds.Max.Y - bounds.Min.Y); + var (minH1, maxH1, minH2, maxH2) = _coordinateSystem.GetHorizontalRange(bounds); + var width = Math.Min(maxH1 - minH1, maxH2 - minH2); + var length = Math.Max(maxH1 - minH1, maxH2 - minH2); if (width > 0 && length / width > 3.0) // 长宽比大于3:1,可能是弯曲坡道 { @@ -352,18 +365,21 @@ namespace NavisworksTransport.PathPlanning } // 找到楼梯的起点和终点 - var startPoint = slopeInfo.SlopePoints.OrderBy(p => p.Z).First(); - var endPoint = slopeInfo.SlopePoints.OrderBy(p => p.Z).Last(); + var startPoint = slopeInfo.SlopePoints.OrderBy(p => _coordinateSystem.GetElevation(p)).First(); + var endPoint = slopeInfo.SlopePoints.OrderBy(p => _coordinateSystem.GetElevation(p)).Last(); // 计算位置在楼梯长度方向上的投影 + var (startH1, startH2) = _coordinateSystem.GetHorizontalCoords(startPoint); + var (endH1, endH2) = _coordinateSystem.GetHorizontalCoords(endPoint); + var (positionH1, positionH2) = _coordinateSystem.GetHorizontalCoords(position); var totalDistance = Math.Sqrt( - Math.Pow(endPoint.X - startPoint.X, 2) + - Math.Pow(endPoint.Y - startPoint.Y, 2) + Math.Pow(endH1 - startH1, 2) + + Math.Pow(endH2 - startH2, 2) ); var currentDistance = Math.Sqrt( - Math.Pow(position.X - startPoint.X, 2) + - Math.Pow(position.Y - startPoint.Y, 2) + Math.Pow(positionH1 - startH1, 2) + + Math.Pow(positionH2 - startH2, 2) ); if (totalDistance <= 0.001) @@ -522,4 +538,4 @@ namespace NavisworksTransport.PathPlanning Other } - } \ No newline at end of file + } diff --git a/src/UI/WPF/Converters/EditableNumberConverter.cs b/src/UI/WPF/Converters/EditableNumberConverter.cs new file mode 100644 index 0000000..ee7886a --- /dev/null +++ b/src/UI/WPF/Converters/EditableNumberConverter.cs @@ -0,0 +1,127 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NavisworksTransport.UI.WPF.Converters +{ + /// + /// Keeps numeric TextBox editing humane: incomplete text is allowed while editing, + /// and only complete numeric values are written back to the bound source. + /// + public class EditableNumberConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) + { + return string.Empty; + } + + string format = parameter as string; + if (string.IsNullOrWhiteSpace(format)) + { + return System.Convert.ToString(value, culture); + } + + if (value is IFormattable formattable) + { + return formattable.ToString(format, culture); + } + + return System.Convert.ToString(value, culture); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + string text = value as string; + if (IsIntermediateText(text)) + { + throw new FormatException("请输入有效的数字。"); + } + + text = text.Trim(); + Type actualTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (TryParseNumber(text, culture, actualTargetType, out object parsedValue)) + { + return parsedValue; + } + + throw new FormatException("请输入有效的数字。"); + } + + public static bool IsIntermediateText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + text = text.Trim(); + + return text == "-" || + text == "+" || + text == "." || + text == "-." || + text == "+."; + } + + public static bool TryParseNumber(string text, CultureInfo culture, Type targetType, out object parsedValue) + { + NumberStyles styles = NumberStyles.Float | NumberStyles.AllowThousands; + + if (targetType == typeof(double)) + { + if (TryParseDouble(text, styles, culture, out double doubleValue)) + { + parsedValue = doubleValue; + return true; + } + } + else if (targetType == typeof(float)) + { + if (TryParseDouble(text, styles, culture, out double doubleValue)) + { + parsedValue = (float)doubleValue; + return true; + } + } + else if (targetType == typeof(decimal)) + { + if (decimal.TryParse(text, styles, culture, out decimal decimalValue) || + decimal.TryParse(text, styles, CultureInfo.InvariantCulture, out decimalValue)) + { + parsedValue = decimalValue; + return true; + } + } + else if (targetType == typeof(int)) + { + if (int.TryParse(text, NumberStyles.Integer, culture, out int intValue) || + int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out intValue)) + { + parsedValue = intValue; + return true; + } + } + else if (targetType == typeof(long)) + { + if (long.TryParse(text, NumberStyles.Integer, culture, out long longValue) || + long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out longValue)) + { + parsedValue = longValue; + return true; + } + } + + parsedValue = null; + return false; + } + + private static bool TryParseDouble(string text, NumberStyles styles, CultureInfo culture, out double value) + { + return double.TryParse(text, styles, culture, out value) || + double.TryParse(text, styles, CultureInfo.InvariantCulture, out value); + } + } +} diff --git a/src/UI/WPF/Models/HoistingLevelItem.cs b/src/UI/WPF/Models/HoistingLevelItem.cs index c92e7ce..56d356b 100644 --- a/src/UI/WPF/Models/HoistingLevelItem.cs +++ b/src/UI/WPF/Models/HoistingLevelItem.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Runtime.CompilerServices; using Autodesk.Navisworks.Api; +using System.Collections.Generic; namespace NavisworksTransport.UI.WPF.Models { @@ -53,4 +54,47 @@ namespace NavisworksTransport.UI.WPF.Models PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } + + /// + /// 吊装路径层高编辑项 + /// + public class HoistingLayerEditItem : INotifyPropertyChanged + { + private int _displayIndex; + private double _heightInMeters; + private string _pointSummary; + + public int DisplayIndex + { + get => _displayIndex; + set { _displayIndex = value; OnPropertyChanged(nameof(DisplayIndex)); } + } + + /// + /// 相对起吊点的绝对高度(米) + /// + public double HeightInMeters + { + get => _heightInMeters; + set { _heightInMeters = value; OnPropertyChanged(nameof(HeightInMeters)); } + } + + public string PointSummary + { + get => _pointSummary; + set { _pointSummary = value; OnPropertyChanged(nameof(PointSummary)); } + } + + /// + /// 当前层关联的路径点 Id 列表 + /// + public List PointIds { get; } = new List(); + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } } diff --git a/src/UI/WPF/ViewModels/AnimationControlViewModel.cs b/src/UI/WPF/ViewModels/AnimationControlViewModel.cs index f62e11c..9b9ca94 100644 --- a/src/UI/WPF/ViewModels/AnimationControlViewModel.cs +++ b/src/UI/WPF/ViewModels/AnimationControlViewModel.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; +using System.Numerics; using System.Runtime.CompilerServices; using System.Threading.Tasks; using System.Windows; @@ -14,10 +15,12 @@ using Autodesk.Navisworks.Api.Clash; using NavisworksTransport.Core; using NavisworksTransport.Core.Models; using NavisworksTransport.Core.Config; +using NavisworksTransport.Core.Services; using NavisworksTransport.Commands; using NavisworksTransport.UI.WPF.Collections; using NavisworksTransport.UI.WPF.Views; using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.UI.WPF.ViewModels { @@ -178,9 +181,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels public ICommand HighlightCommand { get; set; } public ICommand ClearHighlightCommand { get; set; } - // 碰撞位置信息(用于还原碰撞场景) - public Point3D Item1Position { get; set; } - public double Item1YawRadians { get; set; } + // 动画跟踪点位置信息(用于还原碰撞场景) + public Point3D AnimatedObjectTrackedPosition { get; set; } + public double AnimatedObjectTrackedYawRadians { get; set; } + public Rotation3D AnimatedObjectTrackedRotation { get; set; } + public bool AnimatedObjectHasTrackedRotation { get; set; } public bool HasPositionInfo { get; set; } } @@ -352,7 +357,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels private double _safetyMarginInMeters; // 检测间隙(米),从路径编辑同步 // 角度修正相关字段 - private double _objectRotationCorrection; // 物体角度修正值(度,顺时针) + private LocalEulerRotationCorrection _objectRotationCorrection = LocalEulerRotationCorrection.Zero; // 物体绕宿主 X/Y/Z 轴的角度修正 + private double _objectGroundLiftHeightInMeters; // 移动物体原始尺寸(米,在选择物体时保存) private double _objectOriginalLength; // 物体原始长度(X方向) @@ -483,6 +489,14 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + /// + /// 最近一次自动生成的碰撞报告。 + /// 仅用于测试自动化读取结果,不参与业务流程分支。 + /// + public CollisionReportResult LastGeneratedReport => _lastGeneratedReport; + + public bool HasGeneratedCollisionReport => _lastGeneratedReport != null; + /// @@ -705,8 +719,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels VirtualObjectLengthInMeters, VirtualObjectWidthInMeters, VirtualObjectHeightInMeters); // 重置角度修正值(虚拟物体重新选择时重置) - ObjectRotationCorrection = 0.0; - LogManager.Debug($"[切换虚拟物体] 已重置角度修正值为0度"); + ObjectRotationCorrection = LocalEulerRotationCorrection.Zero; + LogManager.Debug("[切换虚拟物体] 已重置角度修正值为0"); // 当切换到虚拟物体模式时,根据路径类型打开通行空间可视化 UpdatePassageSpaceVisualization(); @@ -789,9 +803,9 @@ namespace NavisworksTransport.UI.WPF.ViewModels } /// - /// 物体角度修正值(度,顺时针) + /// 物体绕宿主 X/Y/Z 轴的角度修正。 /// - public double ObjectRotationCorrection + public LocalEulerRotationCorrection ObjectRotationCorrection { get => _objectRotationCorrection; set @@ -1258,8 +1272,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels SelectAnimatedObjectCommand = new RelayCommand(() => { // 重置角度修正值(每个物体的旋转独立) - ObjectRotationCorrection = 0.0; - LogManager.Debug($"[选择物体] 已重置角度修正值为0度"); + ObjectRotationCorrection = LocalEulerRotationCorrection.Zero; + LogManager.Debug("[选择物体] 已重置角度修正值为0"); ExecuteSelectAnimatedObject(); }); ClearAnimatedObjectCommand = new RelayCommand(ExecuteClearAnimatedObject, () => HasSelectedAnimatedObject); @@ -1496,25 +1510,6 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - // 自动调整视角到路径中心 - try - { - var coreRoute = CurrentPathRoute?.Route; - if (coreRoute != null) - { - ViewpointHelper.AdjustViewpointToPathCenter(coreRoute); - LogManager.Info($"正向播放前:已自动调整视角到路径中心: {CurrentPathRoute.Name}"); - } - else - { - LogManager.Warning($"正向播放前:CurrentPathRoute.Route 为 null"); - } - } - catch (Exception ex) - { - LogManager.Error($"正向播放前:调整视角失败: {ex.Message}"); - } - _pathAnimationManager?.PlayForward(); UpdateMediaControlProperties(); LogManager.Info("执行正向播放命令"); @@ -1533,25 +1528,6 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - // 自动调整视角到路径中心 - try - { - var coreRoute = CurrentPathRoute?.Route; - if (coreRoute != null) - { - ViewpointHelper.AdjustViewpointToPathCenter(coreRoute); - LogManager.Info($"反向播放前:已自动调整视角到路径中心: {CurrentPathRoute.Name}"); - } - else - { - LogManager.Warning($"反向播放前:CurrentPathRoute.Route 为 null"); - } - } - catch (Exception ex) - { - LogManager.Error($"反向播放前:调整视角失败: {ex.Message}"); - } - _pathAnimationManager?.PlayReverse(); UpdateMediaControlProperties(); LogManager.Info("执行反向播放命令"); @@ -1698,26 +1674,47 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 2. 先保存物体原始尺寸(在设置SelectedAnimatedObject之前) var bbox = newObject.BoundingBox(); double metersToModelUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); - _objectOriginalLength = (bbox.Max.X - bbox.Min.X) / metersToModelUnits; - _objectOriginalWidth = (bbox.Max.Y - bbox.Min.Y) / metersToModelUnits; - _objectOriginalHeight = (bbox.Max.Z - bbox.Min.Z) / metersToModelUnits; + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var axisConvention = ModelAxisConvention.CreateDefaultForHost(adapter.HostType); + double objectLength = axisConvention.GetForwardSize(bbox); + double objectWidth = axisConvention.GetSideSize(bbox); + double objectHeight = axisConvention.GetUpSize(bbox); + _objectOriginalLength = objectLength / metersToModelUnits; + _objectOriginalWidth = objectWidth / metersToModelUnits; + _objectOriginalHeight = objectHeight / metersToModelUnits; + _pathAnimationManager?.SetRealObjectDimensions(objectLength, objectWidth, objectHeight); LogManager.Debug($"[选择物体] 保存原始尺寸: 长度={_objectOriginalLength:F2}m, 宽度={_objectOriginalWidth:F2}m, 高度={_objectOriginalHeight:F2}m"); - // 3. 设置新物体(会触发UpdatePassageSpaceVisualization) - SelectedAnimatedObject = newObject; - LogManager.Info($"已选择移动物体: {SelectedAnimatedObject.DisplayName}"); + // 3. 选择实体物体意味着切换到实体模式,避免后续起点同步仍走虚拟物体分支。 + if (UseVirtualObject) + { + UseVirtualObject = false; + LogManager.Debug("[选择物体] 已切换到实体物体模式"); + } - // 只有选择不同的物体时,才重置角度修正值 + // 4. 对“新物体”先清空角度修正,再触发 SelectedAnimatedObject 的起点落位。 + // SelectedAnimatedObject 的 setter 会立即触发 MoveAnimatedObjectToPathStart(), + // 如果角度在后面才归零,就会先按旧角度把新选择的物体摆到起点。 if (!isSameObject) { - // 重置 ViewModel 中的角度修正值(会自动同步到 PathAnimationManager) - ObjectRotationCorrection = 0.0; - LogManager.Debug($"[选择物体] 已重置角度修正值为0度(新物体)"); + _pathAnimationManager?.SetObjectRotationCorrectionDirect(LocalEulerRotationCorrection.Zero); + _pathAnimationManager?.SetObjectStartVerticalLiftDirect(0.0); + ObjectRotationCorrection = LocalEulerRotationCorrection.Zero; + _objectGroundLiftHeightInMeters = 0.0; + LogManager.Debug("[选择物体] 已重置角度修正值为0(新物体)"); } else { - LogManager.Debug($"[选择物体] 保持当前角度修正值(同一物体)"); + LogManager.Debug("[选择物体] 保持当前角度修正值(同一物体)"); } + + // 5. 先注册到动画管理器,再设置 SelectedAnimatedObject。 + // SelectedAnimatedObject 的 setter 会立即触发 MoveAnimatedObjectToPathStart(); + // 如果先移动、后 SetAnimatedObject,会把“已摆到起点后的当前姿态”再次当成参考姿态缓存。 + _pathAnimationManager?.SetAnimatedObject(newObject); + SelectedAnimatedObject = newObject; + LogManager.Info($"已选择移动物体: {SelectedAnimatedObject.DisplayName}"); + } catch (Exception ex) { @@ -1876,12 +1873,9 @@ namespace NavisworksTransport.UI.WPF.ViewModels _clashIntegration.ClearWasLastTestCanceled(); break; } - - // 先清除所有碰撞高亮(包括预计算和ClashDetective) + ModelHighlightHelper.ClearCollisionHighlights(); - // 生成碰撞报告(无论有无碰撞)并等待完成 await GenerateAndSaveCollisionReport(); - // 高亮ClashDetective结果(只在动画自然完成时进行,确保报告生成完成) HighlightClashDetectiveResults(); break; case NavisworksTransport.Core.Animation.AnimationState.Stopped: @@ -2009,11 +2003,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (clashResults == null || clashResults.Count == 0) { LogManager.Info("🎉 恭喜!本次动画未检测到任何碰撞,路径规划非常安全!"); - System.Windows.MessageBox.Show( + DialogHelper.ShowAutoClosingMessageBox( "🎉 恭喜!本次动画仿真过程中未发现任何碰撞!\n\n您的物流路径规划非常成功,物体可以安全通行。", "仿真完成 - 无碰撞", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Information); + System.Windows.MessageBoxImage.Information, + autoCloseMilliseconds: 4000); } // 统一流程:刷新历史列表并显示报告(无论有无碰撞) @@ -2141,19 +2135,25 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 1. 归位并清理 _pathAnimationManager.RestoreObjectToCADPosition(); _pathAnimationManager.ClearAnimationResults(); + _pathAnimationManager.SetObjectRotationCorrectionDirect(LocalEulerRotationCorrection.Zero); + _pathAnimationManager.SetObjectStartVerticalLiftDirect(0.0); + _pathAnimationManager.SetObjectStartPlacementMode(ObjectStartPlacementMode.AlignToPathPose); LogManager.Info("已清除PathAnimationManager中的动画数据并归位物体"); } - // 2. 重置角度修正值为0 - ObjectRotationCorrection = 0.0; - LogManager.Debug("[清除物体] 已重置角度修正值为0度"); - - // 3. 重置属性 + // 2. 先清空选择,避免角度修正归零时又触发“移动到起点”链路。 SelectedAnimatedObject = null; + + // 3. 重置角度修正值为0 + ObjectRotationCorrection = LocalEulerRotationCorrection.Zero; + _objectGroundLiftHeightInMeters = 0.0; + LogManager.Debug("[清除物体] 已重置角度修正值为0"); + + // 4. 重置属性 UpdateAnimatedObjectInfo(); UpdateCanGenerateAnimation(); - // 4. 清理高亮 + // 5. 清理高亮 ModelHighlightHelper.ClearCollisionHighlights(); LogManager.Info("移动物体已完全清除并归位"); @@ -2173,16 +2173,30 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - var dialog = new Views.EditRotationWindow(_objectRotationCorrection); + bool enableGroundLift = CurrentPathRoute?.PathType == PathType.Ground; + var dialog = new Views.EditRotationWindow( + _objectRotationCorrection, + _objectGroundLiftHeightInMeters, + enableGroundLift); if (dialog.ShowDialog() == true) { - ObjectRotationCorrection = dialog.RotationAngle; - LogManager.Info($"物体角度修正已更新: {_objectRotationCorrection:F1}°"); - UpdateMainStatus($"物体角度修正: {_objectRotationCorrection:F1}°"); - - // 应用角度修正到物体 - UpdateObjectRotation(); - + var placementRequest = dialog.AdjustmentRequest; + _objectGroundLiftHeightInMeters = placementRequest.VerticalLiftInMeters; + _pathAnimationManager?.ApplyObjectStartPlacementRequest(placementRequest); + ObjectRotationCorrection = placementRequest.RotationCorrection; + + string liftSummary = enableGroundLift + ? $", 提升高度={_objectGroundLiftHeightInMeters:F3}m" + : string.Empty; + string statusSummary = placementRequest.PreserveInitialPose + ? $"物体已调整为平移模式{liftSummary}" + : $"物体角度修正: {_objectRotationCorrection}{liftSummary}"; + + LogManager.Info( + $"物体调整已更新: 模式={placementRequest.PlacementMode}, 角度={_objectRotationCorrection}, " + + $"提升高度={_objectGroundLiftHeightInMeters:F3}m"); + UpdateMainStatus(statusSummary); + // 更新通行空间可视化(考虑旋转后的尺寸) UpdatePassageSpaceVisualization(); } @@ -2194,6 +2208,41 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + private void ApplyTranslationOnlyObjectPlacement() + { + try + { + if (_pathAnimationManager == null || !HasSelectedAnimatedObject) + { + return; + } + + _objectRotationCorrection = LocalEulerRotationCorrection.Zero; + _pathAnimationManager.SetObjectRotationCorrectionDirect(LocalEulerRotationCorrection.Zero); + _pathAnimationManager.SetObjectStartVerticalLiftDirect(0.0); + _pathAnimationManager.SetObjectStartPlacementMode(ObjectStartPlacementMode.PreserveInitialPose); + OnPropertyChanged(nameof(ObjectRotationCorrection)); + + if (_pathAnimationManager.MoveObjectToPathStartPreservingInitialPose()) + { + LogManager.Info("[角度修正] 已按终点原始位姿整体搬运到路径起点"); + UpdateMainStatus("物体已按终点原始位姿平移到路径起点"); + } + else + { + LogManager.Warning("[角度修正] 平移模式未能将物体移动到路径起点"); + UpdateMainStatus("平移模式执行失败"); + } + + UpdatePassageSpaceVisualization(); + } + catch (Exception ex) + { + LogManager.Error($"平移到路径起点失败: {ex.Message}"); + UpdateMainStatus($"平移到路径起点失败: {ex.Message}"); + } + } + /// /// 更新物体旋转(应用角度修正) /// @@ -2205,7 +2254,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels { // 将角度修正传递给动画管理器 _pathAnimationManager.SetObjectRotationCorrection(_objectRotationCorrection); - LogManager.Debug($"[角度修正] 已更新物体旋转: {_objectRotationCorrection:F1}°"); + LogManager.Debug($"[角度修正] 已更新物体旋转: {_objectRotationCorrection}"); } } catch (Exception ex) @@ -2430,7 +2479,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels ModelHighlightHelper.HighlightItems(ManualTargetsHighlightCategory, items); // 聚焦到对象(斜上方45度视角) - ViewpointHelper.FocusOnModelItem(target.ModelItem, viewAngleDegrees: 60.0, targetViewRatio: 0.25); + ViewpointHelper.FocusOnModelItem(target.ModelItem, ViewpointHelper.ViewpointStrategy.ModelFocus); UpdateMainStatus($"已聚焦到: {target.DisplayName}"); LogManager.Info($"聚焦到手工指定对象: {target.DisplayName}"); @@ -2456,7 +2505,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels ModelHighlightHelper.HighlightItems(ExcludedObjectsHighlightCategory, items); // 聚焦到对象(斜上方45度视角) - ViewpointHelper.FocusOnModelItem(excludedObject.ModelItem, viewAngleDegrees: 60.0, targetViewRatio: 0.25); + ViewpointHelper.FocusOnModelItem(excludedObject.ModelItem, ViewpointHelper.ViewpointStrategy.ModelFocus); UpdateMainStatus($"已聚焦到排除对象: {excludedObject.DisplayName}"); LogManager.Info($"聚焦到排除对象: {excludedObject.DisplayName}"); @@ -2506,8 +2555,10 @@ namespace NavisworksTransport.UI.WPF.ViewModels CollisionSceneHelper.MoveToCollisionAndFocus( collisionObject.ModelItem, null, - collisionObject.Item1Position, - collisionObject.Item1YawRadians, + collisionObject.AnimatedObjectTrackedPosition, + collisionObject.AnimatedObjectTrackedYawRadians, + collisionObject.AnimatedObjectTrackedRotation, + collisionObject.AnimatedObjectHasTrackedRotation, false); return; } @@ -2544,8 +2595,10 @@ namespace NavisworksTransport.UI.WPF.ViewModels CollisionSceneHelper.MoveToCollisionAndFocus( collisionObject.ModelItem, animatedObject, - collisionObject.Item1Position, - collisionObject.Item1YawRadians, + collisionObject.AnimatedObjectTrackedPosition, + collisionObject.AnimatedObjectTrackedYawRadians, + collisionObject.AnimatedObjectTrackedRotation, + collisionObject.AnimatedObjectHasTrackedRotation, collisionObject.HasPositionInfo); // 🔥 虚拟物体缩放已通过MoveItemToPositionAndYawWithCurrentScale保留 @@ -2884,6 +2937,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { + if (DialogHelper.IsTestAutomationModeEnabled) + { + LogManager.Info("[碰撞分析] 自动测试模式下跳过预计算碰撞分析"); + SaveCollisionDetectionRecord(); + return; + } + // 1. 获取预计算碰撞结果(从PathAnimationManager,现在缓存帧中已包含碰撞结果) var allResults = _pathAnimationManager?.AllCollisionResults; if (allResults == null || allResults.Count == 0) @@ -3037,50 +3097,9 @@ namespace NavisworksTransport.UI.WPF.ViewModels { var dialog = new CollisionAnalysisDialog(hotspots, totalCollisions); DialogHelper.SetOwnerSafely(dialog); - var result = dialog.ShowDialog(); - - if (result == null) - { - // 用户取消 - UpdateMainStatus("预计算碰撞分析已取消"); - return; - } - - if (dialog.ExcludedObjects.Count > 0) - { - // 用户选择了排除某些物体并重新生成 - LogManager.Info($"[碰撞分析] 用户选择排除 {dialog.ExcludedObjects.Count} 个物体并重新生成"); - - // 🔥 添加排除对象到UI列表(合并,自动去重) - AddExcludedObjectsToUIList(dialog.ExcludedObjects); - - // 🔥 从UI列表获取所有排除对象(包括之前手工添加的),设置到Manager并重新生成 - var allExcludedObjects = _excludedObjects.Select(e => e.ModelItem).Where(m => m != null).ToList(); - _pathAnimationManager.SetExcludedObjectsAndClearCache(allExcludedObjects); - - // 触发重新生成(使用 Dispatcher 避免阻塞 UI) - System.Windows.Application.Current.Dispatcher.BeginInvoke(new Action(() => - { - ExecuteGenerateAnimation(); - }), DispatcherPriority.Background); - - UpdateMainStatus($"已排除 {allExcludedObjects.Count} 个物体,正在重新生成..."); - } - else - { - // 用户选择直接继续(无排除对象) - LogManager.Info("[碰撞分析] 用户选择直接继续生成"); - UpdateMainStatus($"预计算碰撞分析完成,共 {totalCollisions} 个候选碰撞"); - - // 🔥 保存检测记录到数据库(保存测试配置) - var recordId = SaveCollisionDetectionRecord(); - if (recordId.HasValue && IsReusedHistoryRecord(recordId.Value)) - { - // 用户选择使用历史记录,跳过后续流程 - LogManager.Info("[碰撞分析] 用户选择使用历史记录,跳过动画生成"); - return; - } - } + dialog.Closed += (_, __) => HandleCollisionAnalysisDialogClosed(dialog, totalCollisions); + dialog.Show(); + dialog.Activate(); } catch (Exception ex) { @@ -3088,6 +3107,57 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + private void HandleCollisionAnalysisDialogClosed(CollisionAnalysisDialog dialog, int totalCollisions) + { + try + { + if (dialog == null) + { + return; + } + + switch (dialog.SelectedAction) + { + case CollisionAnalysisDialog.CollisionAnalysisAction.Regenerate: + LogManager.Info($"[碰撞分析] 用户选择排除 {dialog.ExcludedObjects.Count} 个物体并重新生成"); + + AddExcludedObjectsToUIList(dialog.ExcludedObjects); + + var allExcludedObjects = _excludedObjects.Select(e => e.ModelItem).Where(m => m != null).ToList(); + _pathAnimationManager.SetExcludedObjectsAndClearCache(allExcludedObjects); + + System.Windows.Application.Current.Dispatcher.BeginInvoke(new Action(() => + { + ExecuteGenerateAnimation(); + }), DispatcherPriority.Background); + + UpdateMainStatus($"已排除 {allExcludedObjects.Count} 个物体,正在重新生成..."); + break; + + case CollisionAnalysisDialog.CollisionAnalysisAction.Continue: + LogManager.Info("[碰撞分析] 用户选择直接继续生成"); + UpdateMainStatus($"预计算碰撞分析完成,共 {totalCollisions} 个候选碰撞"); + + var recordId = SaveCollisionDetectionRecord(); + if (recordId.HasValue && IsReusedHistoryRecord(recordId.Value)) + { + LogManager.Info("[碰撞分析] 用户选择使用历史记录,跳过动画生成"); + } + break; + + case CollisionAnalysisDialog.CollisionAnalysisAction.Cancel: + case CollisionAnalysisDialog.CollisionAnalysisAction.None: + default: + UpdateMainStatus("预计算碰撞分析已取消"); + break; + } + } + catch (Exception ex) + { + LogManager.Error($"[碰撞分析] 处理分析窗口结果失败: {ex.Message}", ex); + } + } + /// /// 保存检测记录到数据库 /// 在生成动画完成后调用,创建检测记录并保存当时的完整配置 @@ -3104,150 +3174,38 @@ namespace NavisworksTransport.UI.WPF.ViewModels } string routeId = CurrentPathRoute?.Id ?? ""; - - // 🔥 检查是否已有相同配置的检测记录 - var existingRecordResult = CheckExistingDetectionRecord(); - if (existingRecordResult.HasValue) + + if (!DialogHelper.IsTestAutomationModeEnabled) { - var existingId = existingRecordResult.Value.id; - var userChoice = existingRecordResult.Value.userChoice; - - if (userChoice == UserChoice.UseExisting) + // 🔥 检查是否已有相同配置的检测记录 + var existingRecordResult = CheckExistingDetectionRecord(); + if (existingRecordResult.HasValue) { - // 用户选择使用历史记录 - LogManager.Info($"[检测记录] 用户选择使用历史记录 (Id={existingId})"); - _pathAnimationManager.CurrentDetectionRecordId = existingId; - _pathAnimationManager.IsUsingHistoryRecord = true; // 标记使用历史记录,动画完成后跳过ClashDetective - - // 在历史列表中选中该记录 - SelectHistoryRecordById(existingId); - - return existingId; + var existingId = existingRecordResult.Value.id; + var userChoice = existingRecordResult.Value.userChoice; + + if (userChoice == UserChoice.UseExisting) + { + // 用户选择使用历史记录 + LogManager.Info($"[检测记录] 用户选择使用历史记录 (Id={existingId})"); + _pathAnimationManager.CurrentDetectionRecordId = existingId; + _pathAnimationManager.IsUsingHistoryRecord = true; // 标记使用历史记录,动画完成后跳过ClashDetective + + // 在历史列表中选中该记录 + SelectHistoryRecordById(existingId); + + return existingId; + } + // 用户选择重新生成,继续创建新记录 + LogManager.Info($"[检测记录] 用户选择重新生成,创建新记录"); } - // 用户选择重新生成,继续创建新记录 - LogManager.Info($"[检测记录] 用户选择重新生成,创建新记录"); + } + else + { + LogManager.Info("[检测记录] 自动测试模式下跳过重复检测记录检查,直接创建新记录"); } - // 生成测试名称(将在ClashDetective完成后更新为正式名称) - string testName = $"动画检测_{DateTime.Now:MMdd_HHmmssfff}"; - - // 创建检测记录 - var record = new CollisionDetectionRecord - { - RouteId = routeId, - CreatedTime = DateTime.Now, - FrameRate = _animationFrameRate, - DurationSeconds = AnimationDuration, - // 检测容差:UI为米单位,内部使用模型单位 - DetectionTolerance = _detectionTolerance * UnitsConverter.GetMetersToUnitsConversionFactor(), - IsVirtualObject = UseVirtualObject, - AnimatedObjectName = UseVirtualObject ? "虚拟物体" : SelectedAnimatedObject?.DisplayName, - DetectAllObjects = !IsManualCollisionTargetEnabled, - ObjectRotationCorrection = _objectRotationCorrection, - Description = $"动画生成于 {DateTime.Now:yyyy-MM-dd HH:mm:ss}", - // 设置碰撞检测相关字段 - TestName = testName, - TestTime = DateTime.Now, - CollisionCount = 0, // 初始为0,将在ClashDetective完成后更新 - AnimationCollisionCount = 0 - }; - - // 虚拟物体尺寸(米 → 模型单位,数据库存储模型单位) - if (UseVirtualObject) - { - double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); - record.VirtualObjectLength = VirtualObjectLengthInMeters * metersToUnits; - record.VirtualObjectWidth = VirtualObjectWidthInMeters * metersToUnits; - record.VirtualObjectHeight = VirtualObjectHeightInMeters * metersToUnits; - } - // 真实物体信息 - else if (SelectedAnimatedObject != null) - { - try - { - var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(SelectedAnimatedObject); - record.ObjectModelIndex = pathId.ModelIndex; - record.ObjectPathId = pathId.PathId; - } - catch (Exception ex) - { - LogManager.Warning($"[检测记录] 获取运动物体PathId失败: {ex.Message}"); - } - } - - // 保存检测记录 - int recordId = pathDatabase.SaveCollisionDetectionRecord(record); - LogManager.Info($"[检测记录] 已创建记录 (Id={recordId})"); - - // 🔥 设置当前检测记录ID到PathAnimationManager(供碰撞结果保存时关联使用) - _pathAnimationManager.CurrentDetectionRecordId = recordId; - _pathAnimationManager.IsUsingHistoryRecord = false; // 新记录,不是历史记录 - - // 保存排除列表 - var excludedObjects = _pathAnimationManager?.GetExcludedObjects(); - if (excludedObjects != null && excludedObjects.Count > 0) - { - var excludedRecords = new List(); - foreach (var obj in excludedObjects) - { - if (obj == null) continue; - try - { - var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(obj); - excludedRecords.Add(new CollisionDetectionExcludedObjectRecord - { - DetectionRecordId = recordId, - ModelIndex = pathId.ModelIndex, - PathId = pathId.PathId, - DisplayName = obj.DisplayName, - ObjectName = obj.DisplayName, - Reason = "预计算碰撞分析排除" - }); - } - catch (Exception ex) - { - LogManager.Warning($"[检测记录] 准备排除对象记录失败: {obj.DisplayName}, {ex.Message}"); - } - } - - if (excludedRecords.Count > 0) - { - pathDatabase.SaveCollisionDetectionExcludedObjects(recordId, excludedRecords); - LogManager.Info($"[检测记录] 已保存 {excludedRecords.Count} 个排除对象"); - } - } - - // 保存手工目标 - if (IsManualCollisionTargetEnabled && _manualCollisionTargets != null && _manualCollisionTargets.Count > 0) - { - var manualTargetRecords = new List(); - foreach (var target in _manualCollisionTargets) - { - if (target?.ModelItem == null) continue; - try - { - var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(target.ModelItem); - manualTargetRecords.Add(new CollisionDetectionManualTargetRecord - { - DetectionRecordId = recordId, - ModelIndex = pathId.ModelIndex, - PathId = pathId.PathId, - DisplayName = target.ModelItem.DisplayName, - ObjectName = target.ModelItem.DisplayName - }); - } - catch (Exception ex) - { - LogManager.Warning($"[检测记录] 准备手工目标记录失败: {target.DisplayName}, {ex.Message}"); - } - } - - if (manualTargetRecords.Count > 0) - { - pathDatabase.SaveCollisionDetectionManualTargets(recordId, manualTargetRecords); - LogManager.Info($"[检测记录] 已保存 {manualTargetRecords.Count} 个手工目标"); - } - } + int recordId = CreateCollisionDetectionRecordSnapshot("动画生成", bindToAnimationManager: true); // 🔥 注册动画配置哈希与检测记录ID的映射(用于后续检查重复配置) try @@ -3274,6 +3232,136 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + /// + /// 创建碰撞检测记录快照。 + /// 生成动画和批处理都应该创建自己的配置快照,不依赖旧检测结果。 + /// + private int CreateCollisionDetectionRecordSnapshot(string scenarioName, bool bindToAnimationManager) + { + var pathDatabase = _pathPlanningManager?.GetPathDatabase(); + if (pathDatabase == null) + { + throw new InvalidOperationException("数据库未初始化,无法保存检测记录"); + } + + string routeId = CurrentPathRoute?.Id ?? string.Empty; + string testName = $"{scenarioName}_{DateTime.Now:MMdd_HHmmssfff}"; + double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); + + var record = new CollisionDetectionRecord + { + RouteId = routeId, + CreatedTime = DateTime.Now, + FrameRate = _animationFrameRate, + DurationSeconds = AnimationDuration, + DetectionTolerance = _detectionTolerance * metersToUnits, + IsVirtualObject = UseVirtualObject, + AnimatedObjectName = UseVirtualObject ? "虚拟物体" : SelectedAnimatedObject?.DisplayName, + DetectAllObjects = !IsManualCollisionTargetEnabled, + ObjectRotationCorrection = _objectRotationCorrection.ZDegrees, + Description = $"{scenarioName}于 {DateTime.Now:yyyy-MM-dd HH:mm:ss}", + TestName = testName, + TestTime = DateTime.Now, + CollisionCount = 0, + AnimationCollisionCount = 0 + }; + + if (UseVirtualObject) + { + record.VirtualObjectLength = VirtualObjectLengthInMeters * metersToUnits; + record.VirtualObjectWidth = VirtualObjectWidthInMeters * metersToUnits; + record.VirtualObjectHeight = VirtualObjectHeightInMeters * metersToUnits; + } + else if (SelectedAnimatedObject != null) + { + try + { + var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(SelectedAnimatedObject); + record.ObjectModelIndex = pathId.ModelIndex; + record.ObjectPathId = pathId.PathId; + } + catch (Exception ex) + { + LogManager.Warning($"[检测记录] 获取运动物体PathId失败: {ex.Message}"); + } + } + + int recordId = pathDatabase.SaveCollisionDetectionRecord(record); + LogManager.Info($"[检测记录] 已创建{scenarioName}记录 (Id={recordId})"); + + if (bindToAnimationManager) + { + _pathAnimationManager.CurrentDetectionRecordId = recordId; + _pathAnimationManager.IsUsingHistoryRecord = false; + } + + var excludedObjects = _pathAnimationManager?.GetExcludedObjects(); + if (excludedObjects != null && excludedObjects.Count > 0) + { + var excludedRecords = new List(); + foreach (var obj in excludedObjects) + { + if (obj == null) continue; + try + { + var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(obj); + excludedRecords.Add(new CollisionDetectionExcludedObjectRecord + { + DetectionRecordId = recordId, + ModelIndex = pathId.ModelIndex, + PathId = pathId.PathId, + DisplayName = obj.DisplayName, + ObjectName = obj.DisplayName, + Reason = "预计算碰撞分析排除" + }); + } + catch (Exception ex) + { + LogManager.Warning($"[检测记录] 准备排除对象记录失败: {obj.DisplayName}, {ex.Message}"); + } + } + + if (excludedRecords.Count > 0) + { + pathDatabase.SaveCollisionDetectionExcludedObjects(recordId, excludedRecords); + LogManager.Info($"[检测记录] 已保存 {excludedRecords.Count} 个排除对象"); + } + } + + if (IsManualCollisionTargetEnabled && _manualCollisionTargets != null && _manualCollisionTargets.Count > 0) + { + var manualTargetRecords = new List(); + foreach (var target in _manualCollisionTargets) + { + if (target?.ModelItem == null) continue; + try + { + var pathId = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(target.ModelItem); + manualTargetRecords.Add(new CollisionDetectionManualTargetRecord + { + DetectionRecordId = recordId, + ModelIndex = pathId.ModelIndex, + PathId = pathId.PathId, + DisplayName = target.ModelItem.DisplayName, + ObjectName = target.ModelItem.DisplayName + }); + } + catch (Exception ex) + { + LogManager.Warning($"[检测记录] 准备手工目标记录失败: {target.DisplayName}, {ex.Message}"); + } + } + + if (manualTargetRecords.Count > 0) + { + pathDatabase.SaveCollisionDetectionManualTargets(recordId, manualTargetRecords); + LogManager.Info($"[检测记录] 已保存 {manualTargetRecords.Count} 个手工目标"); + } + } + + return recordId; + } + /// /// 用户选择析析 /// @@ -3335,7 +3423,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels UseVirtualObject ? VirtualObjectLengthInMeters * factor : (double?)null, // 米 → 模型单位 UseVirtualObject ? VirtualObjectWidthInMeters * factor : (double?)null, UseVirtualObject ? VirtualObjectHeightInMeters * factor : (double?)null, - _objectRotationCorrection, + _objectRotationCorrection.ZDegrees, IsManualCollisionTargetEnabled ); @@ -3380,6 +3468,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels /// private (int id, UserChoice userChoice)? ShowDuplicateConfigDialog(CollisionDetectionRecord record) { + if (TestAutomationHttpService.ShouldAutoChooseCreateNewDetectionRecord) + { + _lastReusedRecordId = null; + LogManager.Info($"[检测记录] 测试自动化已接管重复配置对话框,自动选择重新生成新记录 (ExistingId={record.Id})"); + return (record.Id, UserChoice.CreateNew); + } + var message = $"检测配置已存在!\n\n" + $"找到一条相同配置的历史检测记录:\n" + $"• 测试名称:{record.TestName ?? "未命名"}\n" + @@ -3573,7 +3668,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels } var records = pathDatabase.GetDetectionResultsByPath(pathName); - LogManager.Info($"[刷新碰撞历史] 查询到 {records.Count} 条记录,路径={pathName}"); + LogManager.Debug($"[刷新碰撞历史] 查询到 {records.Count} 条记录,路径={pathName}"); _clashDetectiveResults.Clear(); foreach (var record in records) @@ -3606,7 +3701,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels PathId = record.ObjectPathId }; resultViewModel.AnimatedObject = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.ResolvePathId(animatedObjectPathId); - LogManager.Info($"[碰撞历史] 已加载运动物体: {resultViewModel.AnimatedObject?.DisplayName ?? "未找到"} for TestName={record.TestName}"); + LogManager.Debug($"[碰撞历史] 已加载运动物体: {resultViewModel.AnimatedObject?.DisplayName ?? "未找到"} for TestName={record.TestName}"); } catch (Exception ex) { @@ -3663,9 +3758,21 @@ namespace NavisworksTransport.UI.WPF.ViewModels ModelItem = modelItem, HighlightCommand = new RelayCommand(() => resultViewModel.ExecuteHighlightCollisionObject(objViewModel)), // 填充碰撞位置信息 - Item1Position = objRecord.HasPositionInfo && objRecord.Item1PosX.HasValue ? - new Point3D(objRecord.Item1PosX.Value, objRecord.Item1PosY.Value, objRecord.Item1PosZ.Value) : null, - Item1YawRadians = objRecord.Item1YawRadians ?? 0, + AnimatedObjectTrackedPosition = objRecord.HasPositionInfo && objRecord.AnimatedObjectTrackedPosX.HasValue ? + new Point3D(objRecord.AnimatedObjectTrackedPosX.Value, objRecord.AnimatedObjectTrackedPosY.Value, objRecord.AnimatedObjectTrackedPosZ.Value) : null, + AnimatedObjectTrackedYawRadians = objRecord.AnimatedObjectTrackedYawRadians ?? 0, + AnimatedObjectTrackedRotation = objRecord.AnimatedObjectHasTrackedRotation && + objRecord.AnimatedObjectTrackedRotA.HasValue && + objRecord.AnimatedObjectTrackedRotB.HasValue && + objRecord.AnimatedObjectTrackedRotC.HasValue && + objRecord.AnimatedObjectTrackedRotD.HasValue + ? new Rotation3D( + objRecord.AnimatedObjectTrackedRotA.Value, + objRecord.AnimatedObjectTrackedRotB.Value, + objRecord.AnimatedObjectTrackedRotC.Value, + objRecord.AnimatedObjectTrackedRotD.Value) + : Rotation3D.Identity, + AnimatedObjectHasTrackedRotation = objRecord.AnimatedObjectHasTrackedRotation, HasPositionInfo = objRecord.HasPositionInfo }; resultViewModel.CollisionObjects.Add(objViewModel); @@ -4269,54 +4376,86 @@ namespace NavisworksTransport.UI.WPF.ViewModels } /// - /// 计算旋转后的有效宽度和长度 + /// 计算本地三轴预旋转后的有效尺寸 /// 返回值为模型单位(与Navisworks API一致) /// - /// (有效宽度, 有效长度) - 模型单位 - private (double effectiveWidth, double effectiveLength) CalculateRotatedDimensions() + /// (沿路径, 垂直路径, 法线) - 模型单位 + private (double effectiveAlongPath, double effectiveAcrossPath, double effectiveNormalToPath) CalculateRotatedDimensions() { - double baseLengthMeters, baseWidthMeters; + double forwardSize; + double sideSize; + double upSize; + ModelAxisConvention axisConvention; if (UseVirtualObject) { - baseLengthMeters = VirtualObjectLengthInMeters; - baseWidthMeters = VirtualObjectWidthInMeters; + double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); + forwardSize = VirtualObjectLengthInMeters * metersToUnits; + sideSize = VirtualObjectWidthInMeters * metersToUnits; + upSize = VirtualObjectHeightInMeters * metersToUnits; + axisConvention = ModelAxisConvention.CreateVirtualObjectAssetConvention(); } else if (SelectedAnimatedObject != null) { - // 使用保存的原始尺寸(米),避免物体旋转后包围盒尺寸变化 - baseLengthMeters = _objectOriginalLength; - baseWidthMeters = _objectOriginalWidth; + double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); + forwardSize = _objectOriginalLength * metersToUnits; + sideSize = _objectOriginalWidth * metersToUnits; + upSize = _objectOriginalHeight * metersToUnits; + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + if ((CurrentPathRoute?.PathType == NavisworksTransport.PathType.Ground || + CurrentPathRoute?.PathType == NavisworksTransport.PathType.Hoisting) && + _pathAnimationManager != null && + _pathAnimationManager.TryGetCurrentRouteRealObjectPlanarProjectedExtents( + out double resolvedPlanarForward, + out double resolvedPlanarSide, + out double resolvedPlanarUp)) + { + double unitsToMetersForPlanar = UnitsConverter.GetUnitsToMetersConversionFactor(); + LogManager.Debug( + $"[角度修正] 平面真实物体复用最终姿态尺寸: Forward={resolvedPlanarForward * unitsToMetersForPlanar:F2}m, " + + $"Side={resolvedPlanarSide * unitsToMetersForPlanar:F2}m, Up={resolvedPlanarUp * unitsToMetersForPlanar:F2}m, 角度={_objectRotationCorrection}"); + return (resolvedPlanarForward, resolvedPlanarSide, resolvedPlanarUp); + } + if (CurrentPathRoute?.PathType == NavisworksTransport.PathType.Rail && + _pathAnimationManager != null && + _pathAnimationManager.TryGetCurrentRouteRealObjectRailProjectedExtents( + out double resolvedForward, + out double resolvedSide, + out double resolvedUp)) + { + double unitsToMetersForRail = UnitsConverter.GetUnitsToMetersConversionFactor(); + LogManager.Debug( + $"[角度修正] Rail真实物体复用路径姿态尺寸: Forward={resolvedForward * unitsToMetersForRail:F2}m, " + + $"Side={resolvedSide * unitsToMetersForRail:F2}m, Up={resolvedUp * unitsToMetersForRail:F2}m, 角度={_objectRotationCorrection}"); + return (resolvedForward, resolvedSide, resolvedUp); + } + + axisConvention = CurrentPathRoute?.PathType == NavisworksTransport.PathType.Rail + ? ModelAxisConvention.CreateRailAssetConvention() + : ModelAxisConvention.CreateDefaultForHost(adapter.HostType); } else { - return (0, 0); + return (0, 0, 0); } - // 转换为模型单位 - var metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(); - double baseLength = baseLengthMeters * metersToUnits; - double baseWidth = baseWidthMeters * metersToUnits; + var hostAdapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Quaternion correctionQuaternion = UseVirtualObject + ? hostAdapter.CreateCanonicalRotationCorrection(_objectRotationCorrection) + : hostAdapter.CreateHostRotationCorrection(_objectRotationCorrection); + var result = RotatedObjectExtentHelper.CalculateProjectedSemanticExtents( + axisConvention, + forwardSize, + sideSize, + upSize, + correctionQuaternion); + double unitsToMeters = UnitsConverter.GetUnitsToMetersConversionFactor(); + LogManager.Debug( + $"[角度修正] 旋转后尺寸: 原始({forwardSize * unitsToMeters:F2}m×{sideSize * unitsToMeters:F2}m×{upSize * unitsToMeters:F2}m) -> " + + $"有效({result.forwardExtent * unitsToMeters:F2}m×{result.sideExtent * unitsToMeters:F2}m×{result.upExtent * unitsToMeters:F2}m), 角度={_objectRotationCorrection}"); - // 如果没有角度修正,直接返回原始尺寸 - if (_objectRotationCorrection == 0.0) - { - return (baseWidth, baseLength); - } - - // 计算旋转后的有效尺寸(投影) - double rotationRad = _objectRotationCorrection * Math.PI / 180.0; - double cos = Math.Abs(Math.Cos(rotationRad)); - double sin = Math.Abs(Math.Sin(rotationRad)); - - // 旋转后的宽度 = |宽度*cos| + |长度*sin| - double effectiveWidth = baseWidth * cos + baseLength * sin; - // 旋转后的长度 = |长度*cos| + |宽度*sin| - double effectiveLength = baseLength * cos + baseWidth * sin; - - LogManager.Debug($"[角度修正] 旋转后尺寸: 原始({baseLengthMeters:F2}m×{baseWidthMeters:F2}m) -> 有效({effectiveLength / metersToUnits:F2}m×{effectiveWidth / metersToUnits:F2}m), 角度={_objectRotationCorrection:F1}°"); - - return (effectiveWidth, effectiveLength); + return result; } /// @@ -4335,7 +4474,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels } // 计算旋转后的有效尺寸(考虑角度修正) - (double effectiveWidth, double effectiveLength) = CalculateRotatedDimensions(); + (double effectiveLength, double effectiveWidth, double effectiveHeight) = CalculateRotatedDimensions(); var renderPlugin = PathPointRenderPlugin.Instance; if (renderPlugin == null) @@ -4350,8 +4489,6 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 将安全间隙从米转换为模型单位 var metersToUnitsPassage = UnitsConverter.GetMetersToUnitsConversionFactor(); double safetyMargin = _safetyMarginInMeters * metersToUnitsPassage; - double virtualObjectHeight = VirtualObjectHeightInMeters * metersToUnitsPassage; - if (UseVirtualObject) { // 虚拟物体的xyz定义:X方向 = 长度(前进方向),Y方向 = 宽度(侧面),Z方向 = 高度 @@ -4359,11 +4496,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels { // 空中路径(空轨或吊装):高度上下都加间隙 passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = virtualObjectHeight + 2 * safetyMargin; // Z方向(高度)+ 2*间隙(法线方向) + passageNormalToPath = effectiveHeight + 2 * safetyMargin; // 法线方向高度 + 2*间隙 passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) // 吊装路径分段参数 passageNormalToPathVertical = effectiveLength + 2 * safetyMargin; // 垂直段:物体长度 + 2*间隙 - passageNormalToPathHorizontal = virtualObjectHeight + 2 * safetyMargin; // 水平段:物体高度 + 2*间隙 + passageNormalToPathHorizontal = effectiveHeight + 2 * safetyMargin; // 水平段:物体高度 + 2*间隙 LogManager.Debug($"[通行空间可视化] 空中路径使用虚拟物体尺寸: 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); } else if (CurrentPathRoute.PathType == NavisworksTransport.PathType.Ground) @@ -4371,7 +4508,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 地面路径:物体的长度方向(X轴,前进方向)朝向路径方向 // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度),高度上方加间隙(下方不需要,因为有地面) passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = virtualObjectHeight + safetyMargin; // Z方向(高度)+ 间隙(法线方向,只有上方) + passageNormalToPath = effectiveHeight + (_objectGroundLiftHeightInMeters * metersToUnitsPassage) + safetyMargin; // 物体高度 + 提升高度 + 上方间隙 passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) // 地面路径不需要分段参数 passageNormalToPathVertical = passageNormalToPath; @@ -4381,9 +4518,12 @@ namespace NavisworksTransport.UI.WPF.ViewModels else // Rail { // 空轨路径:物体的长度方向(X轴,前进方向)朝向路径方向 - // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度),高度上下都加间隙(在空中) + // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度) + // 法线方向只在远离轨道的一侧留间隙: + // - 轨下安装:顶面贴路径,底面留间隙 + // - 轨上安装:底面贴路径,顶面留间隙 passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = virtualObjectHeight + 2 * safetyMargin; // Z方向(高度)+ 2*间隙(法线方向,上下都加) + passageNormalToPath = effectiveHeight + safetyMargin; // 法线方向单侧留间隙 passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) // 空轨路径不需要分段参数 passageNormalToPathVertical = passageNormalToPath; @@ -4393,26 +4533,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels } else if (SelectedAnimatedObject != null) { - // 使用选择物体的包围盒尺寸(模型单位) - var bbox = SelectedAnimatedObject.BoundingBox(); - - // 包围盒尺寸已经是模型单位 - double sizeX = bbox.Max.X - bbox.Min.X; // X方向 = 长度(前进方向) - double sizeY = bbox.Max.Y - bbox.Min.Y; // Y方向 = 宽度(侧面) - double sizeZ = bbox.Max.Z - bbox.Min.Z; // Z方向 = 高度 - // 根据路径类型确定通行空间的尺寸 - if (CurrentPathRoute.PathType == NavisworksTransport.PathType.Rail || CurrentPathRoute.PathType == NavisworksTransport.PathType.Hoisting) + if (CurrentPathRoute.PathType == NavisworksTransport.PathType.Hoisting) { - // 空中路径(空轨或吊装): + // 吊装路径: // 高度上下都加间隙 passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = sizeZ + 2 * safetyMargin; // Z方向(高度)+ 2*间隙(法线方向) + passageNormalToPath = effectiveHeight + 2 * safetyMargin; // 局部up方向高度 + 2*间隙(法线方向) passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) // 吊装路径分段参数 passageNormalToPathVertical = effectiveLength + 2 * safetyMargin; // 垂直段:物体长度 + 2*间隙 - passageNormalToPathHorizontal = sizeZ + 2 * safetyMargin; // 水平段:物体高度 + 2*间隙 - LogManager.Debug($"[通行空间可视化] 空中路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 高度={sizeZ / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); + passageNormalToPathHorizontal = effectiveHeight + 2 * safetyMargin; // 水平段:物体高度 + 2*间隙 + LogManager.Debug($"[通行空间可视化] 空中路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 有效高度={effectiveHeight / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); } else if (CurrentPathRoute.PathType == NavisworksTransport.PathType.Ground) { @@ -4420,24 +4552,35 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 物体的长度方向(X轴,前进方向)朝向路径方向 // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度),高度上方加间隙(下方不需要,因为有地面) passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = sizeZ + safetyMargin; // Z方向(高度)+ 间隙(法线方向,只有上方) + passageNormalToPath = effectiveHeight + (_objectGroundLiftHeightInMeters * metersToUnitsPassage) + safetyMargin; // 物体高度 + 提升高度 + 上方间隙 passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) // 地面路径不需要分段参数 passageNormalToPathVertical = passageNormalToPath; passageNormalToPathHorizontal = passageNormalToPath; - LogManager.Debug($"[通行空间可视化] 地面路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); + LogManager.Debug($"[通行空间可视化] 地面路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 有效高度={effectiveHeight / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); } - else // Rail + else if (CurrentPathRoute.PathType == NavisworksTransport.PathType.Rail) { // 空轨路径(可能有坡度): // 物体的长度方向(X轴,前进方向)朝向路径方向 - // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度),高度上下都加间隙(在空中) + // 通行空间沿路径方向 = X方向尺寸(长度),垂直于路径方向 = Y方向尺寸(宽度) + // 法线方向只在远离轨道的一侧留间隙: + // - 轨下安装:顶面贴路径,底面留间隙 + // - 轨上安装:底面贴路径,顶面留间隙 passageAcrossPath = effectiveWidth + 2 * safetyMargin; // 旋转后宽度 + 2*间隙(垂直于路径方向) - passageNormalToPath = sizeZ + 2 * safetyMargin; // Z方向(高度)+ 2*间隙(法线方向,上下都加) + passageNormalToPath = effectiveHeight + safetyMargin; // 局部up方向高度 + 单侧间隙(法线方向) passageAlongPath = effectiveLength; // 旋转后长度(沿路径方向) passageNormalToPathVertical = passageNormalToPath; passageNormalToPathHorizontal = passageNormalToPath; - LogManager.Debug($"[通行空间可视化] 空轨路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); + LogManager.Debug($"[通行空间可视化] 空轨路径使用物体尺寸 ({SelectedAnimatedObject.DisplayName}): 有效长度={effectiveLength / metersToUnitsPassage:F2}m, 有效宽度={effectiveWidth / metersToUnitsPassage:F2}m, 有效高度={effectiveHeight / metersToUnitsPassage:F2}m, 通行空间沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m"); + } + else + { + passageAcrossPath = effectiveWidth + 2 * safetyMargin; + passageNormalToPath = effectiveHeight + safetyMargin; + passageAlongPath = effectiveLength; + passageNormalToPathVertical = passageNormalToPath; + passageNormalToPathHorizontal = passageNormalToPath; } } else @@ -4453,7 +4596,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels passageAlongPath, passageNormalToPathVertical, passageNormalToPathHorizontal); - LogManager.Debug($"[通行空间可视化] 已设置通行空间参数: 沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m, 安全间隙={_safetyMarginInMeters:F2}m"); + LogManager.Debug($"[通行空间可视化] 已设置通行空间参数: 沿路径={passageAlongPath / metersToUnitsPassage:F2}m, 垂直路径={passageAcrossPath / metersToUnitsPassage:F2}m, 法线={passageNormalToPath / metersToUnitsPassage:F2}m, 提升高度={_objectGroundLiftHeightInMeters:F2}m, 安全间隙={_safetyMarginInMeters:F2}m"); // 根据路径类型决定是否切换到物体通行空间模式 bool enableObjectSpace = false; @@ -4911,8 +5054,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels } // 重置角度修正值为0 - ObjectRotationCorrection = 0.0; - LogManager.Debug("[重置状态] 已重置角度修正值为0度"); + ObjectRotationCorrection = LocalEulerRotationCorrection.Zero; + LogManager.Debug("[重置状态] 已重置角度修正值为0"); // 清空选中对象 SelectedAnimatedObject = null; @@ -5192,22 +5335,9 @@ namespace NavisworksTransport.UI.WPF.ViewModels LogManager.Info($"[批处理] 添加路径到批处理队列: {CurrentPathRoute.Name}"); - // 🔥 获取该路径最新的检测记录ID - int? detectionRecordId = null; - var pathDatabase = _pathPlanningManager?.GetPathDatabase(); - if (pathDatabase != null) - { - var latestRecord = pathDatabase.GetLatestCollisionDetectionRecord(CurrentPathRoute.Id); - detectionRecordId = latestRecord?.Id; - if (detectionRecordId.HasValue) - { - LogManager.Info($"[批处理] 关联检测记录 (Id={detectionRecordId})"); - } - else - { - LogManager.Warning("[批处理] 未找到检测记录,请先运行\"生成动画\""); - } - } + // 为批处理创建独立的检测记录快照,保存当前配置、排除列表和手工目标 + int detectionRecordId = CreateCollisionDetectionRecordSnapshot("批处理任务创建", bindToAnimationManager: false); + LogManager.Info($"[批处理] 已创建批处理检测记录 (Id={detectionRecordId})"); // 创建批处理队列项 // 注意:虚拟物体尺寸需要从米转换为模型单位存入数据库 @@ -5235,7 +5365,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels DetectAllObjects = !IsManualCollisionTargetEnabled, // 角度修正配置 - ObjectRotationCorrection = _objectRotationCorrection, + ObjectRotationCorrection = _objectRotationCorrection.ZDegrees, // 🔥 关联的检测记录ID DetectionRecordId = detectionRecordId @@ -5320,7 +5450,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels $"虚拟物体: {queueItem.IsVirtualObject}, " + $"帧率: {queueItem.FrameRate}, " + $"时长: {queueItem.DurationSeconds:F2}秒, " + - $"角度修正: {queueItem.ObjectRotationCorrection:F1}°, " + + $"角度修正: {_objectRotationCorrection}, " + $"ID: {itemId}"); UpdateMainStatus($"已添加到批处理队列: {CurrentPathRoute.Name}"); @@ -5349,4 +5479,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/ViewModels/CollisionReportViewModel.cs b/src/UI/WPF/ViewModels/CollisionReportViewModel.cs index 37e97bc..5349f7b 100644 --- a/src/UI/WPF/ViewModels/CollisionReportViewModel.cs +++ b/src/UI/WPF/ViewModels/CollisionReportViewModel.cs @@ -917,7 +917,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels Title = "导出碰撞报告", Filter = "HTML报告 (*.html)|*.html|碰撞报告 (*.txt)|*.txt|所有文件 (*.*)|*.*", DefaultExt = "html", - FileName = $"NavisworksTransport_CollisionReport_{DateTime.Now:yyyyMMdd_HHmmss}" + FileName = PathHelper.GenerateCollisionReportFileName(CurrentReport?.PathName ?? PathName, "html") }; if (saveFileDialog.ShowDialog() == true) @@ -946,11 +946,10 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - if (CurrentReport == null || !HasCollisions) return; + if (CurrentReport == null) return; // 生成默认文件名和路径 - var sanitizedName = PathHelper.SanitizeFileName(CurrentReport.PathName ?? "未知路径"); - var fileName = $"NavisworksTransport_CollisionReport_{sanitizedName}_{DateTime.Now:yyyyMMdd_HHmmss}.html"; + var fileName = PathHelper.GenerateCollisionReportFileName(CurrentReport.PathName, "html"); var reportDir = PathHelper.GetReportDirectory(); var filePath = Path.Combine(reportDir, fileName); @@ -1389,7 +1388,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels var collisionData = collisionItem.CollisionData; // 首次查看碰撞时保存运动物体状态 - if (collisionData.Item1 != null && collisionData.HasPositionInfo && collisionData.Item1Position != null) + if (collisionData.Item1 != null && collisionData.HasPositionInfo && collisionData.AnimatedObjectTrackedPosition != null) { if (_savedAnimatedObjectState == null || _currentAnimatedObject != collisionData.Item1) { @@ -1432,4 +1431,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/ViewModels/LogisticsControlViewModel.cs b/src/UI/WPF/ViewModels/LogisticsControlViewModel.cs index bfe3c2e..182f913 100644 --- a/src/UI/WPF/ViewModels/LogisticsControlViewModel.cs +++ b/src/UI/WPF/ViewModels/LogisticsControlViewModel.cs @@ -14,6 +14,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels /// public class LogisticsControlViewModel : ViewModelBase { + private enum ClipBoxFocusMode + { + None, + Path, + Selection + } + #region 私有字段 private string _statusText; @@ -144,6 +151,10 @@ namespace NavisworksTransport.UI.WPF.ViewModels } private bool _isClipBoxEnabled; + private bool _isSelectionClipBoxEnabled; + private bool _isUpdatingClipBoxState; + private ClipBoxFocusMode _clipBoxFocusMode = ClipBoxFocusMode.None; + private readonly SelectionClipBoxLockState _selectionClipBoxLockState = new SelectionClipBoxLockState(); /// /// 是否启用剖面盒(聚焦当前路径) @@ -155,14 +166,45 @@ namespace NavisworksTransport.UI.WPF.ViewModels { if (SetProperty(ref _isClipBoxEnabled, value)) { + if (_isUpdatingClipBoxState) + { + return; + } + if (value) { - EnableClipBoxForCurrentRoute(); + ActivatePathClipBox(); } - else + else if (_clipBoxFocusMode == ClipBoxFocusMode.Path) { - SectionClipHelper.ClearClipBox(); - LogManager.Info("[剖面盒] 已关闭"); + ClearActiveClipBox(); + } + } + } + } + + /// + /// 是否启用剖面盒(聚焦当前选择对象) + /// + public bool IsSelectionClipBoxEnabled + { + get => _isSelectionClipBoxEnabled; + set + { + if (SetProperty(ref _isSelectionClipBoxEnabled, value)) + { + if (_isUpdatingClipBoxState) + { + return; + } + + if (value) + { + ActivateSelectionClipBox(); + } + else if (_clipBoxFocusMode == ClipBoxFocusMode.Selection) + { + ClearActiveClipBox(); } } } @@ -238,14 +280,25 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 用于剖面盒自动跟随路径切换 _pathPlanningManager.CurrentRouteSwitched += (s, e) => { - if (IsClipBoxEnabled) + if (_clipBoxFocusMode == ClipBoxFocusMode.Path) { + if (_pathPlanningManager.PathEditState == PathEditState.Creating) + { + LogManager.Info("[剖面盒] 当前处于新建路径流程,跳过自动跟随"); + return; + } + LogManager.Info("[剖面盒] 路径已切换,重新设置剖面盒聚焦到新路径"); EnableClipBoxForCurrentRoute(); } }; } + if (NavisApplication.ActiveDocument?.CurrentSelection != null) + { + NavisApplication.ActiveDocument.CurrentSelection.Changed += OnCurrentSelectionChanged; + } + // 初始化状态 StatusText = "插件已就绪"; @@ -327,6 +380,70 @@ namespace NavisworksTransport.UI.WPF.ViewModels #region 剖面盒功能 + private void ActivatePathClipBox() + { + SetClipBoxState(ClipBoxFocusMode.Path); + EnableClipBoxForCurrentRoute(); + } + + private void ActivateSelectionClipBox() + { + SetClipBoxState(ClipBoxFocusMode.Selection); + EnableClipBoxForCurrentSelection(); + } + + private void ClearActiveClipBox() + { + SetClipBoxState(ClipBoxFocusMode.None); + _selectionClipBoxLockState.Clear(); + SectionClipHelper.ClearClipBox(); + LogManager.Info("[剖面盒] 已关闭"); + } + + private void SetClipBoxState(ClipBoxFocusMode mode) + { + _clipBoxFocusMode = mode; + bool pathEnabled = mode == ClipBoxFocusMode.Path; + bool selectionEnabled = mode == ClipBoxFocusMode.Selection; + + _isUpdatingClipBoxState = true; + try + { + if (_isClipBoxEnabled != pathEnabled) + { + _isClipBoxEnabled = pathEnabled; + OnPropertyChanged(nameof(IsClipBoxEnabled)); + } + + if (_isSelectionClipBoxEnabled != selectionEnabled) + { + _isSelectionClipBoxEnabled = selectionEnabled; + OnPropertyChanged(nameof(IsSelectionClipBoxEnabled)); + } + } + finally + { + _isUpdatingClipBoxState = false; + } + } + + private void OnCurrentSelectionChanged(object sender, EventArgs e) + { + if (_clipBoxFocusMode != ClipBoxFocusMode.Selection) + { + return; + } + + if (!_selectionClipBoxLockState.ShouldRefreshOnSelectionChanged(isSelectionClipBoxMode: true)) + { + LogManager.Info($"[剖面盒] 选择已变化,但当前剖面盒保持锁定,锁定对象数: {_selectionClipBoxLockState.LockedSelectionCount}"); + return; + } + + LogManager.Info("[剖面盒] 选择已变化,重新聚焦到当前选择对象"); + EnableClipBoxForCurrentSelection(); + } + /// /// 启用剖面盒并聚焦到当前路径 /// @@ -338,7 +455,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (currentRoute?.Points == null || currentRoute.Points.Count == 0) { LogManager.Warning("[剖面盒] 当前没有路径,无法设置剖面盒"); - IsClipBoxEnabled = false; + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); return; } @@ -384,13 +502,63 @@ namespace NavisworksTransport.UI.WPF.ViewModels else { LogManager.Warning("[剖面盒] 设置失败"); - IsClipBoxEnabled = false; + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); } } catch (Exception ex) { LogManager.Error($"[剖面盒] 启用失败: {ex.Message}"); - IsClipBoxEnabled = false; + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); + } + } + + /// + /// 启用剖面盒并聚焦到当前选择对象 + /// + private void EnableClipBoxForCurrentSelection() + { + try + { + var document = NavisApplication.ActiveDocument; + var selectedItems = document?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList(); + if (selectedItems == null || selectedItems.Count == 0) + { + LogManager.Warning("[剖面盒] 当前没有选中的模型对象,无法设置剖面盒"); + _selectionClipBoxLockState.Clear(); + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); + return; + } + + _selectionClipBoxLockState.LockSelection(selectedItems); + SectionClipHelper.ClearClipBox(); + + bool success = SectionClipHelper.SetClipBoxByModelItems( + selectedItems, + marginInMeters: 2.0, + heightMarginInMeters: 2.0); + + if (success) + { + ViewpointHelper.FocusOnModelItems(selectedItems, ViewpointHelper.ViewpointStrategy.ModelFocus); + LogManager.Info($"[剖面盒] 已按当前选择对象启用,选择数量: {selectedItems.Count}"); + } + else + { + LogManager.Warning("[剖面盒] 按选择对象设置失败"); + _selectionClipBoxLockState.Clear(); + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); + } + } + catch (Exception ex) + { + LogManager.Error($"[剖面盒] 按选择对象启用失败: {ex.Message}"); + _selectionClipBoxLockState.Clear(); + SectionClipHelper.ClearClipBox(); + SetClipBoxState(ClipBoxFocusMode.None); } } @@ -418,4 +586,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/ViewModels/ModelSettingsViewModel.cs b/src/UI/WPF/ViewModels/ModelSettingsViewModel.cs index 1e0093e..7eb10bd 100644 --- a/src/UI/WPF/ViewModels/ModelSettingsViewModel.cs +++ b/src/UI/WPF/ViewModels/ModelSettingsViewModel.cs @@ -1597,7 +1597,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels try { // 聚焦到模型(斜上方60度视角) - ViewpointHelper.FocusOnModelItem(logisticsModel.NavisworksItem, viewAngleDegrees: 60.0, targetViewRatio: 0.25); + ViewpointHelper.FocusOnModelItem(logisticsModel.NavisworksItem, ViewpointHelper.ViewpointStrategy.ModelFocus); UpdateMainStatus($"已聚焦到物流模型: {logisticsModel.Name}"); LogManager.Info($"聚焦到物流模型: {logisticsModel.Name}, 类别: {logisticsModel.Category}"); @@ -1684,4 +1684,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/ViewModels/PathEditingViewModel.cs b/src/UI/WPF/ViewModels/PathEditingViewModel.cs index 84139bf..31ae3c1 100644 --- a/src/UI/WPF/ViewModels/PathEditingViewModel.cs +++ b/src/UI/WPF/ViewModels/PathEditingViewModel.cs @@ -8,9 +8,9 @@ using System.Windows.Input; using System.Threading.Tasks; using System.Linq; using System.IO; +using System.Numerics; using Microsoft.Win32; using Autodesk.Navisworks.Api; -using Autodesk.Navisworks.Api.Plugins; using NavisApplication = Autodesk.Navisworks.Api.Application; using NavisworksTransport.Core; using NavisworksTransport.Core.Config; @@ -18,7 +18,8 @@ using NavisworksTransport.Commands; using NavisworksTransport.UI.WPF.Views; using NavisworksTransport.UI.WPF.Models; using NavisworksTransport.Utils; -using NavisworksTransport; +using NavisworksTransport.Utils.CoordinateSystem; +using NavisworksTransport.Utils.GeometryAnalysis; namespace NavisworksTransport.UI.WPF.ViewModels { @@ -43,6 +44,28 @@ namespace NavisworksTransport.UI.WPF.ViewModels public string Description { get; set; } } + /// + /// Rail 构型选项辅助类 + /// + /// 枚举类型 + public class RailConfigOption + { + /// + /// 配置值 + /// + public T Value { get; set; } + + /// + /// 显示名称 + /// + public string DisplayName { get; set; } + + /// + /// 详细描述 + /// + public string Description { get; set; } + } + /// /// 路径编辑页面专用ViewModel /// @@ -96,6 +119,207 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 路径策略参数 private PathStrategyOption _selectedPathStrategy; private ObservableCollection _pathStrategyOptions; + private ObservableCollection> _railMountModeOptions; + private const double DefaultAssemblyReferenceRodLengthInMeters = 20.0; + private static readonly double DefaultAssemblyReferenceRodDiameterInMeters = CalculateDefaultAssemblyReferenceRodDiameterInMeters(); + private const double DefaultAssemblyAnchorVerticalOffsetInMeters = 0.0; + private const double AssemblyVisualizationPointDiameterInMeters = 0.05; + private const double RailNormalOffsetNudgeStepInMeters = 0.1; + private const double DefaultAssemblySphereCenterX = 0.0; + private const double DefaultAssemblySphereCenterY = 0.0; + private const double DefaultAssemblySphereCenterZ = 0.0; + private readonly RailAssemblyWorkflowContext _railAssemblyContext = new RailAssemblyWorkflowContext( + DefaultAssemblyReferenceRodLengthInMeters, + DefaultAssemblyReferenceRodDiameterInMeters, + DefaultAssemblyAnchorVerticalOffsetInMeters, + DefaultAssemblySphereCenterX, + DefaultAssemblySphereCenterY, + DefaultAssemblySphereCenterZ); + private const string AssemblyAnchorMarkerPathId = "assembly_anchor_marker"; + private const string AssemblyCenterGuideLinePathId = "assembly_center_guide_line"; + private const string AssemblyReferenceLinePathId = "assembly_reference_line"; + private const string AssemblyEndFaceSeedPathId = "assembly_end_face_seed_points"; + private const string AssemblyEndFaceCenterPathId = "assembly_end_face_center"; + private const string AssemblyEndFaceNormalPathId = "assembly_end_face_normal"; + private const string AssemblyInstallationPickPointPathId = "assembly_install_pick_point"; + private const string AssemblyInstallationAnchorPointPathId = "assembly_install_anchor_point"; + private const string AssemblyInstallationCenterLinePathId = "assembly_install_center_line"; + private const string AssemblyInstallationOffsetLinePathId = "assembly_install_offset_line"; + private const string AssemblyInstallationPlanePathId = "assembly_install_plane"; + private const double AssemblyInstallationPlanePaddingInMeters = 0.5; + private const double AssemblyInstallationLineLengthScale = 1.2; + + private string _assemblyTerminalObjectName + { + get => _railAssemblyContext.TerminalObjectName; + set => _railAssemblyContext.TerminalObjectName = value; + } + + private string _assemblyTerminalObjectInfo + { + get => _railAssemblyContext.TerminalObjectInfo; + set => _railAssemblyContext.TerminalObjectInfo = value; + } + + private double _assemblyReferenceRodLengthInMeters + { + get => _railAssemblyContext.ReferenceRodLengthInMeters; + set => _railAssemblyContext.ReferenceRodLengthInMeters = value; + } + + private double _assemblyReferenceRodDiameterInMeters + { + get => _railAssemblyContext.ReferenceRodDiameterInMeters; + set => _railAssemblyContext.ReferenceRodDiameterInMeters = value; + } + + private double _assemblyAnchorVerticalOffsetInMeters + { + get => _railAssemblyContext.AnchorVerticalOffsetInMeters; + set => _railAssemblyContext.AnchorVerticalOffsetInMeters = value; + } + + private double _assemblySphereCenterX + { + get => _railAssemblyContext.SphereCenterX; + set => _railAssemblyContext.SphereCenterX = value; + } + + private double _assemblySphereCenterY + { + get => _railAssemblyContext.SphereCenterY; + set => _railAssemblyContext.SphereCenterY = value; + } + + private double _assemblySphereCenterZ + { + get => _railAssemblyContext.SphereCenterZ; + set => _railAssemblyContext.SphereCenterZ = value; + } + + private string _assemblyStartPointText + { + get => _railAssemblyContext.StartPointText; + set => _railAssemblyContext.StartPointText = value; + } + + private RailMountMode _assemblyMountMode + { + get => _railAssemblyContext.MountMode; + set => _railAssemblyContext.MountMode = value; + } + + private bool _hasAssemblyTerminalObject + { + get => _railAssemblyContext.HasTerminalObject; + set => _railAssemblyContext.HasTerminalObject = value; + } + + private bool _isSelectingAssemblyStartPoint + { + get => _railAssemblyContext.IsSelectingStartPoint; + set => _railAssemblyContext.IsSelectingStartPoint = value; + } + + private bool _isSelectingAssemblyEndFacePoints + { + get => _railAssemblyContext.IsSelectingEndFacePoints; + set => _railAssemblyContext.IsSelectingEndFacePoints = value; + } + + private bool _isSelectingAssemblyInstallationPoint + { + get => _railAssemblyContext.IsSelectingInstallationPoint; + set => _railAssemblyContext.IsSelectingInstallationPoint = value; + } + + private RailAssemblyWorkflowMode _railAssemblyWorkflowMode + { + get => _railAssemblyContext.WorkflowMode; + set => _railAssemblyContext.WorkflowMode = value; + } + + private bool _hasAssemblyEndFaceAnalysis + { + get => _railAssemblyContext.HasEndFaceAnalysis; + set => _railAssemblyContext.HasEndFaceAnalysis = value; + } + + private bool _hasAssemblyInstallationReference + { + get => _railAssemblyContext.HasInstallationReference; + set => _railAssemblyContext.HasInstallationReference = value; + } + + private ModelItem _assemblyTerminalObject + { + get => _railAssemblyContext.TerminalObject; + set => _railAssemblyContext.TerminalObject = value; + } + + private Point3D _assemblyStartPoint + { + get => _railAssemblyContext.StartPoint; + set => _railAssemblyContext.StartPoint = value; + } + + private Point3D _assemblyEndFaceCenterPoint + { + get => _railAssemblyContext.EndFaceCenterPoint; + set => _railAssemblyContext.EndFaceCenterPoint = value; + } + + private Vector3 _assemblyEndFaceNormal + { + get => _railAssemblyContext.EndFaceNormal; + set => _railAssemblyContext.EndFaceNormal = value; + } + + private Point3D _assemblyInstallationPickPoint + { + get => _railAssemblyContext.InstallationPickPoint; + set => _railAssemblyContext.InstallationPickPoint = value; + } + + private Point3D _assemblyInstallationBaseAnchorPoint + { + get => _railAssemblyContext.InstallationBaseAnchorPoint; + set => _railAssemblyContext.InstallationBaseAnchorPoint = value; + } + + private Point3D _assemblyInstallationAnchorPoint + { + get => _railAssemblyContext.InstallationAnchorPoint; + set => _railAssemblyContext.InstallationAnchorPoint = value; + } + + private Vector3 _assemblyInstallationPlaneNormal + { + get => _railAssemblyContext.InstallationPlaneNormal; + set => _railAssemblyContext.InstallationPlaneNormal = value; + } + + private Vector3 _assemblyInstallationPlaneSpanDirection + { + get => _railAssemblyContext.InstallationPlaneSpanDirection; + set => _railAssemblyContext.InstallationPlaneSpanDirection = value; + } + + private double _assemblyInstallationOffsetDistanceInMeters + { + get => _railAssemblyContext.InstallationOffsetDistanceInMeters; + set => _railAssemblyContext.InstallationOffsetDistanceInMeters = value; + } + + private string _assemblyInstallationReferenceRouteId + { + get => _railAssemblyContext.InstallationReferenceRouteId; + set => _railAssemblyContext.InstallationReferenceRouteId = value; + } + + private List _assemblyInstallationSeedPoints => _railAssemblyContext.InstallationSeedPoints; + + private List _assemblyEndFaceSeedPoints => _railAssemblyContext.EndFaceSeedPoints; // 自动路径起点和终点的路径对象引用(用于正确的ID管理) private PathRoute _autoPathStartPointRoute = null; @@ -108,6 +332,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 多层吊装模式 private bool _isMultiLevelHoistingMode = false; private ObservableCollection _multiLevelItems = new ObservableCollection(); + private ObservableCollection _hoistingLayerEditItems = new ObservableCollection(); private Point3D _multiLevelStartPoint; private Point3D _multiLevelEndPoint; private bool _hasMultiLevelStartPoint = false; @@ -115,6 +340,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels private string _multiLevelStartPointText = "未选择"; private string _multiLevelEndPointText = "未选择"; private string _multiLevelStatsText = "层级数: 0 | 预估路径点数: 0"; + private string _hoistingLayerEditHintText = "选择一条吊装路径后,可在这里按层编辑相对起点的绝对高度。"; private bool _isSelectingMultiLevelStartPoint = false; private bool _isSelectingMultiLevelEndPoint = false; private HoistingLevelItem _currentSelectingLevelTarget; @@ -138,27 +364,43 @@ namespace NavisworksTransport.UI.WPF.ViewModels { // 路径变更时清除选中的路径点 SelectedPathPoint = null; + ResetRailAssemblyWorkflowState(announce: false); // 🔧 修复:同步PathPlanningManager的CurrentRoute if (_pathPlanningManager != null && value != null) { // 查找对应的Core路径对象 - var coreRoute = _pathPlanningManager.Routes?.FirstOrDefault(r => r.Name == value.Name); + var coreRoute = _pathPlanningManager.Routes?.FirstOrDefault(r => r.Id == value.Id) + ?? _pathPlanningManager.Routes?.FirstOrDefault(r => r.Name == value.Name); if (coreRoute != null) { + if (coreRoute.PathType == PathType.Rail) + { + AssemblyMountMode = coreRoute.RailMountMode; + _assemblyAnchorVerticalOffsetInMeters = UnitsConverter.ConvertToMeters(coreRoute.RailNormalOffset); + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + } + + ApplyStoredAutoPlanningParameters(coreRoute); + // 🔥 先设置可视化模式(在 SetCurrentRoute 之前,确保事件触发时状态已就绪) UpdatePassageSpaceVisualizationForPathType(coreRoute.PathType); _pathPlanningManager.SetCurrentRoute(coreRoute); LogManager.Info($"UI路径切换:已同步PathPlanningManager的CurrentRoute到 {value.Name}"); + // 新建路径流程里禁止自动改视角,避免打断建路径操作。 + if (_pathPlanningManager.PathEditState == PathEditState.Creating) + { + LogManager.Info($"UI路径切换:当前处于新建路径流程,跳过自动视角调整: {value.Name}"); + } // 自动调整视角到路径中心(只有路径有点时才调整) - if (coreRoute.Points.Count > 0) + else if (coreRoute.Points.Count > 0) { try { - ViewpointHelper.AdjustViewpointToPathCenter(coreRoute); - LogManager.Info($"UI路径切换:已自动调整视角到路径中心: {value.Name}"); + ViewpointHelper.AdjustViewpointForPath(coreRoute); + LogManager.Info($"UI路径切换:已自动调整视角: {value.Name}, 类型={coreRoute.PathType}"); } catch (Exception ex) { @@ -192,6 +434,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 🔥 路径类型改变时,检查是否可以使用路径线 OnPropertyChanged(nameof(CanUsePathLines)); + OnPropertyChanged(nameof(IsRailRouteSelected)); + OnPropertyChanged(nameof(IsHoistingRouteSelected)); + OnPropertyChanged(nameof(SelectedRailMountMode)); + OnPropertyChanged(nameof(SelectedRailNormalOffsetInMeters)); + OnPropertyChanged(nameof(CanRepositionRailStartPoint)); + OnPropertyChanged(nameof(CanApplyHoistingLayerHeights)); + NotifyRailAssemblyCommandStateChanged(); if (!CanUsePathLines && ShowPathLines) { // 吊装和空轨路径不能使用路径线,自动关闭 @@ -199,6 +448,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels LogManager.Info("[路径可视化] 切换到吊装/空轨路径,自动关闭路径线"); } + RefreshHoistingLayerEditItemsFromSelectedRoute(); + // 实现路径选择时的可视化切换 UpdatePathVisualization(); } @@ -248,6 +499,302 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } } + + public bool IsRailRouteSelected => SelectedPathRoute?.PathType == PathType.Rail; + public bool IsHoistingRouteSelected => SelectedPathRoute?.PathType == PathType.Hoisting; + + public ObservableCollection> RailMountModeOptions + { + get => _railMountModeOptions; + } + + public RailMountMode SelectedRailMountMode + { + get => GetSelectedCoreRoute()?.RailMountMode ?? RailMountMode.UnderRail; + set + { + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Rail || coreRoute.RailMountMode == value) + { + return; + } + + coreRoute.RailMountMode = value; + AssemblyMountMode = value; + ApplyRailRouteConfigurationChange(coreRoute, $"安装方式已更新为 {value}"); + OnPropertyChanged(nameof(SelectedRailMountMode)); + } + } + + public double SelectedRailNormalOffsetInMeters + { + get + { + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Rail) + { + return 0.0; + } + + return UnitsConverter.ConvertToMeters(coreRoute.RailNormalOffset); + } + set + { + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Rail) + { + return; + } + + double offsetInModelUnits = UnitsConverter.ConvertFromMeters(value); + if (Math.Abs(coreRoute.RailNormalOffset - offsetInModelUnits) < 1e-6) + { + return; + } + + _assemblyAnchorVerticalOffsetInMeters = value; + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + coreRoute.RailNormalOffset = offsetInModelUnits; + if (IsAssemblyInstallationReferenceActiveForRoute(coreRoute)) + { + UpdateAssemblyInstallationAnchorFromVerticalOffset(); + PersistAssemblyInstallationReferenceToRailRoute(coreRoute, $"安装法向偏移已更新为 {value:F3} 米"); + } + else + { + ApplyRailRouteConfigurationChange(coreRoute, $"安装法向偏移已更新为 {value:F3} 米"); + } + OnPropertyChanged(nameof(SelectedRailNormalOffsetInMeters)); + } + } + + public string AssemblyTerminalObjectName + { + get => _assemblyTerminalObjectName; + set + { + if (string.Equals(_assemblyTerminalObjectName, value, StringComparison.Ordinal)) + { + return; + } + + _assemblyTerminalObjectName = value; + OnPropertyChanged(nameof(AssemblyTerminalObjectName)); + } + } + + public string AssemblyTerminalObjectInfo + { + get => _assemblyTerminalObjectInfo; + set + { + if (string.Equals(_assemblyTerminalObjectInfo, value, StringComparison.Ordinal)) + { + return; + } + + _assemblyTerminalObjectInfo = value; + OnPropertyChanged(nameof(AssemblyTerminalObjectInfo)); + } + } + + public double AssemblyReferenceRodLengthInMeters + { + get => _assemblyReferenceRodLengthInMeters; + set + { + if (Math.Abs(_assemblyReferenceRodLengthInMeters - value) < 1e-9) + { + return; + } + + _assemblyReferenceRodLengthInMeters = value; + OnPropertyChanged(nameof(AssemblyReferenceRodLengthInMeters)); + } + } + + public double AssemblyReferenceRodDiameterInMeters + { + get => _assemblyReferenceRodDiameterInMeters; + set + { + if (Math.Abs(_assemblyReferenceRodDiameterInMeters - value) < 1e-9) + { + return; + } + + _assemblyReferenceRodDiameterInMeters = value; + OnPropertyChanged(nameof(AssemblyReferenceRodDiameterInMeters)); + } + } + + public double AssemblyAnchorVerticalOffsetInMeters + { + get => _assemblyAnchorVerticalOffsetInMeters; + set + { + if (Math.Abs(_assemblyAnchorVerticalOffsetInMeters - value) < 1e-9) + { + return; + } + + _assemblyAnchorVerticalOffsetInMeters = value; + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + + if (HasAssemblyTerminalObject) + { + var coreRoute = GetSelectedCoreRoute(); + if (_hasAssemblyInstallationReference) + { + UpdateAssemblyInstallationAnchorFromVerticalOffset(); + if (IsAssemblyInstallationReferenceActiveForRoute(coreRoute)) + { + PersistAssemblyInstallationReferenceToRailRoute(coreRoute, $"安装法向偏移已更新为 {value:F3} 米"); + } + } + RefreshAssemblyTerminalObjectInfo(); + if (coreRoute != null && + coreRoute.PathType == PathType.Rail && + AssemblyReferencePathManager.Instance.HasReferenceLine) + { + RefreshSelectedRailReferenceLineVisuals(); + } + else + { + RefreshAssemblyReferenceRodIfNeeded(); + } + } + } + } + + public double AssemblySphereCenterX + { + get => _assemblySphereCenterX; + set + { + if (Math.Abs(_assemblySphereCenterX - value) < 1e-9) + { + return; + } + + _assemblySphereCenterX = value; + OnPropertyChanged(nameof(AssemblySphereCenterX)); + + if (HasAssemblyTerminalObject) + { + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + } + } + } + + public double AssemblySphereCenterY + { + get => _assemblySphereCenterY; + set + { + if (Math.Abs(_assemblySphereCenterY - value) < 1e-9) + { + return; + } + + _assemblySphereCenterY = value; + OnPropertyChanged(nameof(AssemblySphereCenterY)); + + if (HasAssemblyTerminalObject) + { + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + } + } + } + + public double AssemblySphereCenterZ + { + get => _assemblySphereCenterZ; + set + { + if (Math.Abs(_assemblySphereCenterZ - value) < 1e-9) + { + return; + } + + _assemblySphereCenterZ = value; + OnPropertyChanged(nameof(AssemblySphereCenterZ)); + + if (HasAssemblyTerminalObject) + { + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + } + } + } + + public bool HasAssemblyTerminalObject + { + get => _hasAssemblyTerminalObject; + set + { + if (_hasAssemblyTerminalObject == value) + { + return; + } + + _hasAssemblyTerminalObject = value; + OnPropertyChanged(nameof(HasAssemblyTerminalObject)); + } + } + + public string AssemblyStartPointText + { + get => _assemblyStartPointText; + set + { + if (string.Equals(_assemblyStartPointText, value, StringComparison.Ordinal)) + { + return; + } + + _assemblyStartPointText = value; + OnPropertyChanged(nameof(AssemblyStartPointText)); + } + } + + public RailMountMode AssemblyMountMode + { + get => _assemblyMountMode; + set + { + if (_assemblyMountMode == value) + { + return; + } + + _assemblyMountMode = value; + OnPropertyChanged(nameof(AssemblyMountMode)); + + if (HasAssemblyTerminalObject) + { + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + } + } + } + + public bool IsSelectingAssemblyStartPoint + { + get => _isSelectingAssemblyStartPoint; + set + { + if (_isSelectingAssemblyStartPoint == value) + { + return; + } + + _isSelectingAssemblyStartPoint = value; + OnPropertyChanged(nameof(IsSelectingAssemblyStartPoint)); + } + } /// /// 更新路径可视化显示:清理其他路径,只显示当前选中的路径 /// @@ -285,7 +832,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (_selectedPathRoute != null && _selectedPathRoute.Points.Count > 0) { // 查找对应的Core路径对象 - var coreRoute = _pathPlanningManager?.Routes?.FirstOrDefault(r => r.Name == _selectedPathRoute.Name); + var coreRoute = _pathPlanningManager?.Routes?.FirstOrDefault(r => r.Id == _selectedPathRoute.Id) + ?? _pathPlanningManager?.Routes?.FirstOrDefault(r => r.Name == _selectedPathRoute.Name); if (coreRoute != null) { // 使用PathPlanningManager的绘制方法来保持一致性 @@ -298,6 +846,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + RefreshSelectedRailInstallationReferenceVisuals(); + // 3. 恢复网格可视化(如果网格可视化已启用且当前路径有关联的GridMap) if (_pathPlanningManager?.IsAnyGridVisualizationEnabled == true) { @@ -365,6 +915,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels set => SetProperty(ref _multiLevelItems, value); } + public ObservableCollection HoistingLayerEditItems + { + get => _hoistingLayerEditItems; + set => SetProperty(ref _hoistingLayerEditItems, value); + } + + public string HoistingLayerEditHintText + { + get => _hoistingLayerEditHintText; + set => SetProperty(ref _hoistingLayerEditHintText, value); + } + public string MultiLevelStartPointText { get => _multiLevelStartPointText; @@ -384,6 +946,9 @@ namespace NavisworksTransport.UI.WPF.ViewModels } public bool CanCreateMultiLevelPath => _hasMultiLevelStartPoint && _hasMultiLevelEndPoint && _multiLevelItems.Count > 0; + public bool CanApplyHoistingLayerHeights => IsHoistingRouteSelected && + SelectedPathRoute != null && + HoistingLayerEditItems.Count > 0; #endregion @@ -467,6 +1032,77 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + private void ApplyStoredAutoPlanningParameters(PathRoute route) + { + if (route == null) + { + return; + } + + bool hasAnyStoredValue = + route.MaxObjectLength > 0 || + route.MaxObjectWidth > 0 || + route.MaxObjectHeight > 0 || + route.SafetyMargin > 0 || + route.GridSize > 0; + + if (!hasAnyStoredValue) + { + LogManager.Debug($"[自动路径参数同步] 路径 {route.Name} 没有保存自动规划参数,保持当前输入值"); + return; + } + + var appliedFields = new List(); + + if (route.MaxObjectLength > 0 && Math.Abs(ObjectLength - route.MaxObjectLength) > 1e-6) + { + ObjectLength = route.MaxObjectLength; + appliedFields.Add($"长度={route.MaxObjectLength:F3}m"); + } + + if (route.MaxObjectWidth > 0 && Math.Abs(ObjectWidth - route.MaxObjectWidth) > 1e-6) + { + ObjectWidth = route.MaxObjectWidth; + appliedFields.Add($"宽度={route.MaxObjectWidth:F3}m"); + } + + if (route.MaxObjectHeight > 0 && Math.Abs(ObjectHeight - route.MaxObjectHeight) > 1e-6) + { + ObjectHeight = route.MaxObjectHeight; + appliedFields.Add($"高度={route.MaxObjectHeight:F3}m"); + } + + if (route.SafetyMargin > 0 && Math.Abs(SafetyMargin - route.SafetyMargin) > 1e-6) + { + SafetyMargin = route.SafetyMargin; + appliedFields.Add($"安全间隙={route.SafetyMargin:F3}m"); + } + + if (route.GridSize > 0) + { + if (!IsGridSizeManuallyEnabled) + { + IsGridSizeManuallyEnabled = true; + appliedFields.Add("启用手动网格"); + } + + if (Math.Abs(GridSize - route.GridSize) > 1e-6) + { + GridSize = route.GridSize; + appliedFields.Add($"网格={route.GridSize:F3}m"); + } + } + + if (appliedFields.Count > 0) + { + LogManager.Info($"[自动路径参数同步] 已从路径 {route.Name} 同步: {string.Join(", ", appliedFields)}"); + } + else + { + LogManager.Debug($"[自动路径参数同步] 路径 {route.Name} 的自动规划参数与当前输入一致"); + } + } + #region 可视化参数属性 /// @@ -747,6 +1383,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels public ICommand NewRailPathCommand { get; private set; } public ICommand NewHoistingPathCommand { get; private set; } public ICommand NewMultiLevelHoistingPathCommand { get; private set; } + public ICommand DuplicatePathCommand { get; private set; } public ICommand DeletePathCommand { get; private set; } public ICommand RenamePathCommand { get; private set; } public ICommand StartEditCommand { get; private set; } @@ -763,6 +1400,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels public ICommand ImportPathCommand { get; private set; } public ICommand ExportPathCommand { get; private set; } public ICommand SaveAsPathCommand { get; private set; } + public ICommand CaptureAssemblyTerminalObjectForCreateCommand { get; private set; } + public ICommand ReCaptureAssemblyTerminalObjectForEditCommand { get; private set; } + public ICommand SelectAssemblyStartPointForCreateCommand { get; private set; } + public ICommand RepositionRailStartPointCommand { get; private set; } + public ICommand SelectAssemblyInstallationPointForCreateCommand { get; private set; } + public ICommand SelectAssemblyInstallationPointForEditCommand { get; private set; } + public ICommand ClearAssemblyReferenceRodCommand { get; private set; } + public ICommand CancelRailAssemblyWorkflowCommand { get; private set; } + public ICommand AnalyzeAssemblyTerminalFaceForCreateCommand { get; private set; } + public ICommand AnalyzeAssemblyTerminalFaceForEditCommand { get; private set; } + public ICommand DecreaseSelectedRailNormalOffsetCommand { get; private set; } + public ICommand IncreaseSelectedRailNormalOffsetCommand { get; private set; } // 多层吊装命令 public ICommand SelectMultiLevelStartPointCommand { get; private set; } @@ -772,6 +1421,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels public ICommand PickMultiLevelTargetCommand { get; private set; } public ICommand CreateMultiLevelPathCommand { get; private set; } public ICommand CancelMultiLevelHoistingCommand { get; private set; } + public ICommand ApplyHoistingLayerHeightsCommand { get; private set; } #endregion @@ -791,13 +1441,94 @@ namespace NavisworksTransport.UI.WPF.ViewModels public bool CanExecuteEndEdit => (_pathPlanningManager?.PathEditState == PathEditState.Creating || _pathPlanningManager?.PathEditState == PathEditState.Editing || _pathPlanningManager?.PathEditState == PathEditState.AddingPoints || - _pathPlanningManager?.PathEditState == PathEditState.EditingPoint); + _pathPlanningManager?.PathEditState == PathEditState.EditingPoint) || + CanCancelRailAssemblyWorkflow; public bool CanExecuteClearPath => SelectedPathRoute != null && SelectedPathRoute.Points.Count > 0; public bool CanExecuteExportPath => _pathPlanningManager?.Routes?.Count > 0 || SelectedPathRoute != null; public bool CanExecuteSaveAsPath => SelectedPathRoute != null && SelectedPathRoute.Points.Count > 0; + public bool CanSelectAssemblyStartPointForCreate => HasAssemblyTerminalObject && + _pathPlanningManager != null && + AssemblyReferencePathManager.Instance.HasReferenceLine && + !IsSelectingAssemblyStartPoint && + !_isSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + public bool CanRepositionRailStartPoint => HasAssemblyTerminalObject && + IsRailRouteSelected && + SelectedPathRoute != null && + _pathPlanningManager != null && + AssemblyReferencePathManager.Instance.HasReferenceLine && + !IsSelectingAssemblyStartPoint && + !_isSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + public bool CanSelectAssemblyInstallationPointForCreate => HasAssemblyTerminalObject && + _pathPlanningManager != null && + !_isSelectingAssemblyStartPoint && + !_isSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + public bool CanSelectAssemblyInstallationPointForEdit => HasAssemblyTerminalObject && + IsRailRouteSelected && + SelectedPathRoute != null && + _pathPlanningManager != null && + !_isSelectingAssemblyStartPoint && + !_isSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + public bool CanAnalyzeAssemblyTerminalFaceForCreate => HasAssemblyTerminalObject && + _pathPlanningManager != null && + !IsSelectingAssemblyStartPoint && + !IsSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + public bool CanAnalyzeAssemblyTerminalFaceForEdit => HasAssemblyTerminalObject && + IsRailRouteSelected && + SelectedPathRoute != null && + _pathPlanningManager != null && + !IsSelectingAssemblyStartPoint && + !IsSelectingAssemblyEndFacePoints && + !_isSelectingAssemblyInstallationPoint; + + public string CreateAssemblyInstallationPointButtonText => _hasAssemblyInstallationReference && !IsRailEditingWorkflowActive + ? "重选安装点" + : "选安装点"; + + public string EditAssemblyInstallationPointButtonText => HasSelectedRailInstallationReference() + ? "重选安装点" + : "选安装点"; + + public bool IsSelectingAssemblyEndFacePoints => _isSelectingAssemblyEndFacePoints; + public bool CanCancelRailAssemblyWorkflow => _railAssemblyWorkflowMode != RailAssemblyWorkflowMode.None || + HasAssemblyTerminalObject || + _isSelectingAssemblyStartPoint || + _isSelectingAssemblyEndFacePoints || + _isSelectingAssemblyInstallationPoint || + _hasAssemblyEndFaceAnalysis || + _hasAssemblyInstallationReference; + + private bool IsRailCreateWorkflowActive => _railAssemblyWorkflowMode == RailAssemblyWorkflowMode.CreateNewRailPath; + private bool IsRailEditingWorkflowActive => _railAssemblyWorkflowMode == RailAssemblyWorkflowMode.EditSelectedRail; + + private static bool IsRailAssemblyEditMode(RailAssemblyWorkflowMode workflowMode) + { + return workflowMode == RailAssemblyWorkflowMode.EditSelectedRail; + } + + private static string GetRailAssemblyWorkflowLabel(RailAssemblyWorkflowMode workflowMode) + { + return IsRailAssemblyEditMode(workflowMode) + ? "编辑现有Rail路径" + : "新建Rail路径"; + } + + private bool ShouldShowAssemblyReferenceLineVisuals() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return false; + } + + return IsRailEditingWorkflowActive || _hasAssemblyInstallationReference; + } public bool CanExecuteModifyPoint => SelectedPathPoint != null && _pathPlanningManager?.PathEditState != PathEditState.EditingPoint; @@ -835,6 +1566,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 初始化路径策略选项 InitializePathStrategyOptions(); + InitializeRailConfigOptions(); // 从配置加载参数 LoadParametersFromConfig(); @@ -897,6 +1629,303 @@ namespace NavisworksTransport.UI.WPF.ViewModels _selectedPathStrategy = _pathStrategyOptions.FirstOrDefault(x => x.Value == PathStrategy.Shortest); } + /// + /// 初始化 Rail 构型选项 + /// + private void InitializeRailConfigOptions() + { + _railMountModeOptions = new ObservableCollection> + { + new RailConfigOption + { + Value = RailMountMode.UnderRail, + DisplayName = "轨下安装", + Description = "安装头位于双轨下方,构件从轨道平面下侧连接" + }, + new RailConfigOption + { + Value = RailMountMode.OverRail, + DisplayName = "轨上安装", + Description = "安装头位于双轨上方,构件从轨道平面上侧连接" + } + }; + + } + + private PathRoute GetSelectedCoreRoute() + { + if (_pathPlanningManager == null || SelectedPathRoute == null) + { + return null; + } + + return _pathPlanningManager.Routes?.FirstOrDefault(route => route.Id == SelectedPathRoute.Id) + ?? _pathPlanningManager.Routes?.FirstOrDefault(route => route.Name == SelectedPathRoute.Name); + } + + private void ApplyRailRouteConfigurationChange(PathRoute coreRoute, string logMessage) + { + coreRoute.LastModified = DateTime.Now; + + SyncObjectParametersToRenderPlugin(); + _pathPlanningManager.DrawRouteVisualization(coreRoute, isAutoPath: false); + _pathPlanningManager.SavePathToDatabase(coreRoute); + _pathPlanningManager.RaiseVisualizationStateChanged(); + + LogManager.Info($"[Rail构型] {coreRoute.Name}: {logMessage}"); + } + + private void SetRailAssemblyWorkflowMode(RailAssemblyWorkflowMode mode) + { + if (_railAssemblyWorkflowMode == mode) + { + return; + } + + _railAssemblyWorkflowMode = mode; + NotifyRailAssemblyCommandStateChanged(); + } + + private void NotifyRailAssemblyCommandStateChanged() + { + OnPropertyChanged(nameof(CanAnalyzeAssemblyTerminalFaceForCreate)); + OnPropertyChanged(nameof(CanAnalyzeAssemblyTerminalFaceForEdit)); + OnPropertyChanged(nameof(CanSelectAssemblyStartPointForCreate)); + OnPropertyChanged(nameof(CanRepositionRailStartPoint)); + OnPropertyChanged(nameof(CanSelectAssemblyInstallationPointForCreate)); + OnPropertyChanged(nameof(CanSelectAssemblyInstallationPointForEdit)); + OnPropertyChanged(nameof(CreateAssemblyInstallationPointButtonText)); + OnPropertyChanged(nameof(EditAssemblyInstallationPointButtonText)); + OnPropertyChanged(nameof(CanCancelRailAssemblyWorkflow)); + OnPropertyChanged(nameof(CanExecuteEndEdit)); + } + + private bool HasSelectedRailInstallationReference() + { + var coreRoute = GetSelectedCoreRoute(); + return coreRoute != null && + coreRoute.PathType == PathType.Rail && + coreRoute.RailPreferredNormal != null; + } + + private int GetRoutePointIndexByType(PathRoute route, PathPointType pointType, int fallbackIndex) + { + if (route?.Points == null || route.Points.Count == 0) + { + return -1; + } + + for (int i = 0; i < route.Points.Count; i++) + { + if (route.Points[i].Type == pointType) + { + return i; + } + } + + return fallbackIndex >= 0 && fallbackIndex < route.Points.Count + ? fallbackIndex + : -1; + } + + private bool IsAssemblyInstallationReferenceActiveForRoute(PathRoute route) + { + return route != null && + route.PathType == PathType.Rail && + _hasAssemblyInstallationReference && + _assemblyInstallationAnchorPoint != null && + !string.IsNullOrWhiteSpace(_assemblyInstallationReferenceRouteId) && + string.Equals(_assemblyInstallationReferenceRouteId, route.Id, StringComparison.Ordinal); + } + + private int GetRailRouteInstallationPointIndex(PathRoute route) + { + return GetRoutePointIndexByType(route, PathPointType.EndPoint, route?.Points?.Count - 1 ?? -1); + } + + private int GetRailRouteStartPointIndex(PathRoute route) + { + return GetRoutePointIndexByType(route, PathPointType.StartPoint, 0); + } + + private bool TryBuildRailRouteReferenceLine(PathRoute route, out AssemblyReferenceLine referenceLine) + { + referenceLine = null; + if (route == null || route.PathType != PathType.Rail || route.Points == null || route.Points.Count < 2) + { + return false; + } + + int startIndex = GetRailRouteStartPointIndex(route); + int endIndex = GetRailRouteInstallationPointIndex(route); + if (startIndex < 0 || endIndex < 0 || startIndex == endIndex) + { + return false; + } + + Point3D startPoint = route.Points[startIndex].Position; + Point3D endPoint = route.Points[endIndex].Position; + Vector3D direction = new Vector3D( + startPoint.X - endPoint.X, + startPoint.Y - endPoint.Y, + startPoint.Z - endPoint.Z); + double lengthSquared = direction.X * direction.X + direction.Y * direction.Y + direction.Z * direction.Z; + if (lengthSquared < 1e-9) + { + return false; + } + + referenceLine = new AssemblyReferenceLine(startPoint, endPoint, direction.Normalize()); + return true; + } + + private void RefreshSelectedRailReferenceLineVisuals(string statusMessage = null) + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + ClearAssemblyReferenceLineVisuals(); + return; + } + + AssemblyReferenceLine referenceLine; + + if (IsRailEditingWorkflowActive) + { + // 编辑状态:使用默认杆长度构建参考线,方便选择更远的起点 + try + { + referenceLine = BuildAssemblyReferenceLine(); + } + catch + { + ClearAssemblyReferenceLineVisuals(); + return; + } + } + else + { + // 非编辑状态:使用路径实际长度 + var coreRoute = GetSelectedCoreRoute(); + if (!TryBuildRailRouteReferenceLine(coreRoute, out referenceLine)) + { + ClearAssemblyReferenceLineVisuals(); + return; + } + } + + AssemblyReferencePathManager.Instance.CreateOrUpdateReferenceRod( + referenceLine.StartPoint, + referenceLine.EndPoint, + AssemblyReferenceRodDiameterInMeters); + AssemblyStartPointText = $"({referenceLine.StartPoint.X:F2}, {referenceLine.StartPoint.Y:F2}, {referenceLine.StartPoint.Z:F2})"; + RefreshAssemblyTerminalObjectInfo(referenceLine.StartPoint, referenceLine.EndPoint); + RenderAssemblyReferenceLine(referenceLine); + NotifyRailAssemblyCommandStateChanged(); + + if (!string.IsNullOrWhiteSpace(statusMessage)) + { + UpdateMainStatus(statusMessage); + } + } + + private void PersistAssemblyInstallationReferenceToRailRoute(PathRoute route, string logMessage) + { + if (route == null) + { + throw new ArgumentNullException(nameof(route)); + } + + if (route.PathType != PathType.Rail) + { + throw new InvalidOperationException("只有 Rail 路径支持安装点参数更新。"); + } + + if (!_hasAssemblyInstallationReference || _assemblyInstallationAnchorPoint == null) + { + throw new InvalidOperationException("当前没有可应用的安装点参考。"); + } + + int installationPointIndex = GetRailRouteInstallationPointIndex(route); + if (installationPointIndex < 0) + { + throw new InvalidOperationException("当前 Rail 路径没有可更新的终点。"); + } + + // 获取当前起点索引 + int startPointIndex = GetRailRouteStartPointIndex(route); + if (startPointIndex < 0) + { + throw new InvalidOperationException("当前 Rail 路径没有可更新的起点。"); + } + + // 计算原路径长度(起点到终点的距离) + Point3D currentStartPoint = route.Points[startPointIndex].Position; + Point3D currentEndPoint = route.Points[installationPointIndex].Position; + double pathLength = GeometryHelper.CalculatePointDistance(currentStartPoint, currentEndPoint); + + // 更新终点(安装点) + if (!_pathPlanningManager.UpdatePathPointWithConstraints(route, installationPointIndex, _assemblyInstallationAnchorPoint)) + { + throw new InvalidOperationException("更新 Rail 路径安装点失败。"); + } + + // 根据新终点和原路径长度重新计算起点位置 + Point3D newStartPoint = CalculateRailStartPointFromEndPoint(_assemblyInstallationAnchorPoint, pathLength); + if (!_pathPlanningManager.UpdatePathPointWithConstraints(route, startPointIndex, newStartPoint)) + { + throw new InvalidOperationException("更新 Rail 路径起点失败。"); + } + + route.RailPreferredNormal = CreatePersistedAssemblyPreferredNormal(); + route.RailNormalOffset = UnitsConverter.ConvertFromMeters(AssemblyAnchorVerticalOffsetInMeters); + route.LastModified = DateTime.Now; + _assemblyInstallationReferenceRouteId = route.Id; + + _pathPlanningManager.SavePathToDatabase(route); + _pathPlanningManager.DrawRouteVisualization(route, isAutoPath: false); + _pathPlanningManager.RaiseVisualizationStateChanged(); + + if (SelectedPathRoute != null && SelectedPathRoute.Id == route.Id) + { + SyncPathViewModelFromCoreRoute(SelectedPathRoute, route, preserveSelection: true); + OnPropertyChanged(nameof(SelectedRailNormalOffsetInMeters)); + NotifyRailAssemblyCommandStateChanged(); + RefreshAssemblyTerminalObjectInfo(); + RefreshSelectedRailInstallationReferenceVisuals(); + RefreshSelectedRailReferenceLineVisuals(); + } + + LogManager.Info($"[Rail构型] {route.Name}: {logMessage},起点已同步更新为 ({newStartPoint.X:F3}, {newStartPoint.Y:F3}, {newStartPoint.Z:F3})"); + } + + private void RefreshSelectedRailInstallationReferenceVisuals() + { + var coreRoute = GetSelectedCoreRoute(); + if (!IsAssemblyInstallationReferenceActiveForRoute(coreRoute)) + { + ClearAssemblyInstallationReferenceVisuals(); + return; + } + + RenderAssemblyInstallationPickPoints(_assemblyInstallationSeedPoints); + RenderAssemblyInstallationAnchorPoint(_assemblyInstallationAnchorPoint); + RenderAssemblyInstallationCenterLine(_assemblyInstallationPickPoint, GetCurrentAssemblyOpticalAxisDirection()); + RenderAssemblyInstallationPlane(_assemblyInstallationAnchorPoint, GetCurrentAssemblyOpticalAxisDirection(), _assemblyInstallationPlaneSpanDirection); + } + + private void AdjustSelectedRailNormalOffset(double deltaInMeters) + { + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Rail) + { + return; + } + + double currentOffsetInMeters = UnitsConverter.ConvertToMeters(coreRoute.RailNormalOffset); + double nextOffsetInMeters = Math.Round(currentOffsetInMeters + deltaInMeters, 3); + SelectedRailNormalOffsetInMeters = nextOffsetInMeters; + } + /// /// 从配置文件加载参数 /// @@ -1015,6 +2044,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels PickMultiLevelTargetCommand = new RelayCommand((item) => ExecutePickMultiLevelTarget(item)); CreateMultiLevelPathCommand = new RelayCommand(async () => await ExecuteCreateMultiLevelPathAsync(), () => CanCreateMultiLevelPath); CancelMultiLevelHoistingCommand = new RelayCommand(() => ExecuteCancelMultiLevelHoisting()); + DuplicatePathCommand = new RelayCommand(async () => await ExecuteDuplicatePathAsync()); DeletePathCommand = new RelayCommand(async () => await ExecuteDeletePathAsync()); RenamePathCommand = new RelayCommand(async () => await ExecuteRenamePathAsync()); StartEditCommand = new RelayCommand(async () => await ExecuteAddPathPointAsync(), () => CanExecuteStartEdit); @@ -1031,6 +2061,19 @@ namespace NavisworksTransport.UI.WPF.ViewModels ImportPathCommand = new RelayCommand(async () => await ExecuteImportPathAsync()); ExportPathCommand = new RelayCommand(async () => await ExecuteExportPathAsync(), () => CanExecuteExportPath); SaveAsPathCommand = new RelayCommand(async () => await ExecuteSaveAsPathAsync(), () => CanExecuteSaveAsPath); + CaptureAssemblyTerminalObjectForCreateCommand = new RelayCommand(async () => await ExecuteCaptureAssemblyTerminalObjectAsync(RailAssemblyWorkflowMode.CreateNewRailPath)); + ReCaptureAssemblyTerminalObjectForEditCommand = new RelayCommand(async () => await ExecuteCaptureAssemblyTerminalObjectAsync(RailAssemblyWorkflowMode.EditSelectedRail)); + SelectAssemblyStartPointForCreateCommand = new RelayCommand(async () => await ExecuteSelectAssemblyStartPointAsync(), () => CanSelectAssemblyStartPointForCreate); + RepositionRailStartPointCommand = new RelayCommand(async () => await ExecuteRepositionRailStartPointAsync(), () => CanRepositionRailStartPoint); + SelectAssemblyInstallationPointForCreateCommand = new RelayCommand(async () => await ExecuteSelectAssemblyInstallationPointAsync(RailAssemblyWorkflowMode.CreateNewRailPath), () => CanSelectAssemblyInstallationPointForCreate); + SelectAssemblyInstallationPointForEditCommand = new RelayCommand(async () => await ExecuteSelectAssemblyInstallationPointAsync(RailAssemblyWorkflowMode.EditSelectedRail), () => CanSelectAssemblyInstallationPointForEdit); + ClearAssemblyReferenceRodCommand = new RelayCommand(() => ExecuteClearAssemblyReferenceRod()); + CancelRailAssemblyWorkflowCommand = new RelayCommand(() => ExecuteCancelRailAssemblyWorkflow(), () => CanCancelRailAssemblyWorkflow); + AnalyzeAssemblyTerminalFaceForCreateCommand = new RelayCommand(async () => await ExecuteAnalyzeAssemblyTerminalFaceAsync(RailAssemblyWorkflowMode.CreateNewRailPath), () => CanAnalyzeAssemblyTerminalFaceForCreate); + AnalyzeAssemblyTerminalFaceForEditCommand = new RelayCommand(async () => await ExecuteAnalyzeAssemblyTerminalFaceAsync(RailAssemblyWorkflowMode.EditSelectedRail), () => CanAnalyzeAssemblyTerminalFaceForEdit); + DecreaseSelectedRailNormalOffsetCommand = new RelayCommand(() => AdjustSelectedRailNormalOffset(-RailNormalOffsetNudgeStepInMeters)); + IncreaseSelectedRailNormalOffsetCommand = new RelayCommand(() => AdjustSelectedRailNormalOffset(RailNormalOffsetNudgeStepInMeters)); + ApplyHoistingLayerHeightsCommand = new RelayCommand(async () => await ExecuteApplyHoistingLayerHeightsAsync(), () => CanApplyHoistingLayerHeights); } #endregion @@ -1039,6 +2082,1531 @@ namespace NavisworksTransport.UI.WPF.ViewModels #region 路径管理命令 + private async Task ExecuteCaptureAssemblyTerminalObjectAsync(RailAssemblyWorkflowMode mode) + { + await SafeExecuteAsync(() => + { + ClearNonGridPathVisualizations("[直线装配] 捕获箱体"); + + var document = NavisApplication.ActiveDocument; + var selectedItem = document?.CurrentSelection?.SelectedItems?.FirstOrDefault(); + if (selectedItem == null) + { + throw new InvalidOperationException("请先在 Navisworks 中选择终点处已安装的箱体"); + } + + if (!ModelItemAnalysisHelper.IsModelItemValid(selectedItem)) + { + throw new InvalidOperationException("当前选择对象无效,请重新选择终点箱体"); + } + + SetRailAssemblyWorkflowMode(mode); + + _assemblyTerminalObject = selectedItem; + _assemblyStartPoint = Point3D.Origin; + HasAssemblyTerminalObject = true; + AssemblyStartPointText = "未选择"; + AssemblyTerminalObjectName = ModelItemAnalysisHelper.GetSafeDisplayName(selectedItem); + bool isEditMode = IsRailAssemblyEditMode(mode); + + ResetAssemblyEndFaceAnalysisState(); + if (!isEditMode) + { + ResetAssemblyInstallationReferenceState(); + } + RefreshAssemblyTerminalObjectInfo(); + ClearAssemblyAnchorMarker(); + ClearAssemblyEndFaceAnalysisVisuals(); + if (!isEditMode) + { + ClearAssemblyInstallationReferenceVisuals(); + } + NotifyRailAssemblyCommandStateChanged(); + + if (isEditMode) + { + // 编辑态重选箱体后,只恢复可由当前 Rail 路径和终点箱体稳定重建的可视化。 + // 安装参考面依赖安装拾取点、安装面 span 等会话态数据,这些数据当前未持久化到 PathRoute, + // 因此这里不尝试恢复安装参考面,避免后续误以为“少了一次刷新调用”是 bug。 + RefreshSelectedPathVisualizationForAssemblyEdit(); + RefreshAssemblyReferenceRodIfNeeded(); + } + else + { + // 新建路径时隐藏其他路径的辅助线 + ClearAssemblyReferenceLineVisuals(); + } + + UpdateMainStatus($"已捕获终点箱体: {AssemblyTerminalObjectName}"); + LogManager.Info($"[直线装配] 已捕获终点箱体: {AssemblyTerminalObjectName}"); + }, "捕获终点箱体"); + } + + private void RefreshSelectedPathVisualizationForAssemblyEdit() + { + if (_selectedPathRoute == null || _selectedPathRoute.Points.Count == 0 || _pathPlanningManager == null) + { + return; + } + + var coreRoute = _pathPlanningManager.Routes?.FirstOrDefault(route => route.Id == _selectedPathRoute.Id) + ?? _pathPlanningManager.Routes?.FirstOrDefault(route => route.Name == _selectedPathRoute.Name); + if (coreRoute == null) + { + return; + } + + _pathPlanningManager.DrawRouteVisualization(coreRoute, isAutoPath: false); + } + + private void ClearNonGridPathVisualizations(string context) + { + if (PathPointRenderPlugin.Instance == null) + { + return; + } + + try + { + PathPointRenderPlugin.Instance.ClearPathsExcept( + "grid_visualization_all", + "grid_visualization_channel", + "grid_visualization_unknown", + "grid_visualization_obstacle", + "grid_visualization_door"); + LogManager.Info($"{context}:已清除现有路径可视化显示(保留网格可视化)"); + } + catch (Exception ex) + { + LogManager.Error($"{context}:清除现有路径可视化失败: {ex.Message}", ex); + throw; + } + } + + private async Task ExecuteSelectAssemblyStartPointAsync() + { + await SafeExecuteAsync(() => + { + if (_pathPlanningManager == null) + { + throw new InvalidOperationException("路径规划管理器未初始化,无法选择装配起点"); + } + + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,请重新捕获终点箱体"); + } + + if (!AssemblyReferencePathManager.Instance.HasReferenceLine) + { + throw new InvalidOperationException("请先选择安装点,系统生成终端安装辅助线后才能取起点"); + } + + BeginAssemblyStartPointSelection( + statusMessage: "请在辅助线附近点击一个起点,系统会自动吸附到辅助线上并生成直线路径", + logMessage: "[直线装配] 已进入起点拾取模式", + toolPluginFailureMessage: "ToolPlugin 初始化失败,请重试"); + }, "选择终端安装起点"); + } + + private async Task ExecuteRepositionRailStartPointAsync() + { + await SafeExecuteAsync(() => + { + if (_pathPlanningManager == null) + { + throw new InvalidOperationException("路径规划管理器未初始化,无法重选起点。"); + } + + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,请先捕获终点箱体。"); + } + + var selectedRailRoute = GetSelectedCoreRoute(); + if (selectedRailRoute == null || selectedRailRoute.PathType != PathType.Rail) + { + throw new InvalidOperationException("请先选中一条需要编辑起点的 Rail 路径。"); + } + + SetRailAssemblyWorkflowMode(RailAssemblyWorkflowMode.EditSelectedRail); + RefreshSelectedRailReferenceLineVisuals(); + if (!AssemblyReferencePathManager.Instance.HasReferenceLine) + { + throw new InvalidOperationException("当前 Rail 路径无法生成辅助线,请先检查路径起点和终点。"); + } + + BeginAssemblyStartPointSelection( + statusMessage: "请在辅助线附近点击新的起点位置,系统会自动吸附到辅助线上并更新当前 Rail 路径。", + logMessage: "[Rail构型] 已进入起点重选模式", + toolPluginFailureMessage: "ToolPlugin 初始化失败,请重试。"); + }, "重选 Rail 路径起点"); + } + + private void ExecuteClearAssemblyReferenceRod() + { + try + { + ClearAssemblyReferenceLineVisuals(); + ClearAssemblyEndFaceAnalysisVisuals(); + ClearAssemblyInstallationReferenceVisuals(); + UpdateMainStatus("已隐藏终端安装辅助线"); + LogManager.Info("[直线装配] 已隐藏辅助线"); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 隐藏辅助线失败: {ex.Message}"); + } + } + + private async Task ExecuteAnalyzeAssemblyTerminalFaceAsync(RailAssemblyWorkflowMode workflowMode) + { + await SafeExecuteAsync(() => + { + if (_pathPlanningManager == null) + { + throw new InvalidOperationException("路径规划管理器未初始化,无法分析端面。"); + } + + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,请先捕获终点箱体。"); + } + + PrepareRailAssemblyWorkflowForMode( + workflowMode, + editActionDescription: "请先选中一条需要编辑安装点的 Rail 路径。", + syncMountModeFromRoute: true); + + BeginAssemblyEndFaceSelection(workflowMode); + }, "分析终端端面"); + } + + private async Task ExecuteSelectAssemblyInstallationPointAsync(RailAssemblyWorkflowMode workflowMode) + { + await SafeExecuteAsync(() => + { + if (_pathPlanningManager == null) + { + throw new InvalidOperationException("路径规划管理器未初始化,无法选择安装点。"); + } + + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,请先捕获终点箱体。"); + } + + PrepareRailAssemblyWorkflowForMode( + workflowMode, + editActionDescription: "请先选中一条需要编辑安装点的 Rail 路径。", + syncMountModeFromRoute: false); + + BeginAssemblyInstallationSelection(); + }, "选择安装点"); + } + + private async void OnAssemblyReferenceMouseClicked(object sender, PickItemResult pickResult) + { + try + { + if (!IsSelectingAssemblyStartPoint || pickResult == null) + { + return; + } + + Point3D projectedStartPoint = AssemblyReferencePathManager.Instance.ProjectPointToReferenceLine(pickResult.Point); + _assemblyStartPoint = projectedStartPoint; + AssemblyStartPointText = $"({projectedStartPoint.X:F2}, {projectedStartPoint.Y:F2}, {projectedStartPoint.Z:F2})"; + LogManager.Info( + $"[直线装配] 点击点=({pickResult.Point.X:F2}, {pickResult.Point.Y:F2}, {pickResult.Point.Z:F2})," + + $"投影起点=({projectedStartPoint.X:F2}, {projectedStartPoint.Y:F2}, {projectedStartPoint.Z:F2})"); + + await SafeExecuteAsync(() => + { + ApplyAssemblyStartPointForCurrentWorkflow(projectedStartPoint); + + CleanupAssemblyReferenceSelection(); + }, "生成终端安装路径"); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 处理起点拾取失败: {ex.Message}", ex); + UpdateMainStatus($"终端安装起点拾取失败: {ex.Message}"); + CleanupAssemblyReferenceSelection(); + } + } + + private async void OnAssemblyEndFaceMouseClicked(object sender, PickItemResult pickResult) + { + try + { + if (!_isSelectingAssemblyEndFacePoints || pickResult == null) + { + return; + } + + await SafeExecuteAsync(() => + { + if (!IsPickOnAssemblyTerminalObject(pickResult)) + { + UpdateMainStatus("请点击当前终点箱体的同一个端面,不要点到其他对象。"); + return; + } + + _assemblyEndFaceSeedPoints.Add(pickResult.Point); + RenderAssemblyEndFaceSeedPoints(); + + int pickedCount = _assemblyEndFaceSeedPoints.Count; + LogManager.Info($"[直线装配] 已记录端面种子点 {pickedCount}: ({pickResult.Point.X:F3}, {pickResult.Point.Y:F3}, {pickResult.Point.Z:F3})"); + + if (pickedCount < 3) + { + UpdateMainStatus($"已记录端面点 {pickedCount}/3,请继续在同一端面平面上点击。"); + return; + } + + AnalyzeCurrentAssemblyEndFace(); + CleanupAssemblyEndFaceSelection(clearVisuals: false); + }, "处理端面三点拾取"); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 端面三点分析失败: {ex.Message}", ex); + UpdateMainStatus($"端面三点分析失败: {ex.Message}"); + CleanupAssemblyEndFaceSelection(clearVisuals: false); + } + } + + private async void OnAssemblyInstallationMouseClicked(object sender, PickItemResult pickResult) + { + try + { + if (!_isSelectingAssemblyInstallationPoint || pickResult == null) + { + return; + } + + await SafeExecuteAsync(() => + { + if (!IsPickOnAssemblyTerminalObject(pickResult)) + { + UpdateMainStatus("请点击当前终点箱体表面,不要点到其他对象。"); + return; + } + + _assemblyInstallationSeedPoints.Add(pickResult.Point); + RenderAssemblyInstallationPickPoints(_assemblyInstallationSeedPoints); + + int pickedCount = _assemblyInstallationSeedPoints.Count; + LogManager.Info($"[直线装配] 已记录安装面点 {pickedCount}: ({pickResult.Point.X:F3}, {pickResult.Point.Y:F3}, {pickResult.Point.Z:F3})"); + + if (pickedCount < 2) + { + UpdateMainStatus("已记录安装面点 1/2,请继续在同一安装面上点击第二个点。"); + return; + } + + BuildAndRenderAssemblyInstallationReference( + _assemblyInstallationSeedPoints[0], + _assemblyInstallationSeedPoints[1]); + + ApplyAssemblyInstallationReferenceForCurrentWorkflow(); + CleanupAssemblyInstallationSelection(clearVisuals: false); + }, "处理安装点拾取"); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 安装点计算失败: {ex.Message}", ex); + UpdateMainStatus($"安装点计算失败: {ex.Message}"); + CleanupAssemblyInstallationSelection(clearVisuals: true); + } + } + + private void PrepareRailAssemblyWorkflowForMode( + RailAssemblyWorkflowMode workflowMode, + string editActionDescription, + bool syncMountModeFromRoute) + { + PathRoute selectedRailRoute = null; + if (IsRailAssemblyEditMode(workflowMode)) + { + selectedRailRoute = RequireSelectedRailRouteForAssemblyEdit(editActionDescription); + if (syncMountModeFromRoute) + { + AssemblyMountMode = selectedRailRoute.RailMountMode; + } + } + + SetRailAssemblyWorkflowMode(workflowMode); + } + + private PathRoute RequireSelectedRailRouteForAssemblyEdit(string errorMessage) + { + var selectedRailRoute = GetSelectedCoreRoute(); + if (selectedRailRoute == null || selectedRailRoute.PathType != PathType.Rail) + { + throw new InvalidOperationException(errorMessage); + } + + return selectedRailRoute; + } + + private void BeginAssemblyStartPointSelection(string statusMessage, string logMessage, string toolPluginFailureMessage) + { + CleanupAssemblyReferenceSelection(); + + _pathPlanningManager.DisableMouseHandling(); + IsSelectingAssemblyStartPoint = true; + NotifyRailAssemblyCommandStateChanged(); + + PathClickToolPlugin.MouseClicked -= OnAssemblyReferenceMouseClicked; + PathClickToolPlugin.MouseClicked += OnAssemblyReferenceMouseClicked; + + if (!ForceReinitializeToolPlugin(subscribeToEvents: false)) + { + CleanupAssemblyReferenceSelection(); + throw new InvalidOperationException(toolPluginFailureMessage); + } + + _pathPlanningManager.StartClickTool(PathPointType.StartPoint); + UpdateMainStatus(statusMessage); + LogManager.Info(logMessage); + } + + private void BeginAssemblyEndFaceSelection(RailAssemblyWorkflowMode workflowMode) + { + CleanupAssemblyReferenceSelection(); + CleanupAssemblyEndFaceSelection(clearVisuals: false); + CleanupAssemblyInstallationSelection(); + ClearAssemblyEndFaceAnalysisVisuals(); + ResetAssemblyEndFaceAnalysisState(); + ClearAssemblyInstallationReferenceVisuals(); + ResetAssemblyInstallationReferenceState(); + _assemblyEndFaceSeedPoints.Clear(); + + _pathPlanningManager.DisableMouseHandling(); + _isSelectingAssemblyEndFacePoints = true; + NotifyRailAssemblyCommandStateChanged(); + + PathClickToolPlugin.MouseClicked -= OnAssemblyEndFaceMouseClicked; + PathClickToolPlugin.MouseClicked += OnAssemblyEndFaceMouseClicked; + + if (!ForceReinitializeToolPlugin(subscribeToEvents: false)) + { + CleanupAssemblyEndFaceSelection(clearVisuals: false); + throw new InvalidOperationException("ToolPlugin 初始化失败,请重试。"); + } + + UpdateMainStatus("请在同一个端面平面上连续点击 3 个点,系统将分析端面中心。"); + LogManager.Info($"[直线装配] 已进入端面三点分析模式,模式={GetRailAssemblyWorkflowLabel(workflowMode)}"); + } + + private void BeginAssemblyInstallationSelection() + { + CleanupAssemblyReferenceSelection(); + CleanupAssemblyEndFaceSelection(clearVisuals: false); + CleanupAssemblyInstallationSelection(); + ClearAssemblyInstallationReferenceVisuals(); + + _pathPlanningManager.DisableMouseHandling(); + _isSelectingAssemblyInstallationPoint = true; + NotifyRailAssemblyCommandStateChanged(); + + PathClickToolPlugin.MouseClicked -= OnAssemblyInstallationMouseClicked; + PathClickToolPlugin.MouseClicked += OnAssemblyInstallationMouseClicked; + + if (!ForceReinitializeToolPlugin(subscribeToEvents: false)) + { + CleanupAssemblyInstallationSelection(); + throw new InvalidOperationException("ToolPlugin 初始化失败,请重试。"); + } + + _assemblyInstallationSeedPoints.Clear(); + UpdateMainStatus("请在终点箱体表面连续点击两个安装面点,系统将按两点和光轴计算安装参考面与安装点。"); + LogManager.Info($"[直线装配] 已进入安装点拾取模式,模式={GetRailAssemblyWorkflowLabel(_railAssemblyWorkflowMode)}"); + } + + private void ApplyAssemblyStartPointForCurrentWorkflow(Point3D projectedStartPoint) + { + if (IsRailEditingWorkflowActive) + { + UpdateSelectedRailRouteStartPoint(projectedStartPoint); + ClearAssemblyReferenceLineVisuals(); + UpdateMainStatus("已根据辅助线更新当前 Rail 路径起点"); + return; + } + + CreateAssemblyLinearRoute(projectedStartPoint); + UpdateMainStatus("已根据辅助线起点生成终端安装路径"); + } + + private void ApplyAssemblyInstallationReferenceForCurrentWorkflow() + { + if (IsRailEditingWorkflowActive) + { + var selectedRailRoute = RequireSelectedRailRouteForAssemblyEdit("当前没有可更新安装点的 Rail 路径。"); + PersistAssemblyInstallationReferenceToRailRoute( + selectedRailRoute, + "已重选安装点并同步更新 Rail 路径安装参数"); + return; + } + + LogManager.Info("[直线装配] 安装点已确定,请继续选择起点以生成新路径"); + } + + private void CreateAssemblyLinearRoute(Point3D startPoint) + { + Point3D endPoint = AssemblyReferencePathManager.Instance.ReferenceLineEnd; + string routeName = $"人工_{DateTime.Now:MMdd_HHmmss}"; + + var route = new PathRoute(routeName) + { + Description = $"直线装配路径 - {AssemblyTerminalObjectName}", + PathType = PathType.Rail, + RailMountMode = AssemblyMountMode, + RailPathDefinitionMode = RailPathDefinitionMode.RailCenterLine, + RailNormalOffset = UnitsConverter.ConvertFromMeters(AssemblyAnchorVerticalOffsetInMeters) + }; + + if (_hasAssemblyInstallationReference) + { + route.RailPreferredNormal = CreatePersistedAssemblyPreferredNormal(); + } + + route.AddPoint(new PathPoint(startPoint, "起点", PathPointType.StartPoint)); + route.AddPoint(new PathPoint(endPoint, "装配终点", PathPointType.EndPoint)); + + if (!_pathPlanningManager.AddRoute(route)) + { + throw new InvalidOperationException("装配路径添加失败"); + } + + _pathPlanningManager.SetCurrentRoute(route); + if (!_pathPlanningManager.GeneratePath(route)) + { + throw new InvalidOperationException("装配路径生成失败"); + } + + if (!_pathPlanningManager.FinishEditing()) + { + throw new InvalidOperationException("装配路径完成编辑失败"); + } + + var generatedRouteViewModel = PathRoutes.FirstOrDefault(p => p.Name == route.Name); + if (generatedRouteViewModel != null) + { + SelectedPathRoute = generatedRouteViewModel; + UpdatePassageSpaceVisualizationForPathType(route.PathType); + } + + HideAssemblyReferenceVisuals( + resetStartPointText: false, + statusMessage: null, + logMessage: "[直线装配] 路径生成完成后已隐藏辅助线可视化"); + + AssemblyTerminalObjectInfo = string.Format( + "对接终点=({0:F2}, {1:F2}, {2:F2}),装配起点={3},安装方式={4},对接基准={5}", + endPoint.X, + endPoint.Y, + endPoint.Z, + AssemblyStartPointText, + AssemblyMountMode == RailMountMode.OverRail ? "轨上安装" : "轨下安装", + GetAssemblyAnchorText()); + + OnPropertyChanged(nameof(PathRoutes)); + LogManager.Info( + $"[直线装配] 已生成并完成路径: {route.Name},起点=({startPoint.X:F2}, {startPoint.Y:F2}, {startPoint.Z:F2}),终点=({endPoint.X:F2}, {endPoint.Y:F2}, {endPoint.Z:F2})" + + $"{(route.RailPreferredNormal != null ? $",安装法向=({route.RailPreferredNormal.X:F3}, {route.RailPreferredNormal.Y:F3}, {route.RailPreferredNormal.Z:F3})" : string.Empty)}"); + } + + private void UpdateSelectedRailRouteStartPoint(Point3D startPoint) + { + var route = GetSelectedCoreRoute(); + if (route == null || route.PathType != PathType.Rail) + { + throw new InvalidOperationException("当前没有可更新起点的 Rail 路径。"); + } + + int startPointIndex = GetRailRouteStartPointIndex(route); + if (startPointIndex < 0) + { + throw new InvalidOperationException("当前 Rail 路径没有可更新的起点。"); + } + + if (!_pathPlanningManager.UpdatePathPointWithConstraints(route, startPointIndex, startPoint)) + { + throw new InvalidOperationException("更新 Rail 路径起点失败。"); + } + + route.LastModified = DateTime.Now; + _pathPlanningManager.SavePathToDatabase(route); + _pathPlanningManager.DrawRouteVisualization(route, isAutoPath: false); + _pathPlanningManager.RaiseVisualizationStateChanged(); + + if (SelectedPathRoute != null && SelectedPathRoute.Id == route.Id) + { + SyncPathViewModelFromCoreRoute(SelectedPathRoute, route, preserveSelection: true); + RefreshSelectedRailReferenceLineVisuals(); + } + + LogManager.Info($"[Rail构型] {route.Name}: 已重选起点为 ({startPoint.X:F3}, {startPoint.Y:F3}, {startPoint.Z:F3})"); + } + + private void HideAssemblyReferenceVisuals(bool resetStartPointText, string statusMessage, string logMessage) + { + CleanupAssemblyReferenceSelection(); + CleanupAssemblyEndFaceSelection(clearVisuals: false); + ClearAssemblyReferenceLineVisuals(); + ClearAssemblyEndFaceAnalysisVisuals(); + ClearAssemblyInstallationReferenceVisuals(); + if (resetStartPointText) + { + AssemblyStartPointText = "未选择"; + } + NotifyRailAssemblyCommandStateChanged(); + if (!string.IsNullOrWhiteSpace(statusMessage)) + { + UpdateMainStatus(statusMessage); + } + + if (!string.IsNullOrWhiteSpace(logMessage)) + { + LogManager.Info(logMessage); + } + } + + private void ResetRailAssemblyWorkflowState(bool announce, string statusMessage = null, string logMessage = null) + { + CleanupAssemblyReferenceSelection(); + CleanupAssemblyEndFaceSelection(clearVisuals: true); + CleanupAssemblyInstallationSelection(clearVisuals: true); + ClearAssemblyReferenceLineVisuals(); + ClearAssemblyEndFaceAnalysisVisuals(); + ClearAssemblyInstallationReferenceVisuals(); + ResetAssemblyEndFaceAnalysisState(); + ResetAssemblyInstallationReferenceState(); + _railAssemblyContext.ResetSession(); + HasAssemblyTerminalObject = false; + AssemblyTerminalObjectName = _assemblyTerminalObjectName; + AssemblyTerminalObjectInfo = _assemblyTerminalObjectInfo; + AssemblyStartPointText = _assemblyStartPointText; + + SetRailAssemblyWorkflowMode(RailAssemblyWorkflowMode.None); + NotifyRailAssemblyCommandStateChanged(); + + if (announce && !string.IsNullOrWhiteSpace(statusMessage)) + { + UpdateMainStatus(statusMessage); + } + + if (!string.IsNullOrWhiteSpace(logMessage)) + { + LogManager.Info(logMessage); + } + } + + private void ExecuteCancelRailAssemblyWorkflow() + { + try + { + ResetRailAssemblyWorkflowState( + announce: true, + statusMessage: "已取消 Rail 装配流程", + logMessage: "[直线装配] 已取消 Rail 装配流程并清理状态"); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 取消 Rail 装配流程失败: {ex.Message}", ex); + UpdateMainStatus($"取消 Rail 装配流程失败: {ex.Message}"); + } + } + + private Point3D GetAssemblyTerminalAnchorPoint() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,无法计算对接点"); + } + + if (_hasAssemblyInstallationReference && _assemblyInstallationAnchorPoint != null) + { + return _assemblyInstallationAnchorPoint; + } + + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ProjectReferenceFrame projectFrame = CreateAssemblyProjectReferenceFrame(adapter); + + var bounds = _assemblyTerminalObject.BoundingBox(); + Point3D canonicalCenter = adapter.ToCanonicalPoint(bounds.Center); + Vector3D canonicalUp = adapter.ToCanonicalVector( + ModelItemTransformHelper.GetDirectionFromTransform( + _assemblyTerminalObject.Transform, + projectFrame.DefaultModelAxisConvention.UpAxis)); + double direction = GetAssemblyAnchorDirection(); + double verticalOffset = UnitsConverter.ConvertFromMeters(AssemblyAnchorVerticalOffsetInMeters); + + Point3D canonicalAnchorPoint = new Point3D( + canonicalCenter.X + canonicalUp.X * verticalOffset * direction, + canonicalCenter.Y + canonicalUp.Y * verticalOffset * direction, + canonicalCenter.Z + canonicalUp.Z * verticalOffset * direction); + + return adapter.FromCanonicalPoint(canonicalAnchorPoint); + } + + /// + /// 根据 Rail 路径终点和路径长度计算起点位置。 + /// 起点位于从终点沿光轴方向(远离球心方向)延伸路径长度的位置。 + /// + private Point3D CalculateRailStartPointFromEndPoint(Point3D endPoint, double pathLength) + { + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ProjectReferenceFrame projectFrame = CreateAssemblyProjectReferenceFrame(adapter); + + Point3D opticalAxisReferencePoint = GetAssemblyOpticalAxisReferencePoint(); + Point3D canonicalEndPoint = adapter.ToCanonicalPoint(endPoint); + Point3D canonicalOpticalAxisRef = adapter.ToCanonicalPoint(opticalAxisReferencePoint); + + // 计算从球心到光轴参考点的方向(作为参考线方向) + Vector3D direction = new Vector3D( + canonicalOpticalAxisRef.X - projectFrame.SphereCenterInCanonical.X, + canonicalOpticalAxisRef.Y - projectFrame.SphereCenterInCanonical.Y, + canonicalOpticalAxisRef.Z - projectFrame.SphereCenterInCanonical.Z); + + direction = direction.Normalize(); + + // 起点 = 终点 + 方向 * 路径长度 + Point3D canonicalStartPoint = new Point3D( + canonicalEndPoint.X + direction.X * pathLength, + canonicalEndPoint.Y + direction.Y * pathLength, + canonicalEndPoint.Z + direction.Z * pathLength); + + return adapter.FromCanonicalPoint(canonicalStartPoint); + } + + private void UpdateAssemblyInstallationAnchorFromVerticalOffset() + { + if (!_hasAssemblyInstallationReference || + _assemblyInstallationBaseAnchorPoint == null) + { + return; + } + + double offset = UnitsConverter.ConvertFromMeters(_assemblyAnchorVerticalOffsetInMeters); + _assemblyInstallationAnchorPoint = new Point3D( + _assemblyInstallationBaseAnchorPoint.X + _assemblyInstallationPlaneNormal.X * (float)offset, + _assemblyInstallationBaseAnchorPoint.Y + _assemblyInstallationPlaneNormal.Y * (float)offset, + _assemblyInstallationBaseAnchorPoint.Z + _assemblyInstallationPlaneNormal.Z * (float)offset); + + if (_hasAssemblyInstallationReference) + { + RenderAssemblyInstallationPickPoints(_assemblyInstallationSeedPoints); + RenderAssemblyInstallationAnchorPoint(_assemblyInstallationAnchorPoint); + RenderAssemblyInstallationCenterLine(_assemblyInstallationPickPoint, GetCurrentAssemblyOpticalAxisDirection()); + RenderAssemblyInstallationPlane(_assemblyInstallationAnchorPoint, GetCurrentAssemblyOpticalAxisDirection(), _assemblyInstallationPlaneSpanDirection); + } + else + { + ClearAssemblyInstallationReferenceVisuals(); + } + } + + private Point3D CreatePersistedAssemblyPreferredNormal() + { + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 normalizedPreferredNormal = RailPathPoseHelper.NormalizePreferredNormalToHostUpHemisphere( + _assemblyInstallationPlaneNormal, + adapter.HostUpVector3); + + return new Point3D( + normalizedPreferredNormal.X, + normalizedPreferredNormal.Y, + normalizedPreferredNormal.Z); + } + + private Vector3 GetCurrentAssemblyOpticalAxisDirection() + { + Point3D opticalAxisReferencePoint = GetAssemblyOpticalAxisReferencePoint(); + Point3D sphereCenterPoint = new Point3D( + AssemblySphereCenterX, + AssemblySphereCenterY, + AssemblySphereCenterZ); + + Vector3 opticalAxisDirection = new Vector3( + (float)(opticalAxisReferencePoint.X - sphereCenterPoint.X), + (float)(opticalAxisReferencePoint.Y - sphereCenterPoint.Y), + (float)(opticalAxisReferencePoint.Z - sphereCenterPoint.Z)); + return Vector3.Normalize(opticalAxisDirection); + } + + private Point3D GetAssemblyOpticalAxisReferencePoint() + { + if (_hasAssemblyEndFaceAnalysis && _assemblyEndFaceCenterPoint != null) + { + return _assemblyEndFaceCenterPoint; + } + + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,无法计算光轴参考点"); + } + + return _assemblyTerminalObject.BoundingBox().Center; + } + + private AssemblyReferenceLine BuildAssemblyReferenceLine() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + throw new InvalidOperationException("终点箱体未设置或已失效,无法生成装配参考线"); + } + + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ProjectReferenceFrame projectFrame = CreateAssemblyProjectReferenceFrame(adapter); + + Point3D opticalAxisReferencePoint = GetAssemblyOpticalAxisReferencePoint(); + Point3D endPoint = GetAssemblyTerminalAnchorPoint(); + + Point3D canonicalAxisReferencePoint = adapter.ToCanonicalPoint(opticalAxisReferencePoint); + Point3D canonicalEndPoint = adapter.ToCanonicalPoint(endPoint); + Vector3D direction = new Vector3D( + canonicalAxisReferencePoint.X - projectFrame.SphereCenterInCanonical.X, + canonicalAxisReferencePoint.Y - projectFrame.SphereCenterInCanonical.Y, + canonicalAxisReferencePoint.Z - projectFrame.SphereCenterInCanonical.Z); + double directionLengthSquared = direction.X * direction.X + direction.Y * direction.Y + direction.Z * direction.Z; + if (directionLengthSquared < 1e-9) + { + throw new InvalidOperationException("光轴参考点与项目球心重合,无法生成装配参考线方向"); + } + + direction = direction.Normalize(); + double rodLength = UnitsConverter.ConvertFromMeters(AssemblyReferenceRodLengthInMeters); + Point3D canonicalStartPoint = new Point3D( + canonicalEndPoint.X + direction.X * rodLength, + canonicalEndPoint.Y + direction.Y * rodLength, + canonicalEndPoint.Z + direction.Z * rodLength); + Point3D startPoint = adapter.FromCanonicalPoint(canonicalStartPoint); + + LogManager.Info( + $"[直线装配] 参考线已计算,终点锚点=({endPoint.X:F2}, {endPoint.Y:F2}, {endPoint.Z:F2}), " + + $"光轴参考点=({opticalAxisReferencePoint.X:F2}, {opticalAxisReferencePoint.Y:F2}, {opticalAxisReferencePoint.Z:F2}), " + + $"参考线外端=({startPoint.X:F2}, {startPoint.Y:F2}, {startPoint.Z:F2}), " + + $"球心到光轴参考点方向(内部坐标)=({direction.X:F3}, {direction.Y:F3}, {direction.Z:F3})"); + + return new AssemblyReferenceLine(startPoint, endPoint, direction); + } + + private void RefreshAssemblyTerminalObjectInfo(Point3D referenceStartPoint = null, Point3D referenceEndPoint = null) + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return; + } + + var bounds = _assemblyTerminalObject.BoundingBox(); + string anchorText = GetAssemblyAnchorText(); + string mountText = AssemblyMountMode == RailMountMode.OverRail ? "轨上安装" : "轨下安装"; + Point3D opticalAxisReferencePoint = GetAssemblyOpticalAxisReferencePoint(); + string opticalAxisText = _hasAssemblyEndFaceAnalysis + ? string.Format("端面中心=({0:F2}, {1:F2}, {2:F2})", opticalAxisReferencePoint.X, opticalAxisReferencePoint.Y, opticalAxisReferencePoint.Z) + : string.Format("箱体中心=({0:F2}, {1:F2}, {2:F2})", opticalAxisReferencePoint.X, opticalAxisReferencePoint.Y, opticalAxisReferencePoint.Z); + string anchorPointText = _hasAssemblyInstallationReference + ? string.Format("{0}点=({1:F2}, {2:F2}, {3:F2})", + anchorText, + _assemblyInstallationAnchorPoint.X, + _assemblyInstallationAnchorPoint.Y, + _assemblyInstallationAnchorPoint.Z) + : string.Format("{0}点=未选择", anchorText); + string installationText = _hasAssemblyInstallationReference + ? string.Format( + "安装面点中点=({0:F2}, {1:F2}, {2:F2}),安装点=({3:F2}, {4:F2}, {5:F2}),偏距={6:F3}m", + _assemblyInstallationPickPoint.X, + _assemblyInstallationPickPoint.Y, + _assemblyInstallationPickPoint.Z, + _assemblyInstallationAnchorPoint.X, + _assemblyInstallationAnchorPoint.Y, + _assemblyInstallationAnchorPoint.Z, + _assemblyInstallationOffsetDistanceInMeters) + : "安装点=未选择"; + + if (referenceStartPoint != null && referenceEndPoint != null) + { + AssemblyTerminalObjectInfo = string.Format( + "中心=({0:F2}, {1:F2}, {2:F2}),尺寸=({3:F2}, {4:F2}, {5:F2}),{6},{7},{8},{9},垂直偏移={10:F3}m,辅助线外端=({11:F2}, {12:F2}, {13:F2})", + bounds.Center.X, + bounds.Center.Y, + bounds.Center.Z, + bounds.Max.X - bounds.Min.X, + bounds.Max.Y - bounds.Min.Y, + bounds.Max.Z - bounds.Min.Z, + mountText, + anchorPointText, + opticalAxisText, + installationText, + AssemblyAnchorVerticalOffsetInMeters, + referenceStartPoint.X, + referenceStartPoint.Y, + referenceStartPoint.Z); + return; + } + + AssemblyTerminalObjectInfo = string.Format( + "中心=({0:F2}, {1:F2}, {2:F2}),尺寸=({3:F2}, {4:F2}, {5:F2}),{6},{7},{8},{9},垂直偏移={10:F3}m", + bounds.Center.X, + bounds.Center.Y, + bounds.Center.Z, + bounds.Max.X - bounds.Min.X, + bounds.Max.Y - bounds.Min.Y, + bounds.Max.Z - bounds.Min.Z, + mountText, + anchorPointText, + opticalAxisText, + installationText, + AssemblyAnchorVerticalOffsetInMeters); + } + + private void RefreshAssemblyReferenceRodIfNeeded() + { + if (!ShouldShowAssemblyReferenceLineVisuals()) + { + ClearAssemblyReferenceLineVisuals(); + return; + } + + if (_assemblyTerminalObject == null) + { + return; + } + + try + { + AssemblyReferenceLine referenceLine = BuildAssemblyReferenceLine(); + + AssemblyReferencePathManager.Instance.CreateOrUpdateReferenceRod( + referenceLine.StartPoint, + referenceLine.EndPoint, + AssemblyReferenceRodDiameterInMeters); + + RefreshAssemblyTerminalObjectInfo(referenceLine.StartPoint, referenceLine.EndPoint); + RenderAssemblyAnchorMarker(); + RenderAssemblyCenterGuideLine(); + RenderAssemblyReferenceLine(referenceLine); + NotifyRailAssemblyCommandStateChanged(); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 刷新参考杆失败: {ex.Message}", ex); + } + } + + private void InitializeAssemblyAnchorVerticalOffsetFromTerminalObject() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return; + } + + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ProjectReferenceFrame projectFrame = CreateAssemblyProjectReferenceFrame(adapter); + var bounds = _assemblyTerminalObject.BoundingBox(); + double suggestedOffset = UnitsConverter.ConvertToMeters( + Math.Max(0.0, projectFrame.DefaultModelAxisConvention.GetUpSize(bounds) / 2.0)); + _assemblyAnchorVerticalOffsetInMeters = suggestedOffset; + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + } + + private double GetAssemblyAnchorDirection() + { + return PathRoute.IsTopReferenceFaceForMountMode(AssemblyMountMode) + ? 1.0 + : -1.0; + } + + private string GetAssemblyAnchorText() + { + return PathRoute.IsTopReferenceFaceForMountMode(AssemblyMountMode) + ? "顶面参考点" + : "底面参考点"; + } + + private sealed class AssemblyReferenceLine + { + public AssemblyReferenceLine(Point3D startPoint, Point3D endPoint, Vector3D direction) + { + StartPoint = startPoint; + EndPoint = endPoint; + Direction = direction; + } + + public Point3D StartPoint { get; } + public Point3D EndPoint { get; } + public Vector3D Direction { get; } + } + + private static double CalculateDefaultAssemblyReferenceRodDiameterInMeters() + { + double standardRadiusInMeters = ConfigManager.Instance.Current.PathEditing.CellSizeMeters; + standardRadiusInMeters = Math.Max(0.1, Math.Min(0.5, standardRadiusInMeters)); + return standardRadiusInMeters * 0.8; + } + + private void RenderAssemblyAnchorMarker() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return; + } + + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + Point3D anchorPoint = GetAssemblyTerminalAnchorPoint(); + LogManager.Info( + $"[直线装配] 已渲染终点锚点标记: ({anchorPoint.X:F2}, {anchorPoint.Y:F2}, {anchorPoint.Z:F2})"); + var markerRoute = new PathRoute("装配对接点") + { + Id = AssemblyAnchorMarkerPathId, + Description = "直线装配终点对接标记" + }; + AddVisualizationPoint(markerRoute, anchorPoint, "对接点", PathPointType.EndPoint); + renderPlugin.RenderPointOnly(markerRoute); + } + + private void RenderAssemblyReferenceLine(AssemblyReferenceLine referenceLine) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null || referenceLine == null) + { + return; + } + + renderPlugin.RenderRailBaseline( + AssemblyReferenceLinePathId, + new List { referenceLine.EndPoint, referenceLine.StartPoint }); + + LogManager.Info( + $"[直线装配] 已渲染参考线: 终点锚点=({referenceLine.EndPoint.X:F2}, {referenceLine.EndPoint.Y:F2}, {referenceLine.EndPoint.Z:F2}), " + + $"参考线外端=({referenceLine.StartPoint.X:F2}, {referenceLine.StartPoint.Y:F2}, {referenceLine.StartPoint.Z:F2})"); + } + + private void RenderAssemblyCenterGuideLine() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return; + } + + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + ProjectReferenceFrame projectFrame = CreateAssemblyProjectReferenceFrame(adapter); + + Point3D centerPoint = GetAssemblyOpticalAxisReferencePoint(); + Point3D sphereCenterPoint = adapter.FromCanonicalPoint(projectFrame.SphereCenterInCanonical); + renderPlugin.RenderRailBaseline( + AssemblyCenterGuideLinePathId, + new List { sphereCenterPoint, centerPoint }, + RenderStyleName.AssemblyGuideLine); + + LogManager.Info( + $"[直线装配] 已渲染球心到光轴参考点基准线: 球心=({sphereCenterPoint.X:F2}, {sphereCenterPoint.Y:F2}, {sphereCenterPoint.Z:F2}), " + + $"光轴参考点=({centerPoint.X:F2}, {centerPoint.Y:F2}, {centerPoint.Z:F2})"); + } + + private void RenderAssemblyEndFaceSeedPoints() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyEndFaceSeedPathId); + + if (_assemblyEndFaceSeedPoints.Count == 0) + { + return; + } + + var markerRoute = new PathRoute("端面三点") + { + Id = AssemblyEndFaceSeedPathId, + Description = "终端安装端面三点" + }; + + for (int i = 0; i < _assemblyEndFaceSeedPoints.Count; i++) + { + AddVisualizationPoint( + markerRoute, + _assemblyEndFaceSeedPoints[i], + $"端面点{i + 1}", + PathPointType.WayPoint, + UnitsConverter.ConvertFromMeters(AssemblyVisualizationPointDiameterInMeters)); + } + + renderPlugin.RenderPointOnly(markerRoute); + } + + private void RenderAssemblyEndFaceCenter(Point3D centerPoint) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyEndFaceCenterPathId); + + var markerRoute = new PathRoute("端面中心") + { + Id = AssemblyEndFaceCenterPathId, + Description = "终端安装端面中心" + }; + AddVisualizationPoint( + markerRoute, + centerPoint, + "端面中心", + PathPointType.WayPoint, + UnitsConverter.ConvertFromMeters(AssemblyVisualizationPointDiameterInMeters)); + renderPlugin.RenderPointOnly(markerRoute); + } + + private static void AddVisualizationPoint( + PathRoute route, + Point3D position, + string name, + PathPointType type, + double? visualizationDiameter = null) + { + if (route == null) + { + throw new ArgumentNullException(nameof(route)); + } + + route.Points.Add(new PathPoint(position, name, type) + { + Index = route.Points.Count, + VisualizationDiameter = visualizationDiameter + }); + } + + private void RenderAssemblyEndFaceNormal(Point3D centerPoint, Vector3 normal) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.ClearRailBaseline(AssemblyEndFaceNormalPathId); + + double lineLength = UnitsConverter.ConvertFromMeters(Math.Max(0.5, AssemblyReferenceRodDiameterInMeters * 4.0)); + Point3D normalEndPoint = new Point3D( + centerPoint.X + normal.X * (float)lineLength, + centerPoint.Y + normal.Y * (float)lineLength, + centerPoint.Z + normal.Z * (float)lineLength); + renderPlugin.RenderRailBaseline( + AssemblyEndFaceNormalPathId, + new List { centerPoint, normalEndPoint }, + RenderStyleName.AssemblyGuideLine); + } + + private void BuildAndRenderAssemblyInstallationReference(Point3D firstPickPoint, Point3D secondPickPoint) + { + Point3D opticalAxisReferencePoint = GetAssemblyOpticalAxisReferencePoint(); + Point3D sphereCenterPoint = new Point3D( + AssemblySphereCenterX, + AssemblySphereCenterY, + AssemblySphereCenterZ); + + Vector3 opticalAxisDirection = new Vector3( + (float)(opticalAxisReferencePoint.X - sphereCenterPoint.X), + (float)(opticalAxisReferencePoint.Y - sphereCenterPoint.Y), + (float)(opticalAxisReferencePoint.Z - sphereCenterPoint.Z)); + + AssemblyInstallationReferenceResult result = AssemblyInstallationReferenceBuilder.Build( + new Vector3((float)opticalAxisReferencePoint.X, (float)opticalAxisReferencePoint.Y, (float)opticalAxisReferencePoint.Z), + opticalAxisDirection, + new Vector3((float)firstPickPoint.X, (float)firstPickPoint.Y, (float)firstPickPoint.Z), + new Vector3((float)secondPickPoint.X, (float)secondPickPoint.Y, (float)secondPickPoint.Z)); + + _hasAssemblyInstallationReference = true; + _assemblyInstallationPickPoint = AssemblyEndFaceAnalyzer.ToPoint3D((result.PickPoint + result.SecondaryPickPoint) * 0.5f); + _assemblyInstallationBaseAnchorPoint = AssemblyEndFaceAnalyzer.ToPoint3D(result.AnchorPoint); + _assemblyInstallationPlaneNormal = result.PlaneNormal; + _assemblyInstallationPlaneSpanDirection = result.PlaneSpanDirection; + _assemblyInstallationOffsetDistanceInMeters = UnitsConverter.ConvertToMeters(result.OffsetDistance); + _assemblyAnchorVerticalOffsetInMeters = DefaultAssemblyAnchorVerticalOffsetInMeters; + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + UpdateAssemblyInstallationAnchorFromVerticalOffset(); + + RenderAssemblyInstallationPickPoints(_assemblyInstallationSeedPoints); + RenderAssemblyInstallationCenterLine(AssemblyEndFaceAnalyzer.ToPoint3D(result.InstallLineBasePoint), result.OpticalAxisDirection); + + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + UpdateMainStatus( + $"安装参考已计算:安装点=({_assemblyInstallationAnchorPoint.X:F2}, {_assemblyInstallationAnchorPoint.Y:F2}, {_assemblyInstallationAnchorPoint.Z:F2}),偏距={_assemblyInstallationOffsetDistanceInMeters:F3}m,可继续取起点"); + LogManager.Info( + $"[直线装配] 安装参考已计算: 安装面点1=({firstPickPoint.X:F3}, {firstPickPoint.Y:F3}, {firstPickPoint.Z:F3}), " + + $"安装面点2=({secondPickPoint.X:F3}, {secondPickPoint.Y:F3}, {secondPickPoint.Z:F3}), " + + $"安装点=({_assemblyInstallationAnchorPoint.X:F3}, {_assemblyInstallationAnchorPoint.Y:F3}, {_assemblyInstallationAnchorPoint.Z:F3}), " + + $"偏距={_assemblyInstallationOffsetDistanceInMeters:F3}m, 平面法向=({result.PlaneNormal.X:F4}, {result.PlaneNormal.Y:F4}, {result.PlaneNormal.Z:F4})"); + NotifyRailAssemblyCommandStateChanged(); + } + + private void RenderAssemblyInstallationPickPoints(IReadOnlyList pickPoints) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyInstallationPickPointPathId); + var markerRoute = new PathRoute("安装点") + { + Id = AssemblyInstallationPickPointPathId, + Description = "终端安装点" + }; + for (int i = 0; i < pickPoints.Count; i++) + { + AddVisualizationPoint( + markerRoute, + pickPoints[i], + $"安装面点{i + 1}", + PathPointType.WayPoint, + UnitsConverter.ConvertFromMeters(AssemblyVisualizationPointDiameterInMeters)); + } + renderPlugin.RenderPointOnly(markerRoute); + } + + private void RenderAssemblyInstallationAnchorPoint(Point3D anchorPoint) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyInstallationAnchorPointPathId); + var markerRoute = new PathRoute("安装点") + { + Id = AssemblyInstallationAnchorPointPathId, + Description = "终端安装点" + }; + AddVisualizationPoint( + markerRoute, + anchorPoint, + "安装点", + PathPointType.WayPoint, + UnitsConverter.ConvertFromMeters(AssemblyVisualizationPointDiameterInMeters)); + renderPlugin.RenderPointOnly(markerRoute); + } + + private void RenderAssemblyInstallationCenterLine(Point3D lineBasePoint, Vector3 axisDirection) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.ClearRailBaseline(AssemblyInstallationCenterLinePathId); + double halfLength = GetAssemblyInstallationPlaneSideLength() * AssemblyInstallationLineLengthScale / 2.0; + Point3D startPoint = new Point3D( + lineBasePoint.X - axisDirection.X * (float)halfLength, + lineBasePoint.Y - axisDirection.Y * (float)halfLength, + lineBasePoint.Z - axisDirection.Z * (float)halfLength); + Point3D endPoint = new Point3D( + lineBasePoint.X + axisDirection.X * (float)halfLength, + lineBasePoint.Y + axisDirection.Y * (float)halfLength, + lineBasePoint.Z + axisDirection.Z * (float)halfLength); + renderPlugin.RenderRailBaseline( + AssemblyInstallationCenterLinePathId, + new List { startPoint, endPoint }, + RenderStyleName.AssemblyGuideLine); + } + + private void RenderAssemblyInstallationPlane(Point3D planeCenter, Vector3 axisDirection, Vector3 planeSpanDirection) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + double sideLength = GetAssemblyInstallationPlaneSideLength(); + double halfLength = sideLength / 2.0; + double thickness = UnitsConverter.ConvertFromMeters(0.01); + + Vector3D xVector = new Vector3D( + axisDirection.X * (float)sideLength, + axisDirection.Y * (float)sideLength, + axisDirection.Z * (float)sideLength); + Vector3D yVector = new Vector3D( + planeSpanDirection.X * (float)sideLength, + planeSpanDirection.Y * (float)sideLength, + planeSpanDirection.Z * (float)sideLength); + Point3D origin = new Point3D( + planeCenter.X - axisDirection.X * (float)halfLength - planeSpanDirection.X * (float)halfLength + _assemblyInstallationPlaneNormal.X * thickness, + planeCenter.Y - axisDirection.Y * (float)halfLength - planeSpanDirection.Y * (float)halfLength + _assemblyInstallationPlaneNormal.Y * thickness, + planeCenter.Z - axisDirection.Z * (float)halfLength - planeSpanDirection.Z * (float)halfLength + _assemblyInstallationPlaneNormal.Z * thickness); + + renderPlugin.RemovePath(AssemblyInstallationPlanePathId); + renderPlugin.RenderRectanglePlane( + AssemblyInstallationPlanePathId, + origin, + xVector, + yVector, + RenderStyleName.AssemblyInstallationPlane, + filled: true); + } + + private double GetAssemblyInstallationPlaneSideLength() + { + if (_assemblyTerminalObject == null || !ModelItemAnalysisHelper.IsModelItemValid(_assemblyTerminalObject)) + { + return UnitsConverter.ConvertFromMeters(1.0); + } + + BoundingBox3D bounds = _assemblyTerminalObject.BoundingBox(); + double maxSize = Math.Max( + bounds.Max.X - bounds.Min.X, + Math.Max(bounds.Max.Y - bounds.Min.Y, bounds.Max.Z - bounds.Min.Z)); + return maxSize + UnitsConverter.ConvertFromMeters(AssemblyInstallationPlanePaddingInMeters) * 2.0; + } + + private ProjectReferenceFrame CreateAssemblyProjectReferenceFrame(HostCoordinateAdapter adapter) + { + if (adapter == null) + { + throw new ArgumentNullException(nameof(adapter)); + } + + Point3D hostSphereCenter = new Point3D( + AssemblySphereCenterX, + AssemblySphereCenterY, + AssemblySphereCenterZ); + + Point3D canonicalSphereCenter = adapter.ToCanonicalPoint(hostSphereCenter); + return new ProjectReferenceFrame( + canonicalSphereCenter, + HostCoordinateAdapter.CanonicalUp, + ModelAxisConvention.CreateDefaultForHost(adapter.HostType)); + } + + private void ClearAssemblyAnchorMarker() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyAnchorMarkerPathId); + } + + private void ClearAssemblyReferenceLine() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.ClearRailBaseline(AssemblyReferenceLinePathId); + } + + private void ClearAssemblyCenterGuideLine() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.ClearRailBaseline(AssemblyCenterGuideLinePathId); + } + + private void ClearAssemblyReferenceLineVisuals() + { + AssemblyReferencePathManager.Instance.HideReferenceRod(); + ClearAssemblyAnchorMarker(); + ClearAssemblyCenterGuideLine(); + ClearAssemblyReferenceLine(); + NotifyRailAssemblyCommandStateChanged(); + OnPropertyChanged(nameof(CanRepositionRailStartPoint)); + } + + private void ClearAssemblyEndFaceAnalysisVisuals() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyEndFaceSeedPathId); + renderPlugin.RemovePath(AssemblyEndFaceCenterPathId); + renderPlugin.ClearRailBaseline(AssemblyEndFaceNormalPathId); + } + + private void ResetAssemblyEndFaceAnalysisState() + { + _railAssemblyContext.ResetEndFaceAnalysis(); + } + + private void ClearAssemblyInstallationReferenceVisuals() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.RemovePath(AssemblyInstallationPickPointPathId); + renderPlugin.RemovePath(AssemblyInstallationAnchorPointPathId); + renderPlugin.ClearRailBaseline(AssemblyInstallationCenterLinePathId); + renderPlugin.ClearRailBaseline(AssemblyInstallationOffsetLinePathId); + renderPlugin.RemovePath(AssemblyInstallationPlanePathId); + } + + private void ResetAssemblyInstallationReferenceState() + { + _railAssemblyContext.ResetInstallationReference(); + OnPropertyChanged(nameof(AssemblyAnchorVerticalOffsetInMeters)); + NotifyRailAssemblyCommandStateChanged(); + } + + private void CleanupAssemblyReferenceSelection() + { + try + { + PathClickToolPlugin.MouseClicked -= OnAssemblyReferenceMouseClicked; + IsSelectingAssemblyStartPoint = false; + _pathPlanningManager?.EnableMouseHandling(); + _pathPlanningManager?.StopClickTool(); + NotifyRailAssemblyCommandStateChanged(); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 清理起点拾取状态失败: {ex.Message}", ex); + } + } + + private void CleanupAssemblyInstallationSelection(bool clearVisuals = true) + { + try + { + PathClickToolPlugin.MouseClicked -= OnAssemblyInstallationMouseClicked; + _isSelectingAssemblyInstallationPoint = false; + _pathPlanningManager?.EnableMouseHandling(); + _pathPlanningManager?.StopClickTool(); + if (clearVisuals) + { + ClearAssemblyInstallationReferenceVisuals(); + } + NotifyRailAssemblyCommandStateChanged(); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 清理参考安装点拾取状态失败: {ex.Message}", ex); + } + } + + private void CleanupAssemblyEndFaceSelection(bool clearVisuals) + { + try + { + PathClickToolPlugin.MouseClicked -= OnAssemblyEndFaceMouseClicked; + _isSelectingAssemblyEndFacePoints = false; + _pathPlanningManager?.EnableMouseHandling(); + _pathPlanningManager?.StopClickTool(); + if (clearVisuals) + { + _assemblyEndFaceSeedPoints.Clear(); + ClearAssemblyEndFaceAnalysisVisuals(); + ResetAssemblyEndFaceAnalysisState(); + } + NotifyRailAssemblyCommandStateChanged(); + } + catch (Exception ex) + { + LogManager.Error($"[直线装配] 清理端面三点拾取状态失败: {ex.Message}", ex); + } + } + + private bool IsPickOnAssemblyTerminalObject(PickItemResult pickResult) + { + if (pickResult?.ModelItem == null || _assemblyTerminalObject == null) + { + return false; + } + + if (ModelItemAnalysisHelper.ModelItemEquals(pickResult.ModelItem, _assemblyTerminalObject)) + { + return true; + } + + return pickResult.ModelItem.AncestorsAndSelf.Any(ancestor => ModelItemAnalysisHelper.ModelItemEquals(ancestor, _assemblyTerminalObject)); + } + + private void AnalyzeCurrentAssemblyEndFace() + { + if (_assemblyEndFaceSeedPoints.Count != 3) + { + throw new InvalidOperationException("端面分析需要恰好 3 个种子点。"); + } + + var triangles = GeometryHelper.ExtractTriangles(new[] { _assemblyTerminalObject }) + .Select(triangle => new AnalysisTriangle3( + new Vector3((float)triangle.Point1.X, (float)triangle.Point1.Y, (float)triangle.Point1.Z), + new Vector3((float)triangle.Point2.X, (float)triangle.Point2.Y, (float)triangle.Point2.Z), + new Vector3((float)triangle.Point3.X, (float)triangle.Point3.Y, (float)triangle.Point3.Z))) + .ToList(); + + EndFaceAnalysisResult result = AssemblyEndFaceAnalyzer.Analyze( + triangles, + new Vector3((float)_assemblyEndFaceSeedPoints[0].X, (float)_assemblyEndFaceSeedPoints[0].Y, (float)_assemblyEndFaceSeedPoints[0].Z), + new Vector3((float)_assemblyEndFaceSeedPoints[1].X, (float)_assemblyEndFaceSeedPoints[1].Y, (float)_assemblyEndFaceSeedPoints[1].Z), + new Vector3((float)_assemblyEndFaceSeedPoints[2].X, (float)_assemblyEndFaceSeedPoints[2].Y, (float)_assemblyEndFaceSeedPoints[2].Z)); + + if (!result.IsReliable) + { + throw new InvalidOperationException(result.DiagnosticMessage); + } + + Point3D centerPoint = AssemblyEndFaceAnalyzer.ToPoint3D(result.Center); + Point3D sphereCenterPoint = new Point3D( + AssemblySphereCenterX, + AssemblySphereCenterY, + AssemblySphereCenterZ); + Vector3 orientedNormal = AssemblyEndFaceAnalyzer.OrientNormalTowardTarget( + result.Normal, + new Vector3((float)centerPoint.X, (float)centerPoint.Y, (float)centerPoint.Z), + new Vector3((float)sphereCenterPoint.X, (float)sphereCenterPoint.Y, (float)sphereCenterPoint.Z)); + _hasAssemblyEndFaceAnalysis = true; + _assemblyEndFaceCenterPoint = centerPoint; + _assemblyEndFaceNormal = orientedNormal; + RenderAssemblyEndFaceSeedPoints(); + RenderAssemblyEndFaceCenter(centerPoint); + RenderAssemblyEndFaceNormal(centerPoint, orientedNormal); + RefreshAssemblyTerminalObjectInfo(); + RefreshAssemblyReferenceRodIfNeeded(); + UpdateMainStatus($"端面分析完成:中心=({centerPoint.X:F2}, {centerPoint.Y:F2}, {centerPoint.Z:F2}),候选三角形={result.CandidateTriangleCount}"); + LogManager.Info( + $"[直线装配] 端面分析完成: 中心=({centerPoint.X:F3}, {centerPoint.Y:F3}, {centerPoint.Z:F3}), " + + $"法向=({orientedNormal.X:F4}, {orientedNormal.Y:F4}, {orientedNormal.Z:F4}), " + + $"三角形={result.CandidateTriangleCount}, 顶点={result.CandidateVertexCount}, 偏差={result.MaxPlaneDeviation:F6}"); + } + private async Task ExecuteNewPathAsync() { await SafeExecuteAsync(() => @@ -1117,7 +3685,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels { await SafeExecuteAsync(() => { - UpdateMainStatus("正在创建新空轨路径..."); + UpdateMainStatus("正在创建传统空轨路径..."); // 检查是否有空轨基准路径 if (PathPointRenderPlugin.Instance != null) @@ -1126,11 +3694,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels { // 清除所有现有路径的可视化显示 PathPointRenderPlugin.Instance.ClearPathsExcept("grid_visualization_all", "grid_visualization_channel", "grid_visualization_unknown", "grid_visualization_obstacle", "grid_visualization_door"); - LogManager.Info("新建空轨路径:已清除现有路径可视化显示(保留网格可视化)"); + LogManager.Info("新建传统空轨路径:已清除现有路径可视化显示(保留网格可视化)"); } catch (Exception ex) { - LogManager.Error($"新建空轨路径:清除现有路径可视化失败: {ex.Message}", ex); + LogManager.Error($"新建传统空轨路径:清除现有路径可视化失败: {ex.Message}", ex); throw; } } @@ -1140,7 +3708,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (_pathPlanningManager != null) { - // 创建空轨路径 + // 创建传统空轨路径 var newRoute = _pathPlanningManager.StartCreatingNewRoute( isRailPath: true, pathType: PathType.Rail); @@ -1170,22 +3738,22 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (!ForceReinitializeToolPlugin(subscribeToEvents: true)) { UpdateMainStatus("ToolPlugin初始化失败,请重试"); - LogManager.Error("新建空轨路径:ToolPlugin初始化失败"); + LogManager.Error("新建传统空轨路径:ToolPlugin初始化失败"); return; } // 启动点击工具,设置空轨吸附模式 _pathPlanningManager.StartClickTool(PathPointType.WayPoint, enableRailSnapping: true); - LogManager.Info($"已启动空轨路径点击工具(吸附模式): {newRoute.Name}"); + LogManager.Info($"已启动传统空轨路径点击工具(吸附模式): {newRoute.Name}"); - UpdateMainStatus($"已进入新建空轨路径模式: {newRoute.Name} - 请在3D视图中点击空轨模型设置路径点(将自动吸附到基准路径)"); - LogManager.Info($"开始新建空轨路径: {newRoute.Name},已强制重新初始化ToolPlugin获取鼠标焦点"); + UpdateMainStatus($"已进入传统空轨路径模式: {newRoute.Name} - 请在3D视图中点击空轨模型设置路径点(将自动吸附到基准路径)"); + LogManager.Info($"开始新建传统空轨路径: {newRoute.Name},已强制重新初始化ToolPlugin获取鼠标焦点"); } else { - UpdateMainStatus("创建新空轨路径失败:没有可通行的物流模型"); - LogManager.Error("创建新空轨路径失败:没有可通行的物流模型"); - MessageBox.Show("创建新空轨路径失败:没有找到任何可通行的物流模型。\n请先为模型设置可通行的物流属性,然后再尝试创建路径。", "错误", + UpdateMainStatus("创建传统空轨路径失败:没有可通行的物流模型"); + LogManager.Error("创建传统空轨路径失败:没有可通行的物流模型"); + MessageBox.Show("创建传统空轨路径失败:没有找到任何可通行的物流模型。\n请先为模型设置可通行的物流属性,然后再尝试创建路径。", "错误", MessageBoxButton.OK, MessageBoxImage.Warning); } } @@ -1194,7 +3762,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels UpdateMainStatus("路径规划管理器未初始化"); LogManager.Error("路径规划管理器未初始化"); } - }, "新建空轨路径"); + }, "新建传统空轨路径"); } /// @@ -1913,6 +4481,51 @@ namespace NavisworksTransport.UI.WPF.ViewModels }, "删除路径"); } + private async Task ExecuteDuplicatePathAsync() + { + if (SelectedPathRoute == null || _pathPlanningManager == null) return; + + await SafeExecuteAsync(async () => + { + var sourceRoute = SelectedPathRoute.Route ?? + _pathPlanningManager.Routes.FirstOrDefault(r => r.Id == SelectedPathRoute.Id); + if (sourceRoute == null) + { + throw new InvalidOperationException("未找到要复制的路径数据"); + } + + UpdateMainStatus($"正在复制路径: {sourceRoute.Name}..."); + + var duplicatedRoute = sourceRoute.Clone(); + duplicatedRoute.Name = PathHelper.BuildDuplicatedPathName(sourceRoute.Name); + duplicatedRoute.CreatedTime = DateTime.Now; + duplicatedRoute.LastModified = DateTime.Now; + + if (!_pathPlanningManager.AddRoute(duplicatedRoute)) + { + throw new InvalidOperationException($"复制路径失败: {sourceRoute.Name}"); + } + + var duplicatedPathViewModel = new PathRouteViewModel(isFromDatabase: true) + { + Route = duplicatedRoute, + IsActive = false + }; + duplicatedPathViewModel.SetTimeInfo(duplicatedRoute.CreatedTime, duplicatedRoute.LastModified); + + foreach (var point in CreatePathPointViewModelsFromCoreRoute(duplicatedRoute)) + { + duplicatedPathViewModel.Points.Add(point); + } + + PathRoutes.Add(duplicatedPathViewModel); + SelectedPathRoute = duplicatedPathViewModel; + UpdateMainStatus($"已复制路径: {sourceRoute.Name} -> {duplicatedPathViewModel.Name}"); + + await Task.CompletedTask; + }, "复制路径"); + } + #endregion #region 坐标编辑命令 @@ -2019,6 +4632,236 @@ namespace NavisworksTransport.UI.WPF.ViewModels return points; } + private bool SyncPathViewModelFromCoreRoute(PathRouteViewModel pathViewModel, PathRoute coreRoute, bool preserveSelection = true) + { + if (pathViewModel == null || coreRoute == null) + { + return false; + } + + bool needsUpdate = !ReferenceEquals(pathViewModel.Route, coreRoute) || + pathViewModel.Points.Count != coreRoute.Points.Count; + + if (!needsUpdate && pathViewModel.Points.Count == coreRoute.Points.Count) + { + for (int i = 0; i < pathViewModel.Points.Count; i++) + { + var uiPoint = pathViewModel.Points[i]; + var corePoint = coreRoute.Points[i]; + + if (uiPoint.Id != corePoint.Id || + uiPoint.Name != corePoint.Name || + uiPoint.Type != corePoint.Type) + { + needsUpdate = true; + break; + } + + const double tolerance = 1e-6; + if (Math.Abs(uiPoint.X - corePoint.Position.X) > tolerance || + Math.Abs(uiPoint.Y - corePoint.Position.Y) > tolerance || + Math.Abs(uiPoint.Z - corePoint.Position.Z) > tolerance) + { + needsUpdate = true; + break; + } + } + } + + pathViewModel.Route = coreRoute; + pathViewModel.SetTimeInfo(coreRoute.CreatedTime, coreRoute.LastModified); + + if (!needsUpdate) + { + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.TotalLength)); + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.LastModifiedTime)); + return false; + } + + string selectedPointId = preserveSelection ? SelectedPathPoint?.Id : null; + bool hadSelection = !string.IsNullOrEmpty(selectedPointId); + PathPointViewModel newSelectedPoint = null; + + pathViewModel.Points.Clear(); + foreach (var pointViewModel in CreatePathPointViewModelsFromCoreRoute(coreRoute)) + { + pathViewModel.Points.Add(pointViewModel); + if (hadSelection && pointViewModel.Id == selectedPointId) + { + newSelectedPoint = pointViewModel; + } + } + + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.PointCount)); + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.TotalLength)); + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.SummaryInfo)); + pathViewModel.NotifyPropertyChanged(nameof(PathRouteViewModel.LastModifiedTime)); + + if (hadSelection) + { + SelectedPathPoint = newSelectedPoint; + } + + return true; + } + + private void RefreshHoistingLayerEditItemsFromSelectedRoute() + { + HoistingLayerEditItems.Clear(); + + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Hoisting || coreRoute.Points == null || coreRoute.Points.Count < 2) + { + HoistingLayerEditHintText = "选择一条吊装路径后,可在这里按层编辑相对起点的绝对高度。"; + OnPropertyChanged(nameof(CanApplyHoistingLayerHeights)); + return; + } + + HostCoordinateAdapter hostAdapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + PathPoint hostStartPoint = coreRoute.Points.FirstOrDefault(point => point.Type == PathPointType.StartPoint) ?? coreRoute.Points.First(); + double hostStartElevation = HoistingCoordinateHelper.GetElevation(hostStartPoint.Position, hostAdapter); + const double relativeHeightToleranceInMeters = 0.001; + + var layerItems = new List(); + foreach (var point in coreRoute.Points) + { + if (point == null || point.Type == PathPointType.StartPoint || point.Type == PathPointType.EndPoint) + { + continue; + } + + double hostPointElevation = HoistingCoordinateHelper.GetElevation(point.Position, hostAdapter); + double relativeHeightInMeters = UnitsConverter.ConvertToMeters(hostPointElevation - hostStartElevation); + if (relativeHeightInMeters <= relativeHeightToleranceInMeters) + { + continue; + } + + HoistingLayerEditItem existingLayer = layerItems.FirstOrDefault(item => + Math.Abs(item.HeightInMeters - relativeHeightInMeters) <= relativeHeightToleranceInMeters); + if (existingLayer == null) + { + existingLayer = new HoistingLayerEditItem + { + HeightInMeters = Math.Round(relativeHeightInMeters, 3) + }; + layerItems.Add(existingLayer); + } + + if (!string.IsNullOrWhiteSpace(point.Id) && !existingLayer.PointIds.Contains(point.Id)) + { + existingLayer.PointIds.Add(point.Id); + } + } + + List orderedLayers = layerItems + .OrderByDescending(item => item.HeightInMeters) + .ToList(); + + for (int i = 0; i < orderedLayers.Count; i++) + { + orderedLayers[i].DisplayIndex = i + 1; + orderedLayers[i].PointSummary = $"关联点 {orderedLayers[i].PointIds.Count} 个"; + HoistingLayerEditItems.Add(orderedLayers[i]); + } + + HoistingLayerEditHintText = HoistingLayerEditItems.Count > 0 + ? "层高按高到低显示;每层高度相对起吊点,且必须严格低于上一层、高于下一层。" + : "当前吊装路径未解析出可编辑的空中层。"; + OnPropertyChanged(nameof(CanApplyHoistingLayerHeights)); + } + + private async Task ExecuteApplyHoistingLayerHeightsAsync() + { + await SafeExecuteAsync(() => + { + var coreRoute = GetSelectedCoreRoute(); + if (coreRoute == null || coreRoute.PathType != PathType.Hoisting) + { + return; + } + + if (HoistingLayerEditItems.Count == 0) + { + UpdateMainStatus("当前吊装路径没有可编辑的空中层。"); + return; + } + + for (int i = 0; i < HoistingLayerEditItems.Count; i++) + { + HoistingLayerEditItem currentLayer = HoistingLayerEditItems[i]; + if (currentLayer.HeightInMeters <= 0) + { + HandleInvalidHoistingLayerHeightInput($"第{currentLayer.DisplayIndex}层高度必须大于0。"); + return; + } + + if (i > 0 && currentLayer.HeightInMeters >= HoistingLayerEditItems[i - 1].HeightInMeters) + { + HandleInvalidHoistingLayerHeightInput($"第{currentLayer.DisplayIndex}层高度必须严格低于上一层。"); + return; + } + + if (i < HoistingLayerEditItems.Count - 1 && currentLayer.HeightInMeters <= HoistingLayerEditItems[i + 1].HeightInMeters) + { + HandleInvalidHoistingLayerHeightInput($"第{currentLayer.DisplayIndex}层高度必须严格高于下一层。"); + return; + } + } + + HostCoordinateAdapter hostAdapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + PathPoint hostStartPoint = coreRoute.Points.FirstOrDefault(point => point.Type == PathPointType.StartPoint) ?? coreRoute.Points.First(); + double hostStartElevation = HoistingCoordinateHelper.GetElevation(hostStartPoint.Position, hostAdapter); + + foreach (HoistingLayerEditItem layerItem in HoistingLayerEditItems) + { + double hostTargetElevation = hostStartElevation + UnitsConverter.ConvertFromMeters(layerItem.HeightInMeters); + foreach (string pointId in layerItem.PointIds) + { + PathPoint targetPoint = coreRoute.Points.FirstOrDefault(point => string.Equals(point.Id, pointId, StringComparison.Ordinal)); + if (targetPoint != null) + { + targetPoint.Position = HoistingCoordinateHelper.SetElevation(targetPoint.Position, hostTargetElevation, hostAdapter); + } + } + } + + PathPoint firstAerialPoint = coreRoute.Points.FirstOrDefault(point => + point != null && + point.Type != PathPointType.StartPoint && + point.Type != PathPointType.EndPoint && + HoistingCoordinateHelper.GetElevation(point.Position, hostAdapter) > hostStartElevation + UnitsConverter.ConvertFromMeters(0.001)); + coreRoute.LiftHeight = firstAerialPoint != null + ? HoistingCoordinateHelper.GetElevation(firstAerialPoint.Position, hostAdapter) - hostStartElevation + : 0.0; + coreRoute.LastModified = DateTime.Now; + coreRoute.RecalculateRoute("更新吊装层高"); + + SyncObjectParametersToRenderPlugin(); + _pathPlanningManager.DrawRouteVisualization(coreRoute, isAutoPath: false); + _pathPlanningManager.SavePathToDatabase(coreRoute); + _pathPlanningManager.RaiseVisualizationStateChanged(); + + if (SelectedPathRoute != null && SelectedPathRoute.Id == coreRoute.Id) + { + SyncPathViewModelFromCoreRoute(SelectedPathRoute, coreRoute, preserveSelection: true); + } + + RefreshHoistingLayerEditItemsFromSelectedRoute(); + UpdateMainStatus($"已应用 {HoistingLayerEditItems.Count} 个吊装层高。"); + LogManager.Info($"[吊装层高] {coreRoute.Name}: 已应用 {HoistingLayerEditItems.Count} 个层高编辑项"); + }, "应用吊装层高"); + } + + private void HandleInvalidHoistingLayerHeightInput(string message) + { + const string validationMessage = "每层高度必须低于上一层、高于下一层,也不能相等。"; + LogManager.Warning($"[吊装层高] 用户输入无效: {message}"); + RefreshHoistingLayerEditItemsFromSelectedRoute(); + UpdateMainStatus(validationMessage); + MessageBox.Show(validationMessage, "层高输入无效", MessageBoxButton.OK, MessageBoxImage.Warning); + } + /// /// 更新吊装路径的关联点(UI层) /// @@ -2484,6 +5327,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels { if (!CanExecuteEndEdit) return; + if ((_pathPlanningManager == null || _pathPlanningManager.PathEditState == PathEditState.Viewing) && + CanCancelRailAssemblyWorkflow) + { + ExecuteCancelRailAssemblyWorkflow(); + return; + } + await SafeExecuteAsync(async () => { bool success = false; @@ -3512,11 +6362,49 @@ namespace NavisworksTransport.UI.WPF.ViewModels } else if (animationVm.SelectedAnimatedObject != null) { - // 使用选择物体时保存的原始尺寸(米),不受旋转影响 - objectLengthModel = animationVm.ObjectOriginalLength * metersToUnits; - objectWidthModel = animationVm.ObjectOriginalWidth * metersToUnits; - objectHeightModel = animationVm.ObjectOriginalHeight * metersToUnits; - LogManager.Debug($"[物体参数同步] 使用选择物体保存的原始尺寸: {animationVm.ObjectOriginalLength:F2}m x {animationVm.ObjectOriginalWidth:F2}m x {animationVm.ObjectOriginalHeight:F2}m"); + var animationManager = NavisworksTransport.Core.Animation.PathAnimationManager.GetInstance(); + if ((pathType == PathType.Ground || pathType == PathType.Hoisting) && + animationManager != null && + animationManager.TryGetCurrentRouteRealObjectPlanarProjectedExtents( + out double resolvedPlanarForward, + out double resolvedPlanarSide, + out double resolvedPlanarUp)) + { + objectLengthModel = resolvedPlanarForward; + objectWidthModel = resolvedPlanarSide; + objectHeightModel = resolvedPlanarUp; + LogManager.Debug( + $"[物体参数同步] 使用真实物体平面路径最终姿态尺寸: " + + $"沿路径={resolvedPlanarForward / metersToUnits:F2}m, " + + $"垂直路径={resolvedPlanarSide / metersToUnits:F2}m, " + + $"法线={resolvedPlanarUp / metersToUnits:F2}m"); + } + else if (pathType == PathType.Rail && + animationManager != null && + animationManager.TryGetCurrentRouteRealObjectRailProjectedExtents( + out double resolvedRailForward, + out double resolvedRailSide, + out double resolvedRailUp)) + { + objectLengthModel = resolvedRailForward; + objectWidthModel = resolvedRailSide; + objectHeightModel = resolvedRailUp; + LogManager.Debug( + $"[物体参数同步] 使用真实物体 Rail 最终姿态尺寸: " + + $"沿路径={resolvedRailForward / metersToUnits:F2}m, " + + $"垂直路径={resolvedRailSide / metersToUnits:F2}m, " + + $"法线={resolvedRailUp / metersToUnits:F2}m"); + } + else + { + // 回退:使用选择物体时保存的原始尺寸(米) + objectLengthModel = animationVm.ObjectOriginalLength * metersToUnits; + objectWidthModel = animationVm.ObjectOriginalWidth * metersToUnits; + objectHeightModel = animationVm.ObjectOriginalHeight * metersToUnits; + LogManager.Debug( + $"[物体参数同步] 回退使用选择物体保存的原始尺寸: " + + $"{animationVm.ObjectOriginalLength:F2}m x {animationVm.ObjectOriginalWidth:F2}m x {animationVm.ObjectOriginalHeight:F2}m"); + } } else { @@ -3972,88 +6860,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels var pathViewModel = PathRoutes.FirstOrDefault(p => p.Name == e.Route.Name); if (pathViewModel != null) { - // 检查是否需要更新:数量变化或名称/类型/坐标变化 - bool needsUpdate = pathViewModel.Points.Count != e.Route.Points.Count; - - if (!needsUpdate && pathViewModel.Points.Count == e.Route.Points.Count) - { - // 数量相同时,检查名称、类型和坐标是否有变化 - for (int i = 0; i < pathViewModel.Points.Count; i++) - { - var uiPoint = pathViewModel.Points[i]; - var corePoint = e.Route.Points[i]; - - // 检查名称和类型变化 - if (uiPoint.Name != corePoint.Name || uiPoint.Type != corePoint.Type) - { - needsUpdate = true; - LogManager.Info($"检测到路径点属性变化: {uiPoint.Name}({uiPoint.Type}) -> {corePoint.Name}({corePoint.Type})"); - break; - } - - // 检查坐标变化(使用小数位精度比较) - const double tolerance = 1e-6; // 1微米精度 - if (Math.Abs(uiPoint.X - corePoint.Position.X) > tolerance || - Math.Abs(uiPoint.Y - corePoint.Position.Y) > tolerance || - Math.Abs(uiPoint.Z - corePoint.Position.Z) > tolerance) - { - needsUpdate = true; - LogManager.Info($"检测到路径点坐标变化: {uiPoint.Name} " + - $"({uiPoint.X:F3}, {uiPoint.Y:F3}, {uiPoint.Z:F3}) -> " + - $"({corePoint.Position.X:F3}, {corePoint.Position.Y:F3}, {corePoint.Position.Z:F3})"); - break; - } - } - } - - if (!needsUpdate) + bool updated = SyncPathViewModelFromCoreRoute(pathViewModel, e.Route, preserveSelection: true); + if (!updated) { LogManager.Info($"路径点数据无变化({pathViewModel.Points.Count}),跳过更新"); return; } - // 保存当前选中点的Id,以便在重新创建后恢复选中状态 - string selectedPointId = SelectedPathPoint?.Id; - bool hadSelection = !string.IsNullOrEmpty(selectedPointId); - - // 同步路径点数据 - pathViewModel.Points.Clear(); - PathPointViewModel newSelectedPoint = null; - for (int i = 0; i < e.Route.Points.Count; i++) - { - var point = e.Route.Points[i]; - var pointViewModel = new PathPointViewModel - { - Id = point.Id, - Name = point.Name, - X = point.Position.X, - Y = point.Position.Y, - Z = point.Position.Z, - Type = point.Type, - Index = i - }; - pathViewModel.Points.Add(pointViewModel); - LogManager.Debug($"[路径点列表更新] 索引{i}: {point.Name}, Id={point.Id}, 位置=({point.Position.X:F2}, {point.Position.Y:F2}, {point.Position.Z:F2})"); - - // 如果是之前选中的点,保存引用 - if (hadSelection && point.Id == selectedPointId) - { - newSelectedPoint = pointViewModel; - } - } LogManager.Info($"已更新路径点列表,当前点数: {pathViewModel.Points.Count}"); - // 恢复选中状态(使用新的ViewModel实例) - if (hadSelection && newSelectedPoint != null) + if (SelectedPathRoute == pathViewModel) { - SelectedPathPoint = newSelectedPoint; - LogManager.Info($"[路径点列表更新] 恢复选中状态: {newSelectedPoint.Name}, 索引: {newSelectedPoint.Index}"); - } - else if (hadSelection && newSelectedPoint == null) - { - // 之前选中的点已被删除 - SelectedPathPoint = null; - LogManager.Info("[路径点列表更新] 之前选中的点已被删除,清空选中状态"); + RefreshHoistingLayerEditItemsFromSelectedRoute(); } // 更新真实路径可视化 @@ -4088,6 +6906,14 @@ namespace NavisworksTransport.UI.WPF.ViewModels var pathViewModel = PathRoutes.FirstOrDefault(p => p.Name == e.NewRoute.Name); if (pathViewModel != null) { + bool synced = SyncPathViewModelFromCoreRoute(pathViewModel, e.NewRoute, preserveSelection: true); + if (synced) + { + LogManager.Info($"当前路径切换时已同步路径点列表: {e.NewRoute.Name}, 点数: {pathViewModel.Points.Count}"); + } + + ApplyStoredAutoPlanningParameters(e.NewRoute); + // 只在真的不同时才设置,避免重复触发 if (SelectedPathRoute != pathViewModel) { @@ -4268,8 +7094,26 @@ namespace NavisworksTransport.UI.WPF.ViewModels } else if (e.GenerationMethod == RouteGenerationMethod.Manual) { - // 手工路径生成(如果需要特殊处理) - LogManager.Info($"手工路径生成完成: {e.Route.Name}"); + LogManager.Info($"手工路径生成完成: {e.Route.Name},开始刷新路径列表"); + var existingPath = PathRoutes.FirstOrDefault(p => p.Name == e.Route.Name); + if (existingPath == null) + { + existingPath = new PathRouteViewModel(isFromDatabase: true) + { + Route = e.Route, + IsActive = true + }; + PathRoutes.Add(existingPath); + LogManager.Info($"手工路径生成后补建UI路径壳: {e.Route.Name}"); + } + + SyncPathViewModelFromCoreRoute(existingPath, e.Route, preserveSelection: true); + SelectedPathRoute = existingPath; + LogManager.Info($"手工路径已同步并重新选中: {e.Route.Name},点数: {existingPath.Points.Count}"); + + OnPropertyChanged(nameof(PathRoutes)); + OnPropertyChanged(nameof(CanExecuteStartEdit)); + OnPropertyChanged(nameof(CanExecuteEndEdit)); } else if (e.GenerationMethod == RouteGenerationMethod.DatabaseLoad) { @@ -4306,8 +7150,6 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 处理从数据库加载的历史路径 foreach (var coreRoute in e.Routes) { - LogManager.Info($"*** 开始处理数据库加载路径: {coreRoute.Name} ***"); - // 创建对应的 WPF ViewModel var dbPathViewModel = new PathRouteViewModel(isFromDatabase: true) { @@ -4326,26 +7168,29 @@ namespace NavisworksTransport.UI.WPF.ViewModels dbPathViewModel.Points.Add(point); } - LogManager.Info($"*** 已创建PathRouteViewModel(数据库路径),包含 {dbPathViewModel.Points.Count} 个点 ***"); - // 检查是否已存在同名路径,避免重复添加 var existingPath = PathRoutes.FirstOrDefault(p => p.Name == coreRoute.Name); if (existingPath == null) { // 添加到 UI 列表 PathRoutes.Add(dbPathViewModel); - LogManager.Info($"*** 数据库路径已添加到UI列表: {coreRoute.Name},当前PathRoutes.Count = {PathRoutes.Count} ***"); } else { - LogManager.Info($"*** 数据库路径已存在,跳过: {coreRoute.Name} ***"); + LogManager.Debug($"*** 数据库路径已存在,跳过: {coreRoute.Name} ***"); } } + var currentRoute = _pathPlanningManager?.CurrentRoute; + if (currentRoute != null) + { + ApplyStoredAutoPlanningParameters(currentRoute); + LogManager.Info($"路径加载完成后已按当前路径同步自动规划参数: {currentRoute.Name}"); + } + // 更新状态消息 string statusMessage = $"✅ 已从{e.LoadSource}加载 {e.RouteCount} 条历史路径"; UpdateMainStatus(statusMessage); - LogManager.Info($"*** 状态已更新: {statusMessage} ***"); // 自动提取并显示所有空轨模型的基准线 ExtractAndRenderRailBaselinePaths(); @@ -4468,6 +7313,42 @@ namespace NavisworksTransport.UI.WPF.ViewModels LogManager.Warning($"清理自动路径事件订阅时发生异常: {ex.Message}"); } + try + { + CleanupAssemblyReferenceSelection(); + } + catch (Exception ex) + { + LogManager.Warning($"清理直线装配事件订阅时发生异常: {ex.Message}"); + } + + try + { + CleanupAssemblyEndFaceSelection(clearVisuals: true); + } + catch (Exception ex) + { + LogManager.Warning($"清理端面三点分析事件订阅时发生异常: {ex.Message}"); + } + + try + { + ClearAssemblyAnchorMarker(); + } + catch (Exception ex) + { + LogManager.Warning($"清理直线装配对接点标记时发生异常: {ex.Message}"); + } + + try + { + ClearAssemblyReferenceLine(); + } + catch (Exception ex) + { + LogManager.Warning($"清理直线装配参考线时发生异常: {ex.Message}"); + } + // 确保停止任何活动的点击工具 try { @@ -4536,4 +7417,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels public double Height { get; set; } public double SafetyMargin { get; set; } } -} \ No newline at end of file +} diff --git a/src/UI/WPF/ViewModels/RailAssemblyWorkflowContext.cs b/src/UI/WPF/ViewModels/RailAssemblyWorkflowContext.cs new file mode 100644 index 0000000..f5c3262 --- /dev/null +++ b/src/UI/WPF/ViewModels/RailAssemblyWorkflowContext.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Numerics; +using Autodesk.Navisworks.Api; + +namespace NavisworksTransport.UI.WPF.ViewModels +{ + internal enum RailAssemblyWorkflowMode + { + None, + CreateNewRailPath, + EditSelectedRail + } + + internal sealed class RailAssemblyWorkflowContext + { + private const string DefaultTerminalObjectName = "未选择"; + private const string DefaultTerminalObjectInfo = "请选择终点处已安装箱体"; + private const string DefaultAssemblyStartPointText = "未选择"; + + private readonly double _defaultReferenceRodLengthInMeters; + private readonly double _defaultReferenceRodDiameterInMeters; + private readonly double _defaultAnchorVerticalOffsetInMeters; + private readonly double _defaultSphereCenterX; + private readonly double _defaultSphereCenterY; + private readonly double _defaultSphereCenterZ; + + public RailAssemblyWorkflowContext( + double defaultReferenceRodLengthInMeters, + double defaultReferenceRodDiameterInMeters, + double defaultAnchorVerticalOffsetInMeters, + double defaultSphereCenterX, + double defaultSphereCenterY, + double defaultSphereCenterZ) + { + _defaultReferenceRodLengthInMeters = defaultReferenceRodLengthInMeters; + _defaultReferenceRodDiameterInMeters = defaultReferenceRodDiameterInMeters; + _defaultAnchorVerticalOffsetInMeters = defaultAnchorVerticalOffsetInMeters; + _defaultSphereCenterX = defaultSphereCenterX; + _defaultSphereCenterY = defaultSphereCenterY; + _defaultSphereCenterZ = defaultSphereCenterZ; + + TerminalObjectName = DefaultTerminalObjectName; + TerminalObjectInfo = DefaultTerminalObjectInfo; + ReferenceRodLengthInMeters = _defaultReferenceRodLengthInMeters; + ReferenceRodDiameterInMeters = _defaultReferenceRodDiameterInMeters; + AnchorVerticalOffsetInMeters = _defaultAnchorVerticalOffsetInMeters; + SphereCenterX = _defaultSphereCenterX; + SphereCenterY = _defaultSphereCenterY; + SphereCenterZ = _defaultSphereCenterZ; + StartPointText = DefaultAssemblyStartPointText; + MountMode = RailMountMode.UnderRail; + InstallationSeedPoints = new List(); + EndFaceSeedPoints = new List(); + } + + public RailAssemblyWorkflowMode WorkflowMode { get; set; } + + public string TerminalObjectName { get; set; } + + public string TerminalObjectInfo { get; set; } + + public double ReferenceRodLengthInMeters { get; set; } + + public double ReferenceRodDiameterInMeters { get; set; } + + public double AnchorVerticalOffsetInMeters { get; set; } + + public double SphereCenterX { get; set; } + + public double SphereCenterY { get; set; } + + public double SphereCenterZ { get; set; } + + public string StartPointText { get; set; } + + public RailMountMode MountMode { get; set; } + + public bool HasTerminalObject { get; set; } + + public bool IsSelectingStartPoint { get; set; } + + public bool IsSelectingEndFacePoints { get; set; } + + public bool IsSelectingInstallationPoint { get; set; } + + public bool HasEndFaceAnalysis { get; set; } + + public bool HasInstallationReference { get; set; } + + public ModelItem TerminalObject { get; set; } + + public Point3D StartPoint { get; set; } + + public Point3D EndFaceCenterPoint { get; set; } + + public Vector3 EndFaceNormal { get; set; } + + public Point3D InstallationPickPoint { get; set; } + + public Point3D InstallationBaseAnchorPoint { get; set; } + + public Point3D InstallationAnchorPoint { get; set; } + + public Vector3 InstallationPlaneNormal { get; set; } + + public Vector3 InstallationPlaneSpanDirection { get; set; } + + public double InstallationOffsetDistanceInMeters { get; set; } + + public string InstallationReferenceRouteId { get; set; } + + public List InstallationSeedPoints { get; } + + public List EndFaceSeedPoints { get; } + + public void ResetEndFaceAnalysis() + { + HasEndFaceAnalysis = false; + EndFaceCenterPoint = null; + EndFaceNormal = default(Vector3); + EndFaceSeedPoints.Clear(); + } + + public void ResetInstallationReference() + { + HasInstallationReference = false; + InstallationPickPoint = null; + InstallationBaseAnchorPoint = null; + InstallationAnchorPoint = null; + InstallationPlaneNormal = default(Vector3); + InstallationPlaneSpanDirection = default(Vector3); + InstallationOffsetDistanceInMeters = 0.0; + InstallationReferenceRouteId = null; + InstallationSeedPoints.Clear(); + AnchorVerticalOffsetInMeters = _defaultAnchorVerticalOffsetInMeters; + } + + public void ResetSession() + { + WorkflowMode = RailAssemblyWorkflowMode.None; + HasTerminalObject = false; + IsSelectingStartPoint = false; + IsSelectingEndFacePoints = false; + IsSelectingInstallationPoint = false; + TerminalObject = null; + StartPoint = Point3D.Origin; + TerminalObjectName = DefaultTerminalObjectName; + TerminalObjectInfo = DefaultTerminalObjectInfo; + StartPointText = DefaultAssemblyStartPointText; + ResetEndFaceAnalysis(); + ResetInstallationReference(); + } + } +} diff --git a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs index f24006e..e873b06 100644 --- a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs +++ b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs @@ -1,9 +1,15 @@ using System; using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Threading; +using Autodesk.Navisworks.Api; +using ComApiBridge = Autodesk.Navisworks.Api.ComApi.ComApiBridge; using NavisworksTransport.UI.WPF.Collections; using NavisworksTransport.Core; using NavisworksTransport.Core.Config; @@ -21,6 +27,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels #region 私有字段 private readonly UIStateManager _uiStateManager; + private readonly HashSet _documentRotationHintShown = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _documentRotationHintPending = new HashSet(StringComparer.OrdinalIgnoreCase); // 日志管理字段 private ObservableCollection _logLevels; @@ -174,10 +182,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels public ICommand TestVoxelPathFindingCommand { get; private set; } public ICommand ReadTransformTestCommand { get; private set; } public ICommand CoordinateSystemExplorerCommand { get; private set; } + public ICommand CaptureRealObjectTransformCommand { get; private set; } + public ICommand DetectFragmentDefaultUpCommand { get; private set; } // 坐标系设置 public ObservableCollection CoordinateSystemOptions { get; private set; } + public ObservableCollection FragmentDefaultUpAxisOptions { get; private set; } private string _selectedCoordinateSystem = "AutoDetect"; public string SelectedCoordinateSystem @@ -199,6 +210,21 @@ namespace NavisworksTransport.UI.WPF.ViewModels set => SetProperty(ref _currentCoordinateSystemInfo, value); } + private string _selectedFragmentDefaultUpAxis = "Y"; + public string SelectedFragmentDefaultUpAxis + { + get => _selectedFragmentDefaultUpAxis; + set + { + if (SetProperty(ref _selectedFragmentDefaultUpAxis, value)) + { + SaveCurrentDocumentFragmentDefaultUpAxis(value); + UpdateMainStatus($"当前文档 fragment 默认Up已设置为: {value}"); + LogManager.Info($"[fragment默认Up] 当前文档设置为: {value}"); + } + } + } + #endregion #region 构造函数 @@ -311,14 +337,18 @@ namespace NavisworksTransport.UI.WPF.ViewModels TestVoxelPathFindingCommand = new RelayCommand(() => ExecuteTestVoxelPathFinding()); ReadTransformTestCommand = new RelayCommand(() => ExecuteReadTransformTest()); CoordinateSystemExplorerCommand = new RelayCommand(() => ExecuteCoordinateSystemExplorer()); + CaptureRealObjectTransformCommand = new RelayCommand(() => ExecuteCaptureRealObjectTransform()); + DetectFragmentDefaultUpCommand = new RelayCommand(() => ExecuteDetectFragmentDefaultUp()); // 初始化坐标系选项 CoordinateSystemOptions = new ObservableCollection { "AutoDetect", "ZUp", "YUp" }; + FragmentDefaultUpAxisOptions = new ObservableCollection { "Y", "Z" }; // 从配置加载当前坐标系设置 var configType = ConfigManager.Instance.Current.CoordinateSystem?.Type ?? "AutoDetect"; _selectedCoordinateSystem = configType; + _selectedFragmentDefaultUpAxis = GetCurrentDocumentFragmentDefaultUpAxis(); // 订阅文档事件(用于自动检测坐标系) SubscribeToDocumentEvents(); @@ -389,6 +419,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels // 更新显示信息 UpdateCoordinateSystemInfo(); + ShowRootModelRotationHintIfNeeded(); LogManager.Info($"坐标系初始化完成: {configType}"); } @@ -436,6 +467,144 @@ namespace NavisworksTransport.UI.WPF.ViewModels { var info = CoordinateSystemManager.Instance.GetCurrentInfo(); CurrentCoordinateSystemInfo = $"当前坐标系: {info}"; + RefreshFragmentDefaultUpAxisSelectionFromCurrentDocument(); + } + + private void RefreshFragmentDefaultUpAxisSelectionFromCurrentDocument() + { + string resolvedAxis = FragmentDefaultUpContext.GetCurrentDocumentFragmentDefaultUpAxis(); + if (_selectedFragmentDefaultUpAxis != resolvedAxis) + { + SetProperty(ref _selectedFragmentDefaultUpAxis, resolvedAxis, nameof(SelectedFragmentDefaultUpAxis)); + } + } + + private string GetCurrentDocumentFragmentDefaultUpAxis() + { + return FragmentDefaultUpContext.GetCurrentDocumentFragmentDefaultUpAxis(); + } + + private void SaveCurrentDocumentFragmentDefaultUpAxis(string axisName) + { + FragmentDefaultUpContext.SetCurrentDocumentFragmentDefaultUpAxis(axisName); + } + + private string GetCurrentDocumentKey() + { + return FragmentDefaultUpContext.GetCurrentDocumentKey(); + } + + private string GetCurrentHostUpAxis() + { + return FragmentDefaultUpContext.GetCurrentHostUpAxis(); + } + + private static bool IsSupportedFragmentDefaultUpAxis(string axisName) + { + return FragmentDefaultUpContext.IsSupportedAxis(axisName); + } + + private void ShowRootModelRotationHintIfNeeded() + { + string documentKey = GetCurrentDocumentKey(); + if (string.IsNullOrWhiteSpace(documentKey) || + _documentRotationHintShown.Contains(documentKey) || + _documentRotationHintPending.Contains(documentKey)) + { + return; + } + + var doc = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (doc == null || doc.IsClear || doc.Models == null || doc.Models.Count == 0) + { + return; + } + + if (!TryGetRootModelRotationHint(doc, out string hintMessage)) + { + return; + } + + _documentRotationHintPending.Add(documentKey); + _ = Task.Run(async () => + { + try + { + await Task.Delay(1500).ConfigureAwait(false); + _uiStateManager?.QueueUIUpdate(() => + { + try + { + string currentDocumentKey = GetCurrentDocumentKey(); + if (!string.Equals(documentKey, currentDocumentKey, StringComparison.OrdinalIgnoreCase)) + { + _documentRotationHintPending.Remove(documentKey); + return; + } + + if (_documentRotationHintShown.Contains(documentKey)) + { + _documentRotationHintPending.Remove(documentKey); + return; + } + + _documentRotationHintShown.Add(documentKey); + _documentRotationHintPending.Remove(documentKey); + LogManager.Info($"[模型整体旋转提示] {hintMessage}"); + DialogHelper.ShowAutoClosingMessageBox( + hintMessage, + "坐标系提示", + System.Windows.MessageBoxImage.Information, + autoCloseMilliseconds: 4000); + } + catch (Exception ex) + { + _documentRotationHintPending.Remove(documentKey); + LogManager.Warning($"[模型整体旋转提示] 延后弹出失败: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + _documentRotationHintPending.Remove(documentKey); + LogManager.Warning($"[模型整体旋转提示] 延后任务失败: {ex.Message}"); + } + }); + } + + private static bool TryGetRootModelRotationHint(Document doc, out string hintMessage) + { + hintMessage = null; + if (doc == null || doc.Models == null || doc.Models.Count == 0) + { + return false; + } + + const double minAngleDegrees = 1.0; + + for (int i = 0; i < doc.Models.Count; i++) + { + Model model = doc.Models[i]; + ModelItem rootItem = model?.RootItem; + if (rootItem == null) + { + continue; + } + + Transform3DComponents components = rootItem.Transform.Factor(); + var axisAngle = components.Rotation.ToAxisAndAngle(); + double angleDegrees = Math.Abs(axisAngle.Angle) * 180.0 / Math.PI; + if (angleDegrees < minAngleDegrees) + { + continue; + } + + hintMessage = + $"检测到根模型存在整体旋转(约 {angleDegrees:F1}°),模型可能经过坐标系转换;如后续真实物体姿态异常,请检查 Fragment默认Up 设置。"; + return true; + } + + return false; } #region 文档事件处理 @@ -566,6 +735,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels } }); } + + _ = _uiStateManager.ExecuteUIUpdateAsync(() => + { + RefreshFragmentDefaultUpAxisSelectionFromCurrentDocument(); + }); } #endregion @@ -658,14 +832,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - // 使用单例模式显示配置编辑器(非模态,避免独占焦点) - // 不传递 owner,让 ShowEditor 自己通过 DialogHelper 处理 + // 使用模态配置编辑器,确保键盘输入不会被 Navisworks 主窗口截获 var dialog = NavisworksTransport.UI.WPF.Views.ConfigEditorDialog.ShowEditor(); if (dialog != null) { - UpdateMainStatus("配置编辑器已打开"); - LogManager.Info("配置编辑器:已打开(非模态)"); + UpdateMainStatus("配置编辑器已关闭"); + LogManager.Info("配置编辑器:已打开并关闭(模态)"); } else { @@ -849,7 +1022,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels } else { - DatabaseVersion = "未连接"; + DatabaseVersion = "未创建"; PathCount = "--"; DetectionRecordCount = "--"; } @@ -858,9 +1031,30 @@ namespace NavisworksTransport.UI.WPF.ViewModels { LogManager.Error($"刷新数据库状态失败: {ex.Message}"); DatabaseVersion = "错误"; + PathCount = "--"; + DetectionRecordCount = "--"; } } + private PathDatabase GetDatabaseForSystemManagement(string operationName) + { + var pathDatabase = PathPlanningManager.Instance?.GetPathDatabase(); + if (pathDatabase != null) + { + return pathDatabase; + } + + System.Windows.MessageBox.Show( + "当前文档还没有数据库文件。\n\n请先执行会产生持久化数据的操作(例如保存路径),或先恢复一个数据库备份,再使用这个功能。", + operationName, + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + UpdateMainStatus($"{operationName}未执行:当前文档没有数据库"); + LogManager.Info($"[{operationName}] 当前文档没有数据库,已阻止操作"); + RefreshDatabaseStatus(); + return null; + } + /// /// 执行数据备份 /// @@ -870,16 +1064,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - var pathDatabase = PathPlanningManager.Instance?.GetPathDatabase(); - if (pathDatabase == null) - { - System.Windows.MessageBox.Show( - "未找到数据库连接,请确保已加载模型", - "备份数据", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Warning); - return; - } + var pathDatabase = GetDatabaseForSystemManagement("备份数据"); + if (pathDatabase == null) return; UpdateMainStatus("正在备份数据库..."); LogManager.Info("开始数据库文件备份"); @@ -928,16 +1114,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - var pathDatabase = PathPlanningManager.Instance?.GetPathDatabase(); - if (pathDatabase == null) - { - System.Windows.MessageBox.Show( - "未找到数据库连接,请确保已加载模型", - "恢复数据", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Warning); - return; - } + var pathDatabase = GetDatabaseForSystemManagement("恢复数据"); + if (pathDatabase == null) return; // 选择备份文件 var openFileDialog = new Microsoft.Win32.OpenFileDialog @@ -1020,16 +1198,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - var pathDatabase = PathPlanningManager.Instance?.GetPathDatabase(); - if (pathDatabase == null) - { - System.Windows.MessageBox.Show( - "未找到数据库连接", - "修复数据库", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Warning); - return; - } + var pathDatabase = GetDatabaseForSystemManagement("修复数据库"); + if (pathDatabase == null) return; UpdateMainStatus("正在检查数据库..."); LogManager.Info("开始数据库修复检查"); @@ -1076,16 +1246,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { - var pathDatabase = PathPlanningManager.Instance?.GetPathDatabase(); - if (pathDatabase == null) - { - System.Windows.MessageBox.Show( - "未找到数据库连接", - "清空数据", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Warning); - return; - } + var pathDatabase = GetDatabaseForSystemManagement("清空数据"); + if (pathDatabase == null) return; // 双重确认 var result1 = System.Windows.MessageBox.Show( @@ -1315,6 +1477,553 @@ namespace NavisworksTransport.UI.WPF.ViewModels }, "体素路径规划测试"); } + /// + /// 捕获选中的真实物体,显示原始变换信息并可视化三轴。 + /// + private void ExecuteCaptureRealObjectTransform() + { + 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; + } + + var selection = doc.CurrentSelection.SelectedItems; + if (selection.Count == 0) + { + ClearCapturedRealObjectTransformVisualization(); + System.Windows.MessageBox.Show( + "请先选择一个真实物体!", + "提示", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + return; + } + + ModelItem item = selection[0]; + BoundingBox3D bounds = item.BoundingBox(); + Point3D boundsCenter = bounds.Center; + TransformDiagnosticInfo selectedInfo = CreateTransformDiagnosticInfo(item, "选中节点"); + List geometryDescendants = CollectGeometryDescendants(item); + TransformDiagnosticInfo geometryInfo = geometryDescendants.Count > 0 + ? CreateTransformDiagnosticInfo(geometryDescendants[0], "首个几何后代") + : null; + FragmentOrientationAnalysis fragmentAnalysis = AnalyzeFragmentOrientations(item); + + Quaternion visualizationQuaternion = fragmentAnalysis != null && fragmentAnalysis.HasRepresentativeOrientation + ? fragmentAnalysis.RepresentativeRotation + : geometryInfo?.RotationQuaternion ?? selectedInfo.RotationQuaternion; + + double axisLength = GetTransformAxisVisualizationLength(bounds); + RenderCapturedRealObjectTransformAxes(boundsCenter, visualizationQuaternion, axisLength); + + var info = new StringBuilder(); + info.AppendLine("=== 真实物体原始变换信息 ==="); + info.AppendLine(); + info.AppendLine($"对象: {item.DisplayName}"); + info.AppendLine($"ClassName: {item.ClassName}"); + info.AppendLine($"HasGeometry: {item.HasGeometry}"); + info.AppendLine(); + info.AppendLine("【包围盒】"); + info.AppendLine($"Min = ({bounds.Min.X:F3}, {bounds.Min.Y:F3}, {bounds.Min.Z:F3})"); + info.AppendLine($"Max = ({bounds.Max.X:F3}, {bounds.Max.Y:F3}, {bounds.Max.Z:F3})"); + info.AppendLine($"Center = ({boundsCenter.X:F3}, {boundsCenter.Y:F3}, {boundsCenter.Z:F3})"); + info.AppendLine(); + + AppendTransformDiagnosticInfo(info, selectedInfo); + + info.AppendLine("【几何后代】"); + info.AppendLine($"几何后代数量 = {geometryDescendants.Count}"); + if (geometryInfo != null) + { + AppendTransformDiagnosticInfo(info, geometryInfo); + } + else + { + info.AppendLine("未找到 HasGeometry=True 的后代节点"); + info.AppendLine(); + } + + AppendFragmentAnalysis(info, fragmentAnalysis); + info.AppendLine(); + info.AppendLine("【可视化说明】"); + info.AppendLine($"三轴原点使用包围盒中心: ({boundsCenter.X:F3}, {boundsCenter.Y:F3}, {boundsCenter.Z:F3})"); + info.AppendLine($"轴长度 = {axisLength:F3}"); + info.AppendLine($"三轴方向来源 = {(fragmentAnalysis != null && fragmentAnalysis.HasRepresentativeOrientation ? "fragment代表姿态" : geometryInfo != null ? "首个几何后代Transform" : "选中节点Transform")}"); + info.AppendLine("红色=X轴,绿色=Y轴,蓝色=Z轴"); + + string message = info.ToString(); + LogManager.Info("[真实物体原始位姿]\n" + message); + + var resultDialog = new NavisworksTransport.UI.WPF.Views.CoordinateSystemResultDialog + { + Title = "真实物体原始变换信息", + ResultText = message + }; + DialogHelper.SetOwnerSafely(resultDialog); + resultDialog.ShowDialog(); + + UpdateMainStatus($"已捕获真实物体原始位姿:{item.DisplayName}"); + }, "捕获真实物体原始位姿"); + } + + private void ExecuteDetectFragmentDefaultUp() + { + 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; + } + + var selection = doc.CurrentSelection.SelectedItems; + if (selection.Count == 0) + { + System.Windows.MessageBox.Show( + "请先选择一个明显水平放置的真实物体,再执行 fragment 默认Up检测。", + "提示", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + return; + } + + ModelItem item = selection[0]; + FragmentOrientationAnalysis fragmentAnalysis = AnalyzeFragmentOrientations(item); + if (fragmentAnalysis == null || !fragmentAnalysis.HasRepresentativeOrientation) + { + System.Windows.MessageBox.Show( + "未能读取选中物体的 fragment 代表姿态,无法检测默认Up。", + "提示", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Warning); + return; + } + + string hostUpAxis = GetCurrentHostUpAxis(); + if (!RealObjectReferencePoseResolver.TryDetectDefaultUpAxis( + fragmentAnalysis.ValidMatrices, + hostUpAxis, + out string detectedAxis, + out float yAlignment, + out float zAlignment)) + { + System.Windows.MessageBox.Show( + "未能从 fragment 姿态中检测默认Up。", + "提示", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Warning); + return; + } + string currentAxis = SelectedFragmentDefaultUpAxis; + LogManager.Info( + $"[fragment默认Up检测] 对象={item.DisplayName}, 宿主Up={hostUpAxis}, 当前值={currentAxis}, 检测值={detectedAxis}, " + + $"Y对齐度={yAlignment:F4}, Z对齐度={zAlignment:F4}"); + + if (string.Equals(detectedAxis, currentAxis, StringComparison.OrdinalIgnoreCase)) + { + System.Windows.MessageBox.Show( + $"检测完成:当前文档 Fragment默认Up 已是 {currentAxis}。", + "Fragment默认Up检测", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + UpdateMainStatus($"Fragment默认Up检测完成:当前值 {currentAxis} 已匹配"); + return; + } + + SetProperty(ref _selectedFragmentDefaultUpAxis, detectedAxis, nameof(SelectedFragmentDefaultUpAxis)); + SaveCurrentDocumentFragmentDefaultUpAxis(detectedAxis); + + System.Windows.MessageBox.Show( + $"检测完成:当前文档 Fragment默认Up 已自动改为 {detectedAxis}。", + "Fragment默认Up检测", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + + UpdateMainStatus($"当前文档 fragment 默认Up已自动更新为: {detectedAxis}"); + LogManager.Info($"[fragment默认Up] 检测后自动更新为: {detectedAxis}"); + }, "检测Fragment默认Up"); + } + + private void ClearCapturedRealObjectTransformVisualization() + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + renderPlugin.ClearRailBaseline("system_transform_axis_x"); + renderPlugin.ClearRailBaseline("system_transform_axis_y"); + renderPlugin.ClearRailBaseline("system_transform_axis_z"); + } + + private void RenderCapturedRealObjectTransformAxes(Point3D origin, Quaternion rotationQuaternion, double axisLength) + { + var renderPlugin = PathPointRenderPlugin.Instance; + if (renderPlugin == null) + { + return; + } + + ClearCapturedRealObjectTransformVisualization(); + + RenderTransformAxis(renderPlugin, "system_transform_axis_x", origin, Vector3.Transform(Vector3.UnitX, rotationQuaternion), axisLength, RenderStyleName.TransformAxisX); + RenderTransformAxis(renderPlugin, "system_transform_axis_y", origin, Vector3.Transform(Vector3.UnitY, rotationQuaternion), axisLength, RenderStyleName.TransformAxisY); + RenderTransformAxis(renderPlugin, "system_transform_axis_z", origin, Vector3.Transform(Vector3.UnitZ, rotationQuaternion), axisLength, RenderStyleName.TransformAxisZ); + } + + private static void RenderTransformAxis( + PathPointRenderPlugin renderPlugin, + string pathId, + Point3D origin, + Vector3 direction, + double axisLength, + RenderStyleName renderStyleName) + { + if (direction.LengthSquared() < 1e-8f) + { + return; + } + + Vector3 normalizedDirection = Vector3.Normalize(direction); + Point3D endPoint = new Point3D( + origin.X + normalizedDirection.X * (float)axisLength, + origin.Y + normalizedDirection.Y * (float)axisLength, + origin.Z + normalizedDirection.Z * (float)axisLength); + + renderPlugin.RenderRailBaseline( + pathId, + new System.Collections.Generic.List { origin, endPoint }, + renderStyleName); + } + + private static double GetTransformAxisVisualizationLength(BoundingBox3D bounds) + { + double xSpan = bounds.Max.X - bounds.Min.X; + double ySpan = bounds.Max.Y - bounds.Min.Y; + double zSpan = bounds.Max.Z - bounds.Min.Z; + double maxSpan = Math.Max(xSpan, Math.Max(ySpan, zSpan)); + double minLength = UnitsConverter.ConvertFromMeters(0.5); + return Math.Max(minLength, maxSpan * 0.3); + } + + private static Quaternion ToQuaternion(Rotation3D rotation) + { + var axisAngle = rotation.ToAxisAndAngle(); + if (axisAngle.Axis.IsZero || Math.Abs(axisAngle.Angle) < 1e-9) + { + return Quaternion.Identity; + } + + return Quaternion.Normalize(Quaternion.CreateFromAxisAngle( + new Vector3((float)axisAngle.Axis.X, (float)axisAngle.Axis.Y, (float)axisAngle.Axis.Z), + (float)axisAngle.Angle)); + } + + private static TransformDiagnosticInfo CreateTransformDiagnosticInfo(ModelItem item, string label) + { + Transform3DComponents components = item.Transform.Factor(); + var axisAngle = components.Rotation.ToAxisAndAngle(); + Quaternion rotationQuaternion = ToQuaternion(components.Rotation); + + return new TransformDiagnosticInfo + { + Label = label, + DisplayName = item.DisplayName, + ClassName = item.ClassName, + HasGeometry = item.HasGeometry, + Translation = components.Translation, + Scale = components.Scale, + RotationAxis = axisAngle.Axis, + RotationAngleRadians = axisAngle.Angle, + RotationQuaternion = rotationQuaternion, + HostXAxis = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, rotationQuaternion)), + HostYAxis = Vector3.Normalize(Vector3.Transform(Vector3.UnitY, rotationQuaternion)), + HostZAxis = Vector3.Normalize(Vector3.Transform(Vector3.UnitZ, rotationQuaternion)) + }; + } + + private static void AppendTransformDiagnosticInfo(StringBuilder info, TransformDiagnosticInfo diagnostic) + { + info.AppendLine($"【{diagnostic.Label}】"); + info.AppendLine($"对象 = {diagnostic.DisplayName}"); + info.AppendLine($"ClassName = {diagnostic.ClassName}"); + info.AppendLine($"HasGeometry = {diagnostic.HasGeometry}"); + info.AppendLine($"Translation = ({diagnostic.Translation.X:F3}, {diagnostic.Translation.Y:F3}, {diagnostic.Translation.Z:F3})"); + info.AppendLine($"Scale = ({diagnostic.Scale.X:F3}, {diagnostic.Scale.Y:F3}, {diagnostic.Scale.Z:F3})"); + info.AppendLine($"Rotation.Axis = ({diagnostic.RotationAxis.X:F4}, {diagnostic.RotationAxis.Y:F4}, {diagnostic.RotationAxis.Z:F4})"); + info.AppendLine($"Rotation.Angle = {diagnostic.RotationAngleRadians:F6} rad ({diagnostic.RotationAngleRadians * 180.0 / Math.PI:F2}°)"); + info.AppendLine($"X轴 = ({diagnostic.HostXAxis.X:F4}, {diagnostic.HostXAxis.Y:F4}, {diagnostic.HostXAxis.Z:F4})"); + info.AppendLine($"Y轴 = ({diagnostic.HostYAxis.X:F4}, {diagnostic.HostYAxis.Y:F4}, {diagnostic.HostYAxis.Z:F4})"); + info.AppendLine($"Z轴 = ({diagnostic.HostZAxis.X:F4}, {diagnostic.HostZAxis.Y:F4}, {diagnostic.HostZAxis.Z:F4})"); + info.AppendLine(); + } + + private static List CollectGeometryDescendants(ModelItem root) + { + var result = new List(); + if (root == null) + { + return result; + } + + CollectGeometryDescendantsRecursive(root, result); + return result; + } + + private static void CollectGeometryDescendantsRecursive(ModelItem item, List result) + { + if (item == null) + { + return; + } + + if (item.HasGeometry) + { + result.Add(item); + } + + foreach (ModelItem child in item.Children) + { + CollectGeometryDescendantsRecursive(child, result); + } + } + + private static FragmentOrientationAnalysis AnalyzeFragmentOrientations(ModelItem item) + { + if (item == null) + { + return null; + } + + Autodesk.Navisworks.Api.ModelItemCollection collection = new Autodesk.Navisworks.Api.ModelItemCollection { item }; + var comSelection = ComApiBridge.ToInwOpSelection(collection); + List fragments = null; + try + { + fragments = GeometryHelper.GetAllFragments(comSelection); + var validMatrices = fragments + .Where(f => f.TransformMatrix != null && f.TransformMatrix.Length == 16) + .Select(f => f.TransformMatrix) + .ToList(); + + var analysis = new FragmentOrientationAnalysis + { + FragmentCount = fragments.Count, + ValidTransformCount = validMatrices.Count, + ValidMatrices = validMatrices + }; + + if (validMatrices.Count == 0) + { + return analysis; + } + + Vector3 referenceX = NormalizeOrUnitX(GetMatrixAxis(validMatrices[0], 0)); + Vector3 referenceY = NormalizeOrUnitY(GetMatrixAxis(validMatrices[0], 1)); + Vector3 referenceZ = NormalizeOrUnitZ(GetMatrixAxis(validMatrices[0], 2)); + + Vector3 sumX = referenceX; + Vector3 sumY = referenceY; + Vector3 sumZ = referenceZ; + double xDotSum = 1.0; + double yDotSum = 1.0; + double zDotSum = 1.0; + + for (int i = 1; i < validMatrices.Count; i++) + { + Vector3 axisX = NormalizeWithReference(GetMatrixAxis(validMatrices[i], 0), referenceX); + Vector3 axisY = NormalizeWithReference(GetMatrixAxis(validMatrices[i], 1), referenceY); + Vector3 axisZ = NormalizeWithReference(GetMatrixAxis(validMatrices[i], 2), referenceZ); + + xDotSum += Math.Abs(Vector3.Dot(axisX, referenceX)); + yDotSum += Math.Abs(Vector3.Dot(axisY, referenceY)); + zDotSum += Math.Abs(Vector3.Dot(axisZ, referenceZ)); + + sumX += axisX; + sumY += axisY; + sumZ += axisZ; + } + + Vector3 representativeX = NormalizeOrUnitX(sumX); + Vector3 representativeY = sumY - Vector3.Dot(sumY, representativeX) * representativeX; + representativeY = NormalizeOrUnitY(representativeY); + Vector3 representativeZ = Vector3.Normalize(Vector3.Cross(representativeX, representativeY)); + if (Vector3.Dot(representativeZ, NormalizeOrUnitZ(sumZ)) < 0) + { + representativeZ = -representativeZ; + } + representativeY = Vector3.Normalize(Vector3.Cross(representativeZ, representativeX)); + + Matrix4x4 linear = new Matrix4x4( + representativeX.X, representativeY.X, representativeZ.X, 0f, + representativeX.Y, representativeY.Y, representativeZ.Y, 0f, + representativeX.Z, representativeY.Z, representativeZ.Z, 0f, + 0f, 0f, 0f, 1f); + + analysis.HasRepresentativeOrientation = true; + analysis.RepresentativeRotation = Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(linear)); + analysis.RepresentativeXAxis = representativeX; + analysis.RepresentativeYAxis = representativeY; + analysis.RepresentativeZAxis = representativeZ; + analysis.AverageXAxisConsistency = xDotSum / validMatrices.Count; + analysis.AverageYAxisConsistency = yDotSum / validMatrices.Count; + analysis.AverageZAxisConsistency = zDotSum / validMatrices.Count; + analysis.SampleTranslation = GetMatrixTranslation(validMatrices[0]); + return analysis; + } + finally + { + if (fragments != null) + { + foreach (var fragment in fragments) + { + if (fragment?.Fragment != null) + { + try + { + Marshal.ReleaseComObject(fragment.Fragment); + } + catch + { + } + } + } + } + + if (comSelection != null) + { + try + { + Marshal.ReleaseComObject(comSelection); + } + catch + { + } + } + } + } + + private static void AppendFragmentAnalysis(StringBuilder info, FragmentOrientationAnalysis analysis) + { + info.AppendLine("【Fragment 姿态分析】"); + if (analysis == null) + { + info.AppendLine("未能执行 fragment 分析"); + info.AppendLine(); + return; + } + + info.AppendLine($"Fragment 总数 = {analysis.FragmentCount}"); + info.AppendLine($"有效变换矩阵数 = {analysis.ValidTransformCount}"); + if (!analysis.HasRepresentativeOrientation) + { + info.AppendLine("未得到可用的 fragment 代表姿态"); + info.AppendLine(); + return; + } + + info.AppendLine($"示例平移 = ({analysis.SampleTranslation.X:F3}, {analysis.SampleTranslation.Y:F3}, {analysis.SampleTranslation.Z:F3})"); + info.AppendLine($"代表X轴 = ({analysis.RepresentativeXAxis.X:F4}, {analysis.RepresentativeXAxis.Y:F4}, {analysis.RepresentativeXAxis.Z:F4})"); + info.AppendLine($"代表Y轴 = ({analysis.RepresentativeYAxis.X:F4}, {analysis.RepresentativeYAxis.Y:F4}, {analysis.RepresentativeYAxis.Z:F4})"); + info.AppendLine($"代表Z轴 = ({analysis.RepresentativeZAxis.X:F4}, {analysis.RepresentativeZAxis.Y:F4}, {analysis.RepresentativeZAxis.Z:F4})"); + info.AppendLine($"X轴一致性 = {analysis.AverageXAxisConsistency:F4}"); + info.AppendLine($"Y轴一致性 = {analysis.AverageYAxisConsistency:F4}"); + info.AppendLine($"Z轴一致性 = {analysis.AverageZAxisConsistency:F4}"); + info.AppendLine(); + } + + private static Vector3 GetMatrixAxis(double[] matrix, int axisIndex) + { + switch (axisIndex) + { + case 0: + return new Vector3((float)matrix[0], (float)matrix[1], (float)matrix[2]); + case 1: + return new Vector3((float)matrix[4], (float)matrix[5], (float)matrix[6]); + case 2: + return new Vector3((float)matrix[8], (float)matrix[9], (float)matrix[10]); + default: + throw new ArgumentOutOfRangeException(nameof(axisIndex)); + } + } + + private static Vector3 GetMatrixTranslation(double[] matrix) + { + return new Vector3((float)matrix[12], (float)matrix[13], (float)matrix[14]); + } + + private static Vector3 NormalizeWithReference(Vector3 axis, Vector3 reference) + { + Vector3 normalized = NormalizeOrFallback(axis, reference); + return Vector3.Dot(normalized, reference) < 0 ? -normalized : normalized; + } + + private static Vector3 NormalizeOrUnitX(Vector3 value) + { + return NormalizeOrFallback(value, Vector3.UnitX); + } + + private static Vector3 NormalizeOrUnitY(Vector3 value) + { + return NormalizeOrFallback(value, Vector3.UnitY); + } + + private static Vector3 NormalizeOrUnitZ(Vector3 value) + { + return NormalizeOrFallback(value, Vector3.UnitZ); + } + + private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback) + { + return value.LengthSquared() < 1e-8f ? fallback : Vector3.Normalize(value); + } + + private sealed class TransformDiagnosticInfo + { + public string Label { get; set; } + public string DisplayName { get; set; } + public string ClassName { get; set; } + public bool HasGeometry { get; set; } + public Vector3D Translation { get; set; } + public Vector3D Scale { get; set; } + public Vector3D RotationAxis { get; set; } + public double RotationAngleRadians { get; set; } + public Quaternion RotationQuaternion { get; set; } + public Vector3 HostXAxis { get; set; } + public Vector3 HostYAxis { get; set; } + public Vector3 HostZAxis { get; set; } + } + + private sealed class FragmentOrientationAnalysis + { + public int FragmentCount { get; set; } + public int ValidTransformCount { get; set; } + public List ValidMatrices { get; set; } + public bool HasRepresentativeOrientation { get; set; } + public Quaternion RepresentativeRotation { get; set; } + public Vector3 RepresentativeXAxis { get; set; } + public Vector3 RepresentativeYAxis { get; set; } + public Vector3 RepresentativeZAxis { get; set; } + public double AverageXAxisConsistency { get; set; } + public double AverageYAxisConsistency { get; set; } + public double AverageZAxisConsistency { get; set; } + public Vector3 SampleTranslation { get; set; } + } + /// /// 读取选中对象的Transform信息,并测试旋转 /// @@ -1864,4 +2573,4 @@ namespace NavisworksTransport.UI.WPF.ViewModels #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/Views/AnimationControlView.xaml b/src/UI/WPF/Views/AnimationControlView.xaml index 0dee544..eb9efcb 100644 --- a/src/UI/WPF/Views/AnimationControlView.xaml +++ b/src/UI/WPF/Views/AnimationControlView.xaml @@ -27,6 +27,7 @@ NavisworksTransport 检测动画页签视图 - 采用与类别设置和分层管 + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + public partial class CollisionAnalysisDialog : Window { + public enum CollisionAnalysisAction + { + None, + Continue, + Regenerate, + Cancel + } + private List _viewModels; public List ExcludedObjects { get; private set; } + public CollisionAnalysisAction SelectedAction { get; private set; } = CollisionAnalysisAction.None; // 使用项目中定义的预计算碰撞结果高亮类别(紫色 #9C27B0) private const string PrecomputedHighlightCategory = ModelHighlightHelper.PrecomputeCollisionResultsCategory; @@ -25,6 +36,7 @@ namespace NavisworksTransport.UI.WPF.Views { InitializeComponent(); ExcludedObjects = new List(); + Loaded += CollisionAnalysisDialog_Loaded; // 更新统计信息 UpdateStatsText(hotspots.Count, totalCollisions); @@ -53,6 +65,27 @@ namespace NavisworksTransport.UI.WPF.Views LogManager.Info($"[碰撞分析] 自动选中 {autoSelectedCount}/{viewModels.Count} 个高频碰撞物体(>100次或>50%)"); } + private void CollisionAnalysisDialog_Loaded(object sender, RoutedEventArgs e) + { + if (!TestAutomationHttpService.ShouldAutoConfirmCollisionAnalysisDialogs) + { + return; + } + + Dispatcher.BeginInvoke(new Action(() => + { + if (!IsLoaded || !IsVisible) + { + return; + } + + LogManager.Info("[碰撞分析] 测试自动化已接管分析窗口,自动选择继续生成"); + ModelHighlightHelper.ClearCategory(PrecomputedHighlightCategory); + SelectedAction = CollisionAnalysisAction.Continue; + Close(); + }), DispatcherPriority.Background); + } + /// /// 更新统计文本 /// @@ -122,17 +155,14 @@ namespace NavisworksTransport.UI.WPF.Views // 清除预计算高亮 ModelHighlightHelper.ClearCategory(PrecomputedHighlightCategory); - // 根据是否有排除对象设置对话框结果 if (ExcludedObjects.Count > 0) { - // 有排除对象,需要重新生成 - DialogResult = true; + SelectedAction = CollisionAnalysisAction.Regenerate; LogManager.Info($"[碰撞分析] 用户选择排除 {ExcludedObjects.Count} 个物体并重新生成"); } else { - // 没有排除对象,直接继续 - DialogResult = false; + SelectedAction = CollisionAnalysisAction.Continue; LogManager.Info("[碰撞分析] 用户选择直接继续生成(无排除对象)"); } @@ -144,10 +174,36 @@ namespace NavisworksTransport.UI.WPF.Views // 清除预计算高亮 ModelHighlightHelper.ClearCategory(PrecomputedHighlightCategory); - DialogResult = null; + SelectedAction = CollisionAnalysisAction.Cancel; Close(); } + private void SelectAllButton_Click(object sender, RoutedEventArgs e) + { + if (_viewModels == null) + { + return; + } + + foreach (var viewModel in _viewModels) + { + viewModel.IsExcluded = true; + } + } + + private void ClearAllButton_Click(object sender, RoutedEventArgs e) + { + if (_viewModels == null) + { + return; + } + + foreach (var viewModel in _viewModels) + { + viewModel.IsExcluded = false; + } + } + /// /// 视图模型 /// diff --git a/src/UI/WPF/Views/CollisionReportDialog.xaml b/src/UI/WPF/Views/CollisionReportDialog.xaml index 30a6adb..807a5bf 100644 --- a/src/UI/WPF/Views/CollisionReportDialog.xaml +++ b/src/UI/WPF/Views/CollisionReportDialog.xaml @@ -17,7 +17,7 @@ NavisworksTransport 碰撞检测报告对话框 - 采用与主界面一致的Nav xmlns:local="clr-namespace:NavisworksTransport.UI.WPF.Converters" mc:Ignorable="d" Title="碰撞检测报告" - Height="1080" + Height="960" Width="900" ResizeMode="CanResize" WindowStartupLocation="CenterOwner" diff --git a/src/UI/WPF/Views/CollisionReportDialog.xaml.cs b/src/UI/WPF/Views/CollisionReportDialog.xaml.cs index 7515ab0..21c60be 100644 --- a/src/UI/WPF/Views/CollisionReportDialog.xaml.cs +++ b/src/UI/WPF/Views/CollisionReportDialog.xaml.cs @@ -94,11 +94,8 @@ namespace NavisworksTransport.UI.WPF.Views { try { - LogManager.Info("用户关闭碰撞报告对话框,正在自动导出报告..."); - - // 关闭前自动导出报告 + LogManager.Info("用户关闭碰撞报告对话框,正在自动导出最终报告..."); await AutoExportReportAsync(); - this.Close(); } catch (Exception ex) @@ -107,24 +104,6 @@ namespace NavisworksTransport.UI.WPF.Views } } - /// - /// 自动导出报告 - /// - private async System.Threading.Tasks.Task AutoExportReportAsync() - { - try - { - if (_viewModel != null) - { - await _viewModel.AutoExportReportAsync(); - } - } - catch (Exception ex) - { - LogManager.Error($"自动导出报告失败: {ex.Message}"); - } - } - /// /// 窗口关闭事件 /// @@ -169,6 +148,14 @@ namespace NavisworksTransport.UI.WPF.Views } } + private async System.Threading.Tasks.Task AutoExportReportAsync() + { + if (_viewModel != null) + { + await _viewModel.AutoExportReportAsync(); + } + } + /// /// 窗口加载事件 /// @@ -470,4 +457,4 @@ namespace NavisworksTransport.UI.WPF.Views #endregion } -} \ No newline at end of file +} diff --git a/src/UI/WPF/Views/ConfigEditorDialog.xaml.cs b/src/UI/WPF/Views/ConfigEditorDialog.xaml.cs index f8b3638..73e6d56 100644 --- a/src/UI/WPF/Views/ConfigEditorDialog.xaml.cs +++ b/src/UI/WPF/Views/ConfigEditorDialog.xaml.cs @@ -2,13 +2,15 @@ using System; using System.IO; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; using System.Diagnostics; using NavisworksTransport.Core.Config; namespace NavisworksTransport.UI.WPF.Views { /// - /// 配置编辑器对话框 - 非模态窗口,避免独占焦点 + /// 配置编辑器对话框 /// public partial class ConfigEditorDialog : Window { @@ -297,7 +299,7 @@ namespace NavisworksTransport.UI.WPF.Views } /// - /// 窗口加载事件 - 处理激活逻辑避免独占焦点 + /// 窗口初始化后,确保获得稳定的键盘焦点 /// protected override void OnSourceInitialized(EventArgs e) { @@ -305,11 +307,10 @@ namespace NavisworksTransport.UI.WPF.Views try { - // 激活并前置窗口,但不独占焦点 - // 先置顶再取消置顶,让窗口前置但不系统级置顶 this.Activate(); this.Topmost = true; this.Topmost = false; + Dispatcher.BeginInvoke(new Action(FocusEditorTextBox), DispatcherPriority.Input); LogManager.Debug("配置编辑器对话框已激活并前置显示"); } @@ -319,6 +320,15 @@ namespace NavisworksTransport.UI.WPF.Views } } + /// + /// 窗口内容渲染完成后再次请求焦点,避免宿主窗口抢占键盘 + /// + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + Dispatcher.BeginInvoke(new Action(FocusEditorTextBox), DispatcherPriority.Input); + } + /// /// 激活并显示已存在的窗口 /// @@ -332,10 +342,10 @@ namespace NavisworksTransport.UI.WPF.Views this.WindowState = WindowState.Normal; } - // 激活并前置窗口(先置顶再取消置顶,避免独占焦点) this.Activate(); this.Topmost = true; this.Topmost = false; + FocusEditorTextBox(); LogManager.Info("配置编辑器对话框已激活并前置显示"); } @@ -345,10 +355,29 @@ namespace NavisworksTransport.UI.WPF.Views } } + private void FocusEditorTextBox() + { + try + { + if (ConfigContentTextBox == null) + { + return; + } + + ConfigContentTextBox.Focus(); + Keyboard.Focus(ConfigContentTextBox); + ConfigContentTextBox.CaretIndex = ConfigContentTextBox.Text?.Length ?? 0; + } + catch (Exception ex) + { + LogManager.Debug($"配置编辑器焦点设置失败: {ex.Message}"); + } + } + #region 静态工厂方法 /// - /// 创建并显示配置编辑器对话框(单例模式,非模态) + /// 创建并显示配置编辑器对话框(单例模式,模态) /// /// 对话框实例 public static ConfigEditorDialog ShowEditor() @@ -385,16 +414,19 @@ namespace NavisworksTransport.UI.WPF.Views // 创建新的对话框实例 var dialog = new ConfigEditorDialog(); - // 使用 DialogHelper 安全地设置 owner - NavisworksTransport.Utils.DialogHelper.SetOwnerSafely(dialog); + // 优先设置 WPF Owner,失败时回退到 Navisworks 主窗口句柄 + if (!NavisworksTransport.Utils.DialogHelper.SetOwnerSafely(dialog)) + { + NavisworksTransport.Utils.DialogHelper.SetWin32Owner(dialog); + } // 设置为当前实例 _currentInstance = dialog; - // 使用 Show() 非模态显示,避免独占焦点 - dialog.Show(); + // 使用 ShowDialog() 确保键盘输入不会被宿主窗口吃掉 + dialog.ShowDialog(); - LogManager.Info("新的配置编辑器对话框已显示(非模态)"); + LogManager.Info("新的配置编辑器对话框已显示(模态)"); return dialog; } } diff --git a/src/UI/WPF/Views/CoordinateSystemResultDialog.xaml.cs b/src/UI/WPF/Views/CoordinateSystemResultDialog.xaml.cs index 6b3a7ff..9963c70 100644 --- a/src/UI/WPF/Views/CoordinateSystemResultDialog.xaml.cs +++ b/src/UI/WPF/Views/CoordinateSystemResultDialog.xaml.cs @@ -27,15 +27,12 @@ namespace NavisworksTransport.UI.WPF.Views /// private void CopyButton_Click(object sender, RoutedEventArgs e) { - try + if (ClipboardHelper.TrySetText(ResultTextBox.Text, "捕获真实物体位姿")) { - Clipboard.SetText(ResultTextBox.Text); - MessageBox.Show("内容已复制到剪贴板!", "复制成功", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (System.Exception ex) - { - MessageBox.Show($"复制失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + return; } + + MessageBox.Show("复制到剪贴板失败,请稍后重试。", "错误", MessageBoxButton.OK, MessageBoxImage.Error); } /// diff --git a/src/UI/WPF/Views/EditCoordinatesWindow.xaml b/src/UI/WPF/Views/EditCoordinatesWindow.xaml index d952326..931d488 100644 --- a/src/UI/WPF/Views/EditCoordinatesWindow.xaml +++ b/src/UI/WPF/Views/EditCoordinatesWindow.xaml @@ -1,6 +1,7 @@  + @@ -63,7 +65,8 @@ + + private void OnCopyClick(object sender, RoutedEventArgs e) + { + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", X, Y, Z); + bool success = ClipboardHelper.TrySetText(coordinate, "编辑路径点坐标"); + ShowButtonFeedback(CopyButton, success); + } + /// /// 粘贴坐标 /// @@ -62,14 +79,14 @@ namespace NavisworksTransport.UI.WPF.Views string text = Clipboard.GetText().Trim(); string[] parts = text.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && - double.TryParse(parts[0], out double x) && - double.TryParse(parts[1], out double y) && - double.TryParse(parts[2], out double z)) + if (parts.Length >= 3 && + EditableNumberConverter.TryParseNumber(parts[0], System.Globalization.CultureInfo.CurrentCulture, typeof(double), out object xValue) && + EditableNumberConverter.TryParseNumber(parts[1], System.Globalization.CultureInfo.CurrentCulture, typeof(double), out object yValue) && + EditableNumberConverter.TryParseNumber(parts[2], System.Globalization.CultureInfo.CurrentCulture, typeof(double), out object zValue)) { - X = x; - Y = y; - Z = z; + X = (double)xValue; + Y = (double)yValue; + Z = (double)zValue; // 刷新绑定 DataContext = null; DataContext = this; @@ -118,5 +135,41 @@ namespace NavisworksTransport.UI.WPF.Views }; timer.Start(); } + + private bool TryCommitInputValues() + { + return TryUpdateNumberTextBox(XTextBox, "请输入有效的 X 坐标。") && + TryUpdateNumberTextBox(YTextBox, "请输入有效的 Y 坐标。") && + TryUpdateNumberTextBox(ZTextBox, "请输入有效的 Z 坐标。"); + } + + private static bool TryUpdateNumberTextBox(TextBox textBox, string message) + { + BindingExpression binding = textBox.GetBindingExpression(TextBox.TextProperty); + + if (EditableNumberConverter.IsIntermediateText(textBox.Text) || + !EditableNumberConverter.TryParseNumber(textBox.Text.Trim(), System.Globalization.CultureInfo.CurrentCulture, typeof(double), out _)) + { + ShowInputError(message, textBox); + return false; + } + + binding?.UpdateSource(); + + if (Validation.GetHasError(textBox)) + { + ShowInputError(message, textBox); + return false; + } + + return true; + } + + private static void ShowInputError(string message, TextBox textBox) + { + MessageBox.Show(message, "输入错误", MessageBoxButton.OK, MessageBoxImage.Warning); + textBox.Focus(); + textBox.SelectAll(); + } } } diff --git a/src/UI/WPF/Views/EditRotationWindow.xaml b/src/UI/WPF/Views/EditRotationWindow.xaml index f23922e..de7a01f 100644 --- a/src/UI/WPF/Views/EditRotationWindow.xaml +++ b/src/UI/WPF/Views/EditRotationWindow.xaml @@ -1,7 +1,8 @@  + @@ -25,24 +27,26 @@ - - + + - - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + @@ -155,7 +155,7 @@ - + @@ -165,7 +165,7 @@ - + @@ -175,7 +175,7 @@ - + @@ -183,7 +183,7 @@ x:Name="CopyTopButton" Style="{StaticResource IconButtonStyle}" Click="OnCopyTopCenterClick" - ToolTip="复制顶面中心点坐标" + ToolTip="复制宿主包围盒顶面中心点坐标" Margin="3,0,0,0"> - - - + + + @@ -208,7 +208,7 @@ - + @@ -218,7 +218,7 @@ - + @@ -228,7 +228,7 @@ - + @@ -236,13 +236,111 @@ x:Name="CopyBottomButton" Style="{StaticResource IconButtonStyle}" Click="OnCopyBottomCenterClick" - ToolTip="复制底面中心点坐标" + ToolTip="复制宿主包围盒底面中心点坐标" Margin="3,0,0,0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UI/WPF/Views/ModelItemBoundsWindow.xaml.cs b/src/UI/WPF/Views/ModelItemBoundsWindow.xaml.cs index cd83161..b833bb8 100644 --- a/src/UI/WPF/Views/ModelItemBoundsWindow.xaml.cs +++ b/src/UI/WPF/Views/ModelItemBoundsWindow.xaml.cs @@ -4,6 +4,8 @@ using System.Windows.Controls; using System.Windows.Media; using System.Windows.Threading; using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.UI.WPF.Views { @@ -35,23 +37,41 @@ namespace NavisworksTransport.UI.WPF.Views /// 中心点Z坐标 public double CenterZ { get; set; } - /// 顶面中心点X坐标 - public double TopCenterX { get; set; } + /// 宿主包围盒顶面中心点X坐标 + public double HostTopCenterX { get; set; } - /// 顶面中心点Y坐标 - public double TopCenterY { get; set; } + /// 宿主包围盒顶面中心点Y坐标 + public double HostTopCenterY { get; set; } - /// 顶面中心点Z坐标 - public double TopCenterZ { get; set; } + /// 宿主包围盒顶面中心点Z坐标 + public double HostTopCenterZ { get; set; } - /// 底面中心点X坐标 - public double BottomCenterX { get; set; } + /// 宿主包围盒底面中心点X坐标 + public double HostBottomCenterX { get; set; } - /// 底面中心点Y坐标 - public double BottomCenterY { get; set; } + /// 宿主包围盒底面中心点Y坐标 + public double HostBottomCenterY { get; set; } - /// 底面中心点Z坐标 - public double BottomCenterZ { get; set; } + /// 宿主包围盒底面中心点Z坐标 + public double HostBottomCenterZ { get; set; } + + /// 对象姿态语义顶面中心点X坐标 + public double OrientedTopCenterX { get; set; } + + /// 对象姿态语义顶面中心点Y坐标 + public double OrientedTopCenterY { get; set; } + + /// 对象姿态语义顶面中心点Z坐标 + public double OrientedTopCenterZ { get; set; } + + /// 对象姿态语义底面中心点X坐标 + public double OrientedBottomCenterX { get; set; } + + /// 对象姿态语义底面中心点Y坐标 + public double OrientedBottomCenterY { get; set; } + + /// 对象姿态语义底面中心点Z坐标 + public double OrientedBottomCenterZ { get; set; } #endregion @@ -127,15 +147,36 @@ namespace NavisworksTransport.UI.WPF.Views CenterY = (bounds.Min.Y + bounds.Max.Y) / 2.0; CenterZ = (bounds.Min.Z + bounds.Max.Z) / 2.0; - // 计算顶面中心点(Z最大) - TopCenterX = CenterX; - TopCenterY = CenterY; - TopCenterZ = bounds.Max.Z; + HostCoordinateAdapter adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); - // 计算底面中心点(Z最小) - BottomCenterX = CenterX; - BottomCenterY = CenterY; - BottomCenterZ = bounds.Min.Z; + Point3D hostBottomCenter = ModelItemTransformHelper.GetHostBottomAnchorPoint(bounds, adapter); + double hostHeight = ModelItemTransformHelper.GetHostHeight(bounds, adapter); + Point3D hostTopCenter = new Point3D( + hostBottomCenter.X + adapter.HostUpVector.X * hostHeight, + hostBottomCenter.Y + adapter.HostUpVector.Y * hostHeight, + hostBottomCenter.Z + adapter.HostUpVector.Z * hostHeight); + + HostBottomCenterX = hostBottomCenter.X; + HostBottomCenterY = hostBottomCenter.Y; + HostBottomCenterZ = hostBottomCenter.Z; + HostTopCenterX = hostTopCenter.X; + HostTopCenterY = hostTopCenter.Y; + HostTopCenterZ = hostTopCenter.Z; + + Point3D orientedBottomCenter = ModelItemTransformHelper.GetStableBottomAnchorPoint(bounds, item.Transform); + Vector3D objectUp = ModelItemTransformHelper.GetUpDirectionFromTransform(item.Transform); + Vector3D localSize = ModelItemTransformHelper.EstimateLocalBoxSize(bounds, item.Transform); + Point3D orientedTopCenter = new Point3D( + orientedBottomCenter.X + objectUp.X * localSize.Z, + orientedBottomCenter.Y + objectUp.Y * localSize.Z, + orientedBottomCenter.Z + objectUp.Z * localSize.Z); + + OrientedBottomCenterX = orientedBottomCenter.X; + OrientedBottomCenterY = orientedBottomCenter.Y; + OrientedBottomCenterZ = orientedBottomCenter.Z; + OrientedTopCenterX = orientedTopCenter.X; + OrientedTopCenterY = orientedTopCenter.Y; + OrientedTopCenterZ = orientedTopCenter.Z; } catch (Exception ex) { @@ -157,8 +198,10 @@ namespace NavisworksTransport.UI.WPF.Views { SizeX = SizeY = SizeZ = 0; CenterX = CenterY = CenterZ = 0; - TopCenterX = TopCenterY = TopCenterZ = 0; - BottomCenterX = BottomCenterY = BottomCenterZ = 0; + HostTopCenterX = HostTopCenterY = HostTopCenterZ = 0; + HostBottomCenterX = HostBottomCenterY = HostBottomCenterZ = 0; + OrientedTopCenterX = OrientedTopCenterY = OrientedTopCenterZ = 0; + OrientedBottomCenterX = OrientedBottomCenterY = OrientedBottomCenterZ = 0; } /// @@ -192,16 +235,9 @@ namespace NavisworksTransport.UI.WPF.Views /// private void OnCopyCenterClick(object sender, RoutedEventArgs e) { - try - { - string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", CenterX, CenterY, CenterZ); - System.Windows.Clipboard.SetText(coordinate); - ShowButtonFeedback(CopyCenterButton); - } - catch (Exception ex) - { - LogManager.Debug($"[坐标窗口] 复制到剪贴板失败: {ex.Message}"); - } + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", CenterX, CenterY, CenterZ); + bool success = ClipboardHelper.TrySetText(coordinate, "元素包围盒信息"); + ShowButtonFeedback(CopyCenterButton, success); } /// @@ -209,16 +245,9 @@ namespace NavisworksTransport.UI.WPF.Views /// private void OnCopyTopCenterClick(object sender, RoutedEventArgs e) { - try - { - string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", TopCenterX, TopCenterY, TopCenterZ); - System.Windows.Clipboard.SetText(coordinate); - ShowButtonFeedback(CopyTopButton); - } - catch (Exception ex) - { - LogManager.Debug($"[坐标窗口] 复制到剪贴板失败: {ex.Message}"); - } + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", HostTopCenterX, HostTopCenterY, HostTopCenterZ); + bool success = ClipboardHelper.TrySetText(coordinate, "元素包围盒信息"); + ShowButtonFeedback(CopyTopButton, success); } /// @@ -226,33 +255,46 @@ namespace NavisworksTransport.UI.WPF.Views /// private void OnCopyBottomCenterClick(object sender, RoutedEventArgs e) { - try - { - string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", BottomCenterX, BottomCenterY, BottomCenterZ); - System.Windows.Clipboard.SetText(coordinate); - ShowButtonFeedback(CopyBottomButton); - } - catch (Exception ex) - { - LogManager.Debug($"[坐标窗口] 复制到剪贴板失败: {ex.Message}"); - } + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", HostBottomCenterX, HostBottomCenterY, HostBottomCenterZ); + bool success = ClipboardHelper.TrySetText(coordinate, "元素包围盒信息"); + ShowButtonFeedback(CopyBottomButton, success); + } + + private void OnCopyOrientedTopCenterClick(object sender, RoutedEventArgs e) + { + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", OrientedTopCenterX, OrientedTopCenterY, OrientedTopCenterZ); + bool success = ClipboardHelper.TrySetText(coordinate, "元素包围盒信息"); + ShowButtonFeedback(CopyOrientedTopButton, success); + } + + private void OnCopyOrientedBottomCenterClick(object sender, RoutedEventArgs e) + { + string coordinate = string.Format("{0:0.000}, {1:0.000}, {2:0.000}", OrientedBottomCenterX, OrientedBottomCenterY, OrientedBottomCenterZ); + bool success = ClipboardHelper.TrySetText(coordinate, "元素包围盒信息"); + ShowButtonFeedback(CopyOrientedBottomButton, success); } /// - /// 显示按钮点击反馈(绿色背景闪烁) + /// 显示按钮点击反馈 /// - private void ShowButtonFeedback(Button button) + private void ShowButtonFeedback(Button button, bool success) { if (button == null) return; var originalBackground = button.Background; var originalBorderBrush = button.BorderBrush; - // 变为绿色表示成功 - button.Background = new SolidColorBrush(System.Windows.Media.Color.FromRgb(76, 175, 80)); - button.BorderBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(56, 142, 60)); + if (success) + { + button.Background = new SolidColorBrush(System.Windows.Media.Color.FromRgb(76, 175, 80)); + button.BorderBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(56, 142, 60)); + } + else + { + button.Background = new SolidColorBrush(System.Windows.Media.Color.FromRgb(244, 67, 54)); + button.BorderBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(211, 47, 47)); + } - // 800毫秒后恢复 var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(800) }; timer.Tick += (s, e) => { diff --git a/src/UI/WPF/Views/ModelSettingsView.xaml b/src/UI/WPF/Views/ModelSettingsView.xaml index cc63574..70b7197 100644 --- a/src/UI/WPF/Views/ModelSettingsView.xaml +++ b/src/UI/WPF/Views/ModelSettingsView.xaml @@ -28,6 +28,7 @@ NavisworksTransport 类别设置页签视图 - 参考分层管理页签的设计 + @@ -115,14 +116,14 @@ NavisworksTransport 类别设置页签视图 - 参考分层管理页签的设计 public class YUpCoordinateSystem : ICoordinateSystem { + private static readonly HostCoordinateAdapter Adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); + public CoordinateSystemType Type => CoordinateSystemType.YUp; public Vector3D UpVector => new Vector3D(0, 1, 0); @@ -18,22 +21,22 @@ namespace NavisworksTransport.Utils.CoordinateSystem public double GetElevation(Point3D point) { - return point.Y; + return HostPlanarGridHelper.GetElevation3(ToVector3(point), Adapter); } public Point3D SetElevation(Point3D point, double elevation) { - return new Point3D(point.X, elevation, point.Z); + return ToPoint3D(HostPlanarGridHelper.SetElevation3(ToVector3(point), elevation, Adapter)); } public (double h1, double h2) GetHorizontalCoords(Point3D point) { - return (point.X, point.Z); + return HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(point), Adapter); } public Point3D CreatePoint(double h1, double h2, double elevation) { - return new Point3D(h1, elevation, h2); + return ToPoint3D(HostPlanarGridHelper.CreateHostPoint3(h1, h2, elevation, Adapter)); } public (double min, double max) GetHeightRange(BoundingBox3D bounds) @@ -43,12 +46,22 @@ namespace NavisworksTransport.Utils.CoordinateSystem public (double min1, double max1, double min2, double max2) GetHorizontalRange(BoundingBox3D bounds) { - return (bounds.Min.X, bounds.Max.X, bounds.Min.Z, bounds.Max.Z); + return HostPlanarGridHelper.GetHorizontalRange3(ToVector3(bounds.Min), ToVector3(bounds.Max), Adapter); } public override string ToString() { return "Y-Up 坐标系 (Y轴向上)"; } + + private static Vector3 ToVector3(Point3D point) + { + return new Vector3((float)point.X, (float)point.Y, (float)point.Z); + } + + private static Point3D ToPoint3D(Vector3 point) + { + return new Point3D(point.X, point.Y, point.Z); + } } } diff --git a/src/Utils/CoordinateSystem/ZUpCoordinateSystem.cs b/src/Utils/CoordinateSystem/ZUpCoordinateSystem.cs index d19ac32..c263633 100644 --- a/src/Utils/CoordinateSystem/ZUpCoordinateSystem.cs +++ b/src/Utils/CoordinateSystem/ZUpCoordinateSystem.cs @@ -1,4 +1,5 @@ using Autodesk.Navisworks.Api; +using System.Numerics; namespace NavisworksTransport.Utils.CoordinateSystem { @@ -8,6 +9,8 @@ namespace NavisworksTransport.Utils.CoordinateSystem /// public class ZUpCoordinateSystem : ICoordinateSystem { + private static readonly HostCoordinateAdapter Adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); + public CoordinateSystemType Type => CoordinateSystemType.ZUp; public Vector3D UpVector => new Vector3D(0, 0, 1); @@ -18,22 +21,22 @@ namespace NavisworksTransport.Utils.CoordinateSystem public double GetElevation(Point3D point) { - return point.Z; + return HostPlanarGridHelper.GetElevation3(ToVector3(point), Adapter); } public Point3D SetElevation(Point3D point, double elevation) { - return new Point3D(point.X, point.Y, elevation); + return ToPoint3D(HostPlanarGridHelper.SetElevation3(ToVector3(point), elevation, Adapter)); } public (double h1, double h2) GetHorizontalCoords(Point3D point) { - return (point.X, point.Y); + return HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(point), Adapter); } public Point3D CreatePoint(double h1, double h2, double elevation) { - return new Point3D(h1, h2, elevation); + return ToPoint3D(HostPlanarGridHelper.CreateHostPoint3(h1, h2, elevation, Adapter)); } public (double min, double max) GetHeightRange(BoundingBox3D bounds) @@ -43,12 +46,22 @@ namespace NavisworksTransport.Utils.CoordinateSystem public (double min1, double max1, double min2, double max2) GetHorizontalRange(BoundingBox3D bounds) { - return (bounds.Min.X, bounds.Max.X, bounds.Min.Y, bounds.Max.Y); + return HostPlanarGridHelper.GetHorizontalRange3(ToVector3(bounds.Min), ToVector3(bounds.Max), Adapter); } public override string ToString() { return "Z-Up 坐标系 (Z轴向上)"; } + + private static Vector3 ToVector3(Point3D point) + { + return new Vector3((float)point.X, (float)point.Y, (float)point.Z); + } + + private static Point3D ToPoint3D(Vector3 point) + { + return new Point3D(point.X, point.Y, point.Z); + } } } diff --git a/src/Utils/DialogHelper.cs b/src/Utils/DialogHelper.cs index 9f9f762..39f8ec8 100644 --- a/src/Utils/DialogHelper.cs +++ b/src/Utils/DialogHelper.cs @@ -2,6 +2,7 @@ using System; using System.Windows; using System.Windows.Controls; using System.Windows.Interop; +using System.Windows.Threading; using Autodesk.Navisworks.Api; namespace NavisworksTransport.Utils @@ -11,6 +12,14 @@ namespace NavisworksTransport.Utils /// public static class DialogHelper { + private const string TestAutomationEnvironmentVariable = "TRANSPORTPLUGIN_TEST_AUTOMATION"; + + public static bool IsTestAutomationModeEnabled => + string.Equals( + Environment.GetEnvironmentVariable(TestAutomationEnvironmentVariable), + "1", + StringComparison.OrdinalIgnoreCase); + /// /// 从 UserControl 获取父窗口并设置为对话框 Owner /// @@ -162,6 +171,159 @@ namespace NavisworksTransport.Utils return MessageBox.Show(messageBoxText, caption, button, icon); } + /// + /// 显示自动关闭的提示窗口,避免在自动化场景中阻塞流程。 + /// + public static void ShowAutoClosingMessageBox( + string messageBoxText, + string caption, + MessageBoxImage icon = MessageBoxImage.Information, + int autoCloseMilliseconds = 4000) + { + if (IsTestAutomationModeEnabled) + { + LogManager.Info($"[DialogHelper] 自动测试模式下跳过自动关闭提示框: {caption}"); + return; + } + + var owner = FindOwnerWindow(); + + var window = new Window + { + Title = caption, + Width = 460, + SizeToContent = SizeToContent.Height, + ResizeMode = ResizeMode.NoResize, + WindowStartupLocation = owner != null ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen, + ShowInTaskbar = false, + Topmost = true, + Content = BuildAutoClosingDialogContent(messageBoxText, icon, autoCloseMilliseconds) + }; + + if (owner != null) + { + try + { + window.Owner = owner; + } + catch (InvalidOperationException ex) + { + LogManager.Warning($"[DialogHelper] 设置自动关闭提示框 Owner 失败: {ex.Message}"); + } + } + else + { + SetWin32Owner(window); + } + + var closeTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(Math.Max(500, autoCloseMilliseconds)) + }; + closeTimer.Tick += (_, __) => + { + closeTimer.Stop(); + if (window.IsVisible) + { + window.Close(); + } + }; + + window.Closed += (_, __) => closeTimer.Stop(); + closeTimer.Start(); + window.Show(); + } + + private static FrameworkElement BuildAutoClosingDialogContent( + string messageBoxText, + MessageBoxImage icon, + int autoCloseMilliseconds) + { + string iconGlyph; + switch (icon) + { + case MessageBoxImage.Warning: + iconGlyph = "!"; + break; + case MessageBoxImage.Error: + iconGlyph = "X"; + break; + case MessageBoxImage.Question: + iconGlyph = "?"; + break; + default: + iconGlyph = "i"; + break; + } + + var root = new Grid + { + Margin = new Thickness(18) + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var iconText = new TextBlock + { + Text = iconGlyph, + FontSize = 22, + FontWeight = FontWeights.Bold, + Margin = new Thickness(0, 0, 14, 0), + VerticalAlignment = VerticalAlignment.Top + }; + Grid.SetColumn(iconText, 0); + Grid.SetRow(iconText, 0); + root.Children.Add(iconText); + + var messageText = new TextBlock + { + Text = messageBoxText, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0), + MinWidth = 320 + }; + Grid.SetColumn(messageText, 1); + Grid.SetRow(messageText, 0); + root.Children.Add(messageText); + + var footerPanel = new DockPanel + { + Margin = new Thickness(0, 16, 0, 0), + LastChildFill = false + }; + Grid.SetColumn(footerPanel, 0); + Grid.SetColumnSpan(footerPanel, 2); + Grid.SetRow(footerPanel, 1); + + var autoCloseHint = new TextBlock + { + Text = $"此提示将在 {Math.Max(1, autoCloseMilliseconds / 1000)} 秒后自动关闭。", + VerticalAlignment = VerticalAlignment.Center + }; + DockPanel.SetDock(autoCloseHint, Dock.Left); + footerPanel.Children.Add(autoCloseHint); + + var closeButton = new Button + { + Content = "确定", + MinWidth = 72, + Padding = new Thickness(14, 4, 14, 4), + Margin = new Thickness(16, 0, 0, 0) + }; + closeButton.Click += (_, __) => + { + Window parentWindow = Window.GetWindow(closeButton); + parentWindow?.Close(); + }; + DockPanel.SetDock(closeButton, Dock.Right); + footerPanel.Children.Add(closeButton); + + root.Children.Add(footerPanel); + return root; + } + /// /// 获取 Navisworks 主窗口句柄(Win32) /// diff --git a/src/Utils/ModelItemAnalysisHelper.cs b/src/Utils/ModelItemAnalysisHelper.cs index d1aed30..0316da6 100644 --- a/src/Utils/ModelItemAnalysisHelper.cs +++ b/src/Utils/ModelItemAnalysisHelper.cs @@ -425,6 +425,8 @@ namespace NavisworksTransport.Utils if (isComposite || hasGeometry) { + bool addedCurrentItem = false; + // 复合对象或独立实体节点:检查几何体类型 var geometry = item.Geometry; @@ -440,6 +442,7 @@ namespace NavisworksTransport.Utils else mixedCount++; visibleItems.Add(item); // 只保留包含三角形的几何体 + addedCurrentItem = true; } else if (primitiveTypes == PrimitiveTypes.Lines) { @@ -465,6 +468,7 @@ namespace NavisworksTransport.Utils { mixedCount++; visibleItems.Add(item); // 其他混合类型保留 + addedCurrentItem = true; } } else @@ -473,8 +477,15 @@ namespace NavisworksTransport.Utils // 不添加:无几何体数据 } - // 复合对象:不继续遍历子节点(Clash Detective API 会自动处理) - // 独立实体节点:也没有子节点 + // 只有当前节点已经可直接作为几何候选时,才截断子树。 + // 如果当前节点本身没有可用三角几何,则继续向下遍历,避免把真实几何子节点整棵截断。 + if (!addedCurrentItem) + { + foreach (var child in item.Children) + { + stack.Push(child); + } + } } else { @@ -536,4 +547,4 @@ namespace NavisworksTransport.Utils } #endregion -} \ No newline at end of file +} diff --git a/src/Utils/ModelItemTransformHelper.cs b/src/Utils/ModelItemTransformHelper.cs index bf8814d..b167e38 100644 --- a/src/Utils/ModelItemTransformHelper.cs +++ b/src/Utils/ModelItemTransformHelper.cs @@ -1,5 +1,7 @@ using System; using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils.CoordinateSystem; +using System.Numerics; namespace NavisworksTransport.Utils { @@ -53,31 +55,39 @@ namespace NavisworksTransport.Utils try { - // 获取变换的组件 var components = transform.Factor(); - var rotation = components.Rotation; + return GetYawFromRotation(components.Rotation); + } + catch (Exception) + { + return 0.0; + } + } - // 使用ToAxisAndAngle()提取轴和角度 + /// + /// 从 Rotation3D 中提取 XY 平面的 yaw 角(弧度)。 + /// + public static double GetYawFromRotation(Rotation3D rotation) + { + if (rotation == null) + { + return 0.0; + } + + try + { var rotationResult = rotation.ToAxisAndAngle(); double angle = rotationResult.Angle; double ux = rotationResult.Axis.X; double uy = rotationResult.Axis.Y; double uz = rotationResult.Axis.Z; - // 旋转公式(罗德里格旋转公式简化版,提取局部X轴(1,0,0)旋转后的方向) - // 旋转后的向量 v' = v*cosθ + (u×v)*sinθ + u*(u·v)*(1-cosθ) - // 当 v = (1,0,0) 时: - // v'.x = cosθ + ux*ux*(1-cosθ) - // v'.y = uz*sinθ + ux*uy*(1-cosθ) - double cos = Math.Cos(angle); double sin = Math.Sin(angle); - // 计算旋转后的X轴在世界坐标系XY平面上的分量 double vx = cos + ux * ux * (1 - cos); double vy = uz * sin + ux * uy * (1 - cos); - // 在XY平面上通过Atan2计算偏航角 return Math.Atan2(vy, vx); } catch (Exception) @@ -86,6 +96,326 @@ namespace NavisworksTransport.Utils } } + /// + /// 获取变换后局部 X 轴在世界坐标中的方向,通常可作为物体主朝向。 + /// + public static Vector3D GetForwardDirectionFromTransform(Transform3D transform) + { + return GetAxisDirectionFromTransform(transform, 0, new Vector3D(1, 0, 0)); + } + + /// + /// 获取变换后局部 Z 轴在世界坐标中的方向,可作为构件顶/底面法向。 + /// + public static Vector3D GetUpDirectionFromTransform(Transform3D transform) + { + return GetAxisDirectionFromTransform(transform, 2, new Vector3D(0, 0, 1)); + } + + /// + /// 根据模型局部轴约定,获取指定局部轴在世界坐标中的方向。 + /// + public static Vector3D GetDirectionFromTransform( + Transform3D transform, + LocalAxisDirection axisDirection) + { + GetAxisIndexAndSign(axisDirection, out int axisIndex, out int sign); + Vector3D fallback = GetFallbackAxis(axisDirection); + Vector3D direction = GetAxisDirectionFromTransform(transform, axisIndex, fallback); + + return sign >= 0 + ? direction + : new Vector3D(-direction.X, -direction.Y, -direction.Z); + } + + /// + /// 获取绑定在物体局部坐标中的稳定底面锚点。 + /// 适用于带俯仰/侧倾的三维姿态对象,避免使用世界 AABB 底面中心导致锚点漂移。 + /// + public static Point3D GetStableBottomAnchorPoint(BoundingBox3D bounds, Transform3D transform) + { + if (bounds == null) + { + return new Point3D(0, 0, 0); + } + + if (transform == null) + { + return new Point3D(bounds.Center.X, bounds.Center.Y, bounds.Min.Z); + } + + var up = GetUpDirectionFromTransform(transform); + var localSize = EstimateLocalBoxSize(bounds, transform); + double halfHeight = localSize.Z / 2.0; + + return new Point3D( + bounds.Center.X - up.X * halfHeight, + bounds.Center.Y - up.Y * halfHeight, + bounds.Center.Z - up.Z * halfHeight); + } + + /// + /// 使用宿主坐标系的 up 轴,从世界 AABB 中提取“底部锚点”。 + /// 适用于真实模型仍保持宿主坐标语义(如 Y-up 模型)的场景。 + /// + public static Point3D GetHostBottomAnchorPoint(BoundingBox3D bounds, HostCoordinateAdapter adapter) + { + if (bounds == null) + { + return new Point3D(0, 0, 0); + } + + if (adapter == null) + { + throw new ArgumentNullException(nameof(adapter)); + } + + switch (adapter.HostUpAxisIndex) + { + case 1: + return new Point3D(bounds.Center.X, bounds.Min.Y, bounds.Center.Z); + case 2: + default: + return new Point3D(bounds.Center.X, bounds.Center.Y, bounds.Min.Z); + } + } + + /// + /// 使用宿主坐标系的 up 轴,从世界 AABB 中提取高度。 + /// 适用于真实模型仍保持宿主坐标语义(如 Y-up 模型)的场景。 + /// + public static double GetHostHeight(BoundingBox3D bounds, HostCoordinateAdapter adapter) + { + if (bounds == null) + { + return 0.0; + } + + if (adapter == null) + { + throw new ArgumentNullException(nameof(adapter)); + } + + switch (adapter.HostUpAxisIndex) + { + case 1: + return bounds.Max.Y - bounds.Min.Y; + case 2: + default: + return bounds.Max.Z - bounds.Min.Z; + } + } + + /// + /// 根据世界轴对齐包围盒和物体变换,估算物体在局部坐标系中的尺寸。 + /// 适用于刚性箱体类对象,用于顶/底面对接点推导。 + /// + public static Vector3D EstimateLocalBoxSize(BoundingBox3D bounds, Transform3D transform) + { + double halfXWorld = Math.Max(0.0, (bounds.Max.X - bounds.Min.X) / 2.0); + double halfYWorld = Math.Max(0.0, (bounds.Max.Y - bounds.Min.Y) / 2.0); + double halfZWorld = Math.Max(0.0, (bounds.Max.Z - bounds.Min.Z) / 2.0); + + if (transform == null) + { + return new Vector3D(halfXWorld * 2.0, halfYWorld * 2.0, halfZWorld * 2.0); + } + + try + { + var forward = GetForwardDirectionFromTransform(transform); + var localY = GetAxisDirectionFromTransform(transform, 1, new Vector3D(0, 1, 0)); + var up = GetUpDirectionFromTransform(transform); + + double[,] matrix = + { + { Math.Abs(forward.X), Math.Abs(localY.X), Math.Abs(up.X) }, + { Math.Abs(forward.Y), Math.Abs(localY.Y), Math.Abs(up.Y) }, + { Math.Abs(forward.Z), Math.Abs(localY.Z), Math.Abs(up.Z) } + }; + + double[] worldHalfExtents = { halfXWorld, halfYWorld, halfZWorld }; + double[] localHalfExtents = SolveLinearSystem3x3(matrix, worldHalfExtents); + + return new Vector3D( + Math.Max(0.0, localHalfExtents[0] * 2.0), + Math.Max(0.0, localHalfExtents[1] * 2.0), + Math.Max(0.0, localHalfExtents[2] * 2.0)); + } + catch (Exception) + { + return new Vector3D(halfXWorld * 2.0, halfYWorld * 2.0, halfZWorld * 2.0); + } + } + + /// + /// 根据箱体局部尺寸和世界原点方向,推导“背离球心”的真实长轴方向。 + /// 先选局部最长轴,再用箱体中心相对世界原点的方向决定正负。 + /// + public static Vector3D GetLongestAxisDirectionAwayFromOrigin(BoundingBox3D bounds, Transform3D transform) + { + Vector3D localSize = EstimateLocalBoxSize(bounds, transform); + + int longestAxisIndex = 0; + double longestAxisLength = localSize.X; + if (localSize.Y > longestAxisLength) + { + longestAxisIndex = 1; + longestAxisLength = localSize.Y; + } + + if (localSize.Z > longestAxisLength) + { + longestAxisIndex = 2; + } + + Vector3D axisDirection = GetAxisDirectionFromTransform( + transform, + longestAxisIndex, + longestAxisIndex == 0 ? new Vector3D(1, 0, 0) : + longestAxisIndex == 1 ? new Vector3D(0, 1, 0) : + new Vector3D(0, 0, 1)); + + Vector3D awayFromOrigin = new Vector3D(bounds.Center.X, bounds.Center.Y, bounds.Center.Z); + double awayLengthSquared = awayFromOrigin.X * awayFromOrigin.X + awayFromOrigin.Y * awayFromOrigin.Y + awayFromOrigin.Z * awayFromOrigin.Z; + if (awayLengthSquared < 1e-9) + { + return axisDirection; + } + + double dot = axisDirection.X * awayFromOrigin.X + axisDirection.Y * awayFromOrigin.Y + axisDirection.Z * awayFromOrigin.Z; + if (dot < 0.0) + { + return new Vector3D(-axisDirection.X, -axisDirection.Y, -axisDirection.Z); + } + + return axisDirection; + } + + private static Vector3D GetAxisDirectionFromTransform(Transform3D transform, int axisIndex, Vector3D fallback) + { + if (transform == null) + { + return fallback; + } + + try + { + var linear = transform.Linear; + var direction = new Vector3D( + linear.Get(0, axisIndex), + linear.Get(1, axisIndex), + linear.Get(2, axisIndex)); + + double lengthSquared = direction.X * direction.X + direction.Y * direction.Y + direction.Z * direction.Z; + if (lengthSquared < 1e-9) + { + return fallback; + } + + double length = Math.Sqrt(lengthSquared); + return new Vector3D(direction.X / length, direction.Y / length, direction.Z / length); + } + catch (Exception) + { + return fallback; + } + } + + private static void GetAxisIndexAndSign(LocalAxisDirection axisDirection, out int axisIndex, out int sign) + { + switch (axisDirection) + { + case LocalAxisDirection.PositiveX: + axisIndex = 0; + sign = 1; + break; + case LocalAxisDirection.NegativeX: + axisIndex = 0; + sign = -1; + break; + case LocalAxisDirection.PositiveY: + axisIndex = 1; + sign = 1; + break; + case LocalAxisDirection.NegativeY: + axisIndex = 1; + sign = -1; + break; + case LocalAxisDirection.PositiveZ: + axisIndex = 2; + sign = 1; + break; + case LocalAxisDirection.NegativeZ: + axisIndex = 2; + sign = -1; + break; + default: + throw new ArgumentOutOfRangeException(nameof(axisDirection), axisDirection, null); + } + } + + private static Vector3D GetFallbackAxis(LocalAxisDirection axisDirection) + { + switch (axisDirection) + { + case LocalAxisDirection.PositiveX: + return new Vector3D(1, 0, 0); + case LocalAxisDirection.NegativeX: + return new Vector3D(-1, 0, 0); + case LocalAxisDirection.PositiveY: + return new Vector3D(0, 1, 0); + case LocalAxisDirection.NegativeY: + return new Vector3D(0, -1, 0); + case LocalAxisDirection.PositiveZ: + return new Vector3D(0, 0, 1); + case LocalAxisDirection.NegativeZ: + return new Vector3D(0, 0, -1); + default: + throw new ArgumentOutOfRangeException(nameof(axisDirection), axisDirection, null); + } + } + + private static double[] SolveLinearSystem3x3(double[,] matrix, double[] values) + { + double determinant = + matrix[0, 0] * (matrix[1, 1] * matrix[2, 2] - matrix[1, 2] * matrix[2, 1]) - + matrix[0, 1] * (matrix[1, 0] * matrix[2, 2] - matrix[1, 2] * matrix[2, 0]) + + matrix[0, 2] * (matrix[1, 0] * matrix[2, 1] - matrix[1, 1] * matrix[2, 0]); + + if (Math.Abs(determinant) < 1e-9) + { + return new[] { values[0], values[1], values[2] }; + } + + double inverseDeterminant = 1.0 / determinant; + double[,] inverse = + { + { + (matrix[1, 1] * matrix[2, 2] - matrix[1, 2] * matrix[2, 1]) * inverseDeterminant, + (matrix[0, 2] * matrix[2, 1] - matrix[0, 1] * matrix[2, 2]) * inverseDeterminant, + (matrix[0, 1] * matrix[1, 2] - matrix[0, 2] * matrix[1, 1]) * inverseDeterminant + }, + { + (matrix[1, 2] * matrix[2, 0] - matrix[1, 0] * matrix[2, 2]) * inverseDeterminant, + (matrix[0, 0] * matrix[2, 2] - matrix[0, 2] * matrix[2, 0]) * inverseDeterminant, + (matrix[0, 2] * matrix[1, 0] - matrix[0, 0] * matrix[1, 2]) * inverseDeterminant + }, + { + (matrix[1, 0] * matrix[2, 1] - matrix[1, 1] * matrix[2, 0]) * inverseDeterminant, + (matrix[0, 1] * matrix[2, 0] - matrix[0, 0] * matrix[2, 1]) * inverseDeterminant, + (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) * inverseDeterminant + } + }; + + return new[] + { + inverse[0, 0] * values[0] + inverse[0, 1] * values[1] + inverse[0, 2] * values[2], + inverse[1, 0] * values[0] + inverse[1, 1] * values[1] + inverse[1, 2] * values[2], + inverse[2, 0] * values[0] + inverse[2, 1] * values[1] + inverse[2, 2] * values[2] + }; + } + /// /// 恢复物体到原位置 /// @@ -107,7 +437,7 @@ namespace NavisworksTransport.Utils /// 适用于碰撞位置还原等场景 /// /// 要移动的物体 - /// 目标位置(地面位置,即包围盒底面) + /// 目标位置(动画跟踪点,当前统一为几何中心) /// 目标朝向(弧度,绕Z轴) public static void MoveItemToPositionAndYaw(ModelItem item, Point3D targetPosition, double targetYaw) { @@ -119,18 +449,14 @@ namespace NavisworksTransport.Utils // 获取CAD原始状态 var originalBounds = item.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); + var originalCenterPos = originalBounds.Center; var originalYaw = GetYawFromTransform(item.Transform); // 计算从CAD原始位置到目标位置的增量 var deltaPos = new Vector3D( - targetPosition.X - originalGroundPos.X, - targetPosition.Y - originalGroundPos.Y, - targetPosition.Z - originalGroundPos.Z + targetPosition.X - originalCenterPos.X, + targetPosition.Y - originalCenterPos.Y, + targetPosition.Z - originalCenterPos.Z ); double deltaYaw = targetYaw - originalYaw; @@ -141,13 +467,13 @@ namespace NavisworksTransport.Utils // 有旋转:需要补偿绕原点旋转带来的位置偏移 double cos = Math.Cos(deltaYaw); double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; + double rotatedX = originalCenterPos.X * cos - originalCenterPos.Y * sin; + double rotatedY = originalCenterPos.X * sin + originalCenterPos.Y * cos; var compensatedTranslation = new Vector3D( targetPosition.X - rotatedX, targetPosition.Y - rotatedY, - deltaPos.Z + targetPosition.Z - originalCenterPos.Z ); var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); @@ -165,6 +491,677 @@ namespace NavisworksTransport.Utils doc.Models.OverridePermanentTransform(modelItems, transform, false); } + /// + /// 将物体移动到指定位置和完整三维朝向(先回到CAD原始位置,再移动) + /// + public static void MoveItemToPositionAndRotation(ModelItem item, Point3D targetPosition, Rotation3D targetRotation) + { + ApplyAbsoluteTransform(item, targetPosition, targetRotation, preserveCurrentScale: false); + } + + /// + /// 将物体移动到指定位置和完整三维朝向,同时保留当前缩放。 + /// + public static void MoveItemToPositionAndRotationWithCurrentScale(ModelItem item, Point3D targetPosition, Rotation3D targetRotation) + { + ApplyAbsoluteTransform(item, targetPosition, targetRotation, preserveCurrentScale: true); + } + + /// + /// 基于物体当前实际姿态,增量移动到目标位置和完整三维朝向。 + /// 适用于动画过程中的真实模型物体,避免每次回到 CAD 原始状态导致位置跑偏。 + /// + public static void MoveItemIncrementallyToPositionAndRotation( + ModelItem item, + Point3D currentPosition, + Rotation3D currentRotation, + Point3D targetPosition, + Rotation3D targetRotation) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + + Rotation3D deltaRotation = BuildDeltaRotation(currentRotation, targetRotation); + var deltaLinear = new Transform3D(deltaRotation).Linear; + var currentLinear = new Transform3D(currentRotation).Linear; + var targetLinear = new Transform3D(targetRotation).Linear; + + var rotatedCurrentPosition = new Point3D( + deltaLinear.Get(0, 0) * currentPosition.X + deltaLinear.Get(0, 1) * currentPosition.Y + deltaLinear.Get(0, 2) * currentPosition.Z, + deltaLinear.Get(1, 0) * currentPosition.X + deltaLinear.Get(1, 1) * currentPosition.Y + deltaLinear.Get(1, 2) * currentPosition.Z, + deltaLinear.Get(2, 0) * currentPosition.X + deltaLinear.Get(2, 1) * currentPosition.Y + deltaLinear.Get(2, 2) * currentPosition.Z); + + var translation = new Vector3D( + targetPosition.X - rotatedCurrentPosition.X, + targetPosition.Y - rotatedCurrentPosition.Y, + targetPosition.Z - rotatedCurrentPosition.Z); + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 当前=({currentPosition.X:F3},{currentPosition.Y:F3},{currentPosition.Z:F3}), " + + $"目标=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " + + $"平移=({translation.X:F3},{translation.Y:F3},{translation.Z:F3})"); + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 当前旋转: " + + $"X=({currentLinear.Get(0, 0):F4},{currentLinear.Get(1, 0):F4},{currentLinear.Get(2, 0):F4}), " + + $"Y=({currentLinear.Get(0, 1):F4},{currentLinear.Get(1, 1):F4},{currentLinear.Get(2, 1):F4}), " + + $"Z=({currentLinear.Get(0, 2):F4},{currentLinear.Get(1, 2):F4},{currentLinear.Get(2, 2):F4})"); + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 目标旋转: " + + $"X=({targetLinear.Get(0, 0):F4},{targetLinear.Get(1, 0):F4},{targetLinear.Get(2, 0):F4}), " + + $"Y=({targetLinear.Get(0, 1):F4},{targetLinear.Get(1, 1):F4},{targetLinear.Get(2, 1):F4}), " + + $"Z=({targetLinear.Get(0, 2):F4},{targetLinear.Get(1, 2):F4},{targetLinear.Get(2, 2):F4})"); + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 增量旋转: " + + $"X=({deltaLinear.Get(0, 0):F4},{deltaLinear.Get(1, 0):F4},{deltaLinear.Get(2, 0):F4}), " + + $"Y=({deltaLinear.Get(0, 1):F4},{deltaLinear.Get(1, 1):F4},{deltaLinear.Get(2, 1):F4}), " + + $"Z=({deltaLinear.Get(0, 2):F4},{deltaLinear.Get(1, 2):F4},{deltaLinear.Get(2, 2):F4}), " + + $"旋后当前点=({rotatedCurrentPosition.X:F3},{rotatedCurrentPosition.Y:F3},{rotatedCurrentPosition.Z:F3})"); + + LogGeometryLevelTransforms(item, "[模型增量姿态应用前][GeometryAPI]"); + + // 用显式三步法应用三维增量位姿: + // 1. 把当前锚点移到原点 + // 2. 绕原点旋转到目标姿态 + // 3. 再把锚点移到目标位置 + // 这与旧 yaw 链路的补偿语义一致,只是从二维扩展到三维。 + var toOrigin = Transform3D.CreateTranslation(new Vector3D( + -currentPosition.X, + -currentPosition.Y, + -currentPosition.Z)); + doc.Models.OverridePermanentTransform(modelItems, toOrigin, false); + + var rotationOnlyComponents = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)).Factor(); + rotationOnlyComponents.Rotation = deltaRotation; + var rotationOnly = rotationOnlyComponents.Combine(); + doc.Models.OverridePermanentTransform(modelItems, rotationOnly, false); + + var toTarget = Transform3D.CreateTranslation(new Vector3D( + targetPosition.X, + targetPosition.Y, + targetPosition.Z)); + doc.Models.OverridePermanentTransform(modelItems, toTarget, false); + + LogIncrementalTransformActual(item, targetPosition, targetRotation); + } + + /// + /// 直接对当前显示结果施加宿主轴旋转增量,再补一段平移把业务跟踪点拉回目标点。 + /// 不先构造 targetRotation,也不从 current/target 姿态反推 deltaRotation。 + /// + public static void MoveItemIncrementallyByAxisRotationAndTranslation( + ModelItem item, + Point3D currentPosition, + Vector3 hostAxis, + double deltaAngleRadians, + Point3D targetPosition) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (hostAxis.LengthSquared() < 1e-12f) + { + throw new ArgumentException("hostAxis must be non-zero.", nameof(hostAxis)); + } + + Vector3 normalizedHostAxis = Vector3.Normalize(hostAxis); + Rotation3D deltaRotation = new Rotation3D( + new UnitVector3D(normalizedHostAxis.X, normalizedHostAxis.Y, normalizedHostAxis.Z), + deltaAngleRadians); + Matrix3 deltaLinear = new Transform3D(deltaRotation).Linear; + Point3D rotatedCurrentPosition = new Point3D( + deltaLinear.Get(0, 0) * currentPosition.X + deltaLinear.Get(0, 1) * currentPosition.Y + deltaLinear.Get(0, 2) * currentPosition.Z, + deltaLinear.Get(1, 0) * currentPosition.X + deltaLinear.Get(1, 1) * currentPosition.Y + deltaLinear.Get(1, 2) * currentPosition.Z, + deltaLinear.Get(2, 0) * currentPosition.X + deltaLinear.Get(2, 1) * currentPosition.Y + deltaLinear.Get(2, 2) * currentPosition.Z); + + Vector3D compensatedTranslation = new Vector3D( + targetPosition.X - rotatedCurrentPosition.X, + targetPosition.Y - rotatedCurrentPosition.Y, + targetPosition.Z - rotatedCurrentPosition.Z); + + LogManager.Debug( + $"[模型纯增量旋转平移] {item.DisplayName} 当前=({currentPosition.X:F3},{currentPosition.Y:F3},{currentPosition.Z:F3}), " + + $"目标=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " + + $"Axis=({normalizedHostAxis.X:F4},{normalizedHostAxis.Y:F4},{normalizedHostAxis.Z:F4}), " + + $"Angle={deltaAngleRadians * 180.0 / Math.PI:F2}°, " + + $"旋后当前点=({rotatedCurrentPosition.X:F3},{rotatedCurrentPosition.Y:F3},{rotatedCurrentPosition.Z:F3}), " + + $"平移=({compensatedTranslation.X:F3},{compensatedTranslation.Y:F3},{compensatedTranslation.Z:F3})"); + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + LogGeometryLevelTransforms(item, "[模型纯增量旋转平移应用前][GeometryAPI]"); + + var toOrigin = Transform3D.CreateTranslation(new Vector3D( + -currentPosition.X, + -currentPosition.Y, + -currentPosition.Z)); + doc.Models.OverridePermanentTransform(modelItems, toOrigin, false); + + var rotationOnlyComponents = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)).Factor(); + rotationOnlyComponents.Rotation = deltaRotation; + var rotationOnly = rotationOnlyComponents.Combine(); + doc.Models.OverridePermanentTransform(modelItems, rotationOnly, false); + + var toTarget = Transform3D.CreateTranslation(new Vector3D( + targetPosition.X, + targetPosition.Y, + targetPosition.Z)); + doc.Models.OverridePermanentTransform(modelItems, toTarget, false); + + LogGeometryLevelTransforms(item, "[模型纯增量旋转平移应用后][GeometryAPI]"); + } + + /// + /// 读取物体当前实际几何姿态。 + /// 优先使用 ModelGeometry.ActiveTransform,因为 ModelItem.Transform 只反映原始设计变换。 + /// + public static bool TryGetCurrentGeometryRotation(ModelItem item, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + if (item == null) + { + return false; + } + + try + { + ModelGeometry geometry = item.FindFirstGeometry() ?? item.Geometry; + if (geometry?.ActiveTransform == null) + { + return false; + } + + rotation = geometry.ActiveTransform.Factor().Rotation; + return true; + } + catch (Exception ex) + { + LogManager.Warning($"[当前几何姿态] 读取 ActiveTransform 失败: {ex.Message}"); + return false; + } + } + + public static bool TryGetCurrentOverrideRotation(ModelItem item, out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + if (item == null) + { + return false; + } + + try + { + ModelGeometry geometry = item.FindFirstGeometry() ?? item.Geometry; + if (geometry?.PermanentOverrideTransform == null) + { + return true; + } + + rotation = geometry.PermanentOverrideTransform.Factor().Rotation; + return true; + } + catch (Exception ex) + { + LogManager.Warning($"[当前覆盖姿态] 读取 PermanentOverrideTransform 失败: {ex.Message}"); + return false; + } + } + + public static bool TryResolveOverrideRotationForFinalTarget( + ModelItem item, + Rotation3D finalTargetRotation, + out Rotation3D overrideRotation) + { + overrideRotation = Rotation3D.Identity; + if (item == null) + { + return false; + } + + try + { + ModelGeometry geometry = item.FindFirstGeometry() ?? item.Geometry; + Transform3D originalTransform = geometry?.OriginalTransform ?? item.Transform; + Rotation3D originalRotation = originalTransform.Factor().Rotation; + var originalInverse = originalRotation.Invert(); + var overrideTransform = Transform3D.Multiply( + new Transform3D(originalInverse), + new Transform3D(finalTargetRotation)); + overrideRotation = overrideTransform.Factor().Rotation; + return true; + } + catch (Exception ex) + { + LogManager.Warning($"[覆盖姿态换算] 计算 override 姿态失败: {ex.Message}"); + return false; + } + } + + public static void LogCurrentGeometryTransformsForDebug(ModelItem item, string prefix) + { + LogGeometryLevelTransforms(item, prefix); + } + + private static void LogIncrementalTransformActual(ModelItem item, Point3D targetPosition, Rotation3D targetRotation) + { + try + { + var actualBounds = item.BoundingBox(); + var actualTransform = item.Transform; + var actualRotation = actualTransform.Factor().Rotation; + var actualLinear = new Transform3D(actualRotation).Linear; + var targetLinear = new Transform3D(targetRotation).Linear; + var actualPosition = actualBounds.Center; + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 立即读回位置(可能滞后): " + + $"实际=({actualPosition.X:F3},{actualPosition.Y:F3},{actualPosition.Z:F3}), " + + $"期望=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " + + $"偏差=({actualPosition.X - targetPosition.X:F3},{actualPosition.Y - targetPosition.Y:F3},{actualPosition.Z - targetPosition.Z:F3})"); + + LogManager.Debug( + $"[模型增量姿态] {item.DisplayName} 立即读回旋转(可能滞后/不反映override): " + + $"实际X=({actualLinear.Get(0, 0):F4},{actualLinear.Get(1, 0):F4},{actualLinear.Get(2, 0):F4}), " + + $"实际Y=({actualLinear.Get(0, 1):F4},{actualLinear.Get(1, 1):F4},{actualLinear.Get(2, 1):F4}), " + + $"实际Z=({actualLinear.Get(0, 2):F4},{actualLinear.Get(1, 2):F4},{actualLinear.Get(2, 2):F4}), " + + $"期望X=({targetLinear.Get(0, 0):F4},{targetLinear.Get(1, 0):F4},{targetLinear.Get(2, 0):F4}), " + + $"期望Y=({targetLinear.Get(0, 1):F4},{targetLinear.Get(1, 1):F4},{targetLinear.Get(2, 1):F4}), " + + $"期望Z=({targetLinear.Get(0, 2):F4},{targetLinear.Get(1, 2):F4},{targetLinear.Get(2, 2):F4})"); + + LogGeometryLevelTransforms(item, "[模型增量姿态应用后][GeometryAPI]"); + } + catch (Exception ex) + { + LogManager.Warning($"[模型增量姿态] 输出应用后日志失败: {ex.Message}"); + } + } + + /// + /// 将物体移动到指定中心点和完整三维朝向,同时保留当前缩放。 + /// 仅适用于“几何中心就是业务定位点”的实体,例如参考杆、辅助几何、虚拟包围盒。 + /// 不适用于真实模型物体的动画、碰撞验证或场景恢复;真实模型应使用增量三步法, + /// 即先将固定参考点移到原点,再旋转,再移动到目标位置。 + /// + public static void MoveItemToCenterAndRotationWithCurrentScale(ModelItem item, Point3D targetCenter, Rotation3D targetRotation) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + + var currentComponents = item.Transform.Factor(); + Vector3D currentScale = currentComponents.Scale; + + doc.Models.ResetPermanentTransform(modelItems); + + var originalBounds = item.BoundingBox(); + Point3D originalCenter = originalBounds.Center; + + var rotationTransform = new Transform3D(targetRotation); + var linear = rotationTransform.Linear; + + var rotatedCenter = new Point3D( + linear.Get(0, 0) * originalCenter.X + linear.Get(0, 1) * originalCenter.Y + linear.Get(0, 2) * originalCenter.Z, + linear.Get(1, 0) * originalCenter.X + linear.Get(1, 1) * originalCenter.Y + linear.Get(1, 2) * originalCenter.Z, + linear.Get(2, 0) * originalCenter.X + linear.Get(2, 1) * originalCenter.Y + linear.Get(2, 2) * originalCenter.Z); + + var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); + var components = identity.Factor(); + components.Scale = currentScale; + components.Rotation = targetRotation; + components.Translation = new Vector3D( + targetCenter.X - rotatedCenter.X, + targetCenter.Y - rotatedCenter.Y, + targetCenter.Z - rotatedCenter.Z); + + doc.Models.OverridePermanentTransform(modelItems, components.Combine(), false); + } + + /// + /// 将物体移动到指定中心点和完整三维朝向。 + /// 仅应用位置和旋转,不保留当前缩放。 + /// 仅适用于“几何中心就是业务定位点”的实体,例如缩放已经在 Model 层完成的参考杆。 + /// 不适用于真实模型物体的动画、碰撞验证或场景恢复;真实模型应使用增量三步法, + /// 即先将固定参考点移到原点,再旋转,再移动到目标位置。 + /// + public static void MoveItemToCenterAndRotation(ModelItem item, Point3D targetCenter, Rotation3D targetRotation) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + + doc.Models.ResetPermanentTransform(modelItems); + + var originalBounds = item.BoundingBox(); + Point3D originalCenter = originalBounds.Center; + + var rotationTransform = new Transform3D(targetRotation); + var linear = rotationTransform.Linear; + + var rotatedCenter = new Point3D( + linear.Get(0, 0) * originalCenter.X + linear.Get(0, 1) * originalCenter.Y + linear.Get(0, 2) * originalCenter.Z, + linear.Get(1, 0) * originalCenter.X + linear.Get(1, 1) * originalCenter.Y + linear.Get(1, 2) * originalCenter.Z, + linear.Get(2, 0) * originalCenter.X + linear.Get(2, 1) * originalCenter.Y + linear.Get(2, 2) * originalCenter.Z); + + var translation = new Vector3D( + targetCenter.X - rotatedCenter.X, + targetCenter.Y - rotatedCenter.Y, + targetCenter.Z - rotatedCenter.Z); + + doc.Models.OverridePermanentTransform(modelItems, new Transform3D(targetRotation, translation), false); + } + + /// + /// 将物体的局部正 X 端面中心对齐到目标点,并保留当前缩放。 + /// 适用于单位立方体/单位圆柱体沿 +X 定义长度方向的参考杆资源。 + /// + public static void MoveItemPositiveXEndToPointAndRotationWithCurrentScale(ModelItem item, Point3D targetPoint, Rotation3D targetRotation) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + + var currentComponents = item.Transform.Factor(); + Vector3D currentScale = currentComponents.Scale; + + doc.Models.ResetPermanentTransform(modelItems); + + var originalBounds = item.BoundingBox(); + Point3D originalPositiveXEndCenter = new Point3D( + originalBounds.Max.X, + originalBounds.Center.Y, + originalBounds.Center.Z); + + var rotationTransform = new Transform3D(targetRotation); + var linear = rotationTransform.Linear; + + var rotatedPositiveXEndCenter = new Point3D( + linear.Get(0, 0) * originalPositiveXEndCenter.X + linear.Get(0, 1) * originalPositiveXEndCenter.Y + linear.Get(0, 2) * originalPositiveXEndCenter.Z, + linear.Get(1, 0) * originalPositiveXEndCenter.X + linear.Get(1, 1) * originalPositiveXEndCenter.Y + linear.Get(1, 2) * originalPositiveXEndCenter.Z, + linear.Get(2, 0) * originalPositiveXEndCenter.X + linear.Get(2, 1) * originalPositiveXEndCenter.Y + linear.Get(2, 2) * originalPositiveXEndCenter.Z); + + var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); + var components = identity.Factor(); + components.Scale = currentScale; + components.Rotation = targetRotation; + components.Translation = new Vector3D( + targetPoint.X - rotatedPositiveXEndCenter.X, + targetPoint.Y - rotatedPositiveXEndCenter.Y, + targetPoint.Z - rotatedPositiveXEndCenter.Z); + + doc.Models.OverridePermanentTransform(modelItems, components.Combine(), false); + } + + /// + /// 将物体的局部正 X 端面中心对齐到目标点。 + /// 仅应用位置和旋转,不保留当前缩放。 + /// 适用于缩放已经在 Model 层完成、Item 层只负责定位的场景。 + /// + public static void MoveItemPositiveXEndToPointAndRotation(ModelItem item, Point3D targetPoint, Rotation3D targetRotation) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + + doc.Models.ResetPermanentTransform(modelItems); + + var originalBounds = item.BoundingBox(); + Point3D originalPositiveXEndCenter = new Point3D( + originalBounds.Max.X, + originalBounds.Center.Y, + originalBounds.Center.Z); + + var rotationTransform = new Transform3D(targetRotation); + var linear = rotationTransform.Linear; + + var rotatedPositiveXEndCenter = new Point3D( + linear.Get(0, 0) * originalPositiveXEndCenter.X + linear.Get(0, 1) * originalPositiveXEndCenter.Y + linear.Get(0, 2) * originalPositiveXEndCenter.Z, + linear.Get(1, 0) * originalPositiveXEndCenter.X + linear.Get(1, 1) * originalPositiveXEndCenter.Y + linear.Get(1, 2) * originalPositiveXEndCenter.Z, + linear.Get(2, 0) * originalPositiveXEndCenter.X + linear.Get(2, 1) * originalPositiveXEndCenter.Y + linear.Get(2, 2) * originalPositiveXEndCenter.Z); + + var translation = new Vector3D( + targetPoint.X - rotatedPositiveXEndCenter.X, + targetPoint.Y - rotatedPositiveXEndCenter.Y, + targetPoint.Z - rotatedPositiveXEndCenter.Z); + + doc.Models.OverridePermanentTransform(modelItems, new Transform3D(targetRotation, translation), false); + } + + private static void ApplyAbsoluteTransform(ModelItem item, Point3D targetPosition, Rotation3D targetRotation, bool preserveCurrentScale) + { + var doc = Application.ActiveDocument; + var modelItems = new ModelItemCollection { item }; + Vector3D currentScale = new Vector3D(1, 1, 1); + ModelGeometry geometry = item.FindFirstGeometry() ?? item.Geometry; + + if (preserveCurrentScale) + { + var currentBaseTransform = geometry?.PermanentOverrideTransform ?? item.Transform; + var currentComponents = currentBaseTransform.Factor(); + currentScale = currentComponents.Scale; + } + + doc.Models.ResetPermanentTransform(modelItems); + + var originalTransform = geometry?.OriginalTransform ?? item.Transform; + var originalComponents = originalTransform.Factor(); + var originalRotation = originalComponents.Rotation; + var originalBounds = geometry?.BoundingBox ?? item.BoundingBox(); + var originalCenterPos = originalBounds.Center; + + Rotation3D deltaRotation = BuildDeltaRotation(originalRotation, targetRotation); + var rotationTransform = new Transform3D(deltaRotation); + var linear = rotationTransform.Linear; + + var rotatedCenterPos = new Point3D( + linear.Get(0, 0) * originalCenterPos.X + linear.Get(0, 1) * originalCenterPos.Y + linear.Get(0, 2) * originalCenterPos.Z, + linear.Get(1, 0) * originalCenterPos.X + linear.Get(1, 1) * originalCenterPos.Y + linear.Get(1, 2) * originalCenterPos.Z, + linear.Get(2, 0) * originalCenterPos.X + linear.Get(2, 1) * originalCenterPos.Y + linear.Get(2, 2) * originalCenterPos.Z + ); + + var translation = new Vector3D( + targetPosition.X - rotatedCenterPos.X, + targetPosition.Y - rotatedCenterPos.Y, + targetPosition.Z - rotatedCenterPos.Z + ); + + Transform3D transform; + if (preserveCurrentScale) + { + var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); + var components = identity.Factor(); + components.Scale = currentScale; + components.Rotation = deltaRotation; + components.Translation = translation; + transform = components.Combine(); + } + else + { + transform = new Transform3D(deltaRotation, translation); + } + + LogAbsoluteTransformDiagnostics(item, originalRotation, targetRotation, deltaRotation, originalCenterPos, targetPosition, translation); + doc.Models.OverridePermanentTransform(modelItems, transform, false); + LogAbsoluteTransformActual(item, preserveCurrentScale, currentScale, targetPosition, targetRotation); + } + + private static Rotation3D BuildDeltaRotation(Rotation3D originalRotation, Rotation3D targetRotation) + { + var originalInverse = originalRotation.Invert(); + var deltaTransform = Transform3D.Multiply( + new Transform3D(targetRotation), + new Transform3D(originalInverse)); + return deltaTransform.Factor().Rotation; + } + + private static void LogAbsoluteTransformDiagnostics( + ModelItem item, + Rotation3D originalRotation, + Rotation3D targetRotation, + Rotation3D deltaRotation, + Point3D originalTrackedPosition, + Point3D targetPosition, + Vector3D translation) + { + try + { + var originalLinear = new Transform3D(originalRotation).Linear; + var targetLinear = new Transform3D(targetRotation).Linear; + var deltaLinear = new Transform3D(deltaRotation).Linear; + + LogManager.Debug( + $"[模型姿态] {item.DisplayName} 原始: " + + $"X=({originalLinear.Get(0, 0):F4},{originalLinear.Get(1, 0):F4},{originalLinear.Get(2, 0):F4}), " + + $"Y=({originalLinear.Get(0, 1):F4},{originalLinear.Get(1, 1):F4},{originalLinear.Get(2, 1):F4}), " + + $"Z=({originalLinear.Get(0, 2):F4},{originalLinear.Get(1, 2):F4},{originalLinear.Get(2, 2):F4})"); + + LogManager.Debug( + $"[模型姿态] {item.DisplayName} 目标: " + + $"X=({targetLinear.Get(0, 0):F4},{targetLinear.Get(1, 0):F4},{targetLinear.Get(2, 0):F4}), " + + $"Y=({targetLinear.Get(0, 1):F4},{targetLinear.Get(1, 1):F4},{targetLinear.Get(2, 1):F4}), " + + $"Z=({targetLinear.Get(0, 2):F4},{targetLinear.Get(1, 2):F4},{targetLinear.Get(2, 2):F4})"); + + LogManager.Debug( + $"[模型姿态] {item.DisplayName} 增量: " + + $"X=({deltaLinear.Get(0, 0):F4},{deltaLinear.Get(1, 0):F4},{deltaLinear.Get(2, 0):F4}), " + + $"Y=({deltaLinear.Get(0, 1):F4},{deltaLinear.Get(1, 1):F4},{deltaLinear.Get(2, 1):F4}), " + + $"Z=({deltaLinear.Get(0, 2):F4},{deltaLinear.Get(1, 2):F4},{deltaLinear.Get(2, 2):F4}), " + + $"原始跟踪点=({originalTrackedPosition.X:F3},{originalTrackedPosition.Y:F3},{originalTrackedPosition.Z:F3}), " + + $"目标跟踪点=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " + + $"平移=({translation.X:F3},{translation.Y:F3},{translation.Z:F3})"); + } + catch (Exception ex) + { + LogManager.Warning($"[模型姿态] 输出诊断日志失败: {ex.Message}"); + } + } + + private static void LogAbsoluteTransformActual( + ModelItem item, + bool preserveCurrentScale, + Vector3D currentScale, + Point3D targetPosition, + Rotation3D targetRotation) + { + try + { + Transform3D actualTransform = item.Transform; + var actualComponents = actualTransform.Factor(); + var actualRotation = actualComponents.Rotation; + var actualScale = actualComponents.Scale; + var actualLinear = new Transform3D(actualRotation).Linear; + var targetLinear = new Transform3D(targetRotation).Linear; + var actualBounds = item.BoundingBox(); + Point3D actualCenter = actualBounds?.Center ?? new Point3D(0, 0, 0); + + LogManager.Debug( + $"[模型姿态应用后] {item.DisplayName} PreserveScale={preserveCurrentScale}, " + + $"输入Scale=({currentScale.X:F4},{currentScale.Y:F4},{currentScale.Z:F4}), " + + $"实际Scale=({actualScale.X:F4},{actualScale.Y:F4},{actualScale.Z:F4}), " + + $"目标中心=({targetPosition.X:F3},{targetPosition.Y:F3},{targetPosition.Z:F3}), " + + $"实际中心=({actualCenter.X:F3},{actualCenter.Y:F3},{actualCenter.Z:F3})"); + + LogManager.Debug( + $"[模型姿态应用后] {item.DisplayName} 目标Transform轴: " + + $"X=({targetLinear.Get(0, 0):F4},{targetLinear.Get(1, 0):F4},{targetLinear.Get(2, 0):F4}), " + + $"Y=({targetLinear.Get(0, 1):F4},{targetLinear.Get(1, 1):F4},{targetLinear.Get(2, 1):F4}), " + + $"Z=({targetLinear.Get(0, 2):F4},{targetLinear.Get(1, 2):F4},{targetLinear.Get(2, 2):F4})"); + + LogManager.Debug( + $"[模型姿态应用后] {item.DisplayName} 实际Transform轴: " + + $"X=({actualLinear.Get(0, 0):F4},{actualLinear.Get(1, 0):F4},{actualLinear.Get(2, 0):F4}), " + + $"Y=({actualLinear.Get(0, 1):F4},{actualLinear.Get(1, 1):F4},{actualLinear.Get(2, 1):F4}), " + + $"Z=({actualLinear.Get(0, 2):F4},{actualLinear.Get(1, 2):F4},{actualLinear.Get(2, 2):F4})"); + + LogGeometryLevelTransforms(item, "[模型姿态应用后][GeometryAPI]"); + } + catch (Exception ex) + { + LogManager.Warning($"[模型姿态应用后] 输出诊断日志失败: {ex.Message}"); + } + } + + private static void LogGeometryLevelTransforms(ModelItem item, string prefix) + { + if (item == null) + { + return; + } + + try + { + ModelGeometry geometry = item.FindFirstGeometry() ?? item.Geometry; + if (geometry == null) + { + LogManager.Debug($"{prefix} Geometry=null"); + return; + } + + LogManager.Debug( + $"{prefix} GeometryType={geometry.GetType().FullName}, " + + $"FragmentCount={geometry.FragmentCount}, " + + $"BoundsCenter=({geometry.BoundingBox.Center.X:F3},{geometry.BoundingBox.Center.Y:F3},{geometry.BoundingBox.Center.Z:F3})"); + + LogTransformProperty(geometry.OriginalTransform, $"{prefix} OriginalTransform"); + LogTransformProperty(geometry.PermanentOverrideTransform, $"{prefix} PermanentOverrideTransform"); + LogTransformProperty(geometry.PermanentTransform, $"{prefix} PermanentTransform"); + LogTransformProperty(geometry.ActiveTransform, $"{prefix} ActiveTransform"); + } + catch (Exception ex) + { + LogManager.Warning($"{prefix} 读取几何层当前变换失败: {ex.Message}"); + } + } + + private static void LogTransformProperty(Transform3D transform, string prefix) + { + if (transform == null) + { + LogManager.Debug($"{prefix}=null"); + return; + } + + try + { + var linear = transform.Linear; + var components = transform.Factor(); + LogManager.Debug( + $"{prefix}: " + + $"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}), " + + $"Z=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4}), " + + $"T=({components.Translation.X:F3},{components.Translation.Y:F3},{components.Translation.Z:F3})"); + } + catch (Exception ex) + { + LogManager.Warning($"{prefix} 读取失败: {ex.Message}"); + } + } + /// /// 将物体移动到指定位置和朝向,同时保持缩放比例 /// 专为虚拟物体设计,避免缩放被覆盖 @@ -186,18 +1183,14 @@ namespace NavisworksTransport.Utils // 获取CAD原始状态 var originalBounds = item.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); + var originalCenterPos = originalBounds.Center; var originalYaw = GetYawFromTransform(item.Transform); // 计算从CAD原始位置到目标位置的增量 var deltaPos = new Vector3D( - targetPosition.X - originalGroundPos.X, - targetPosition.Y - originalGroundPos.Y, - targetPosition.Z - originalGroundPos.Z + targetPosition.X - originalCenterPos.X, + targetPosition.Y - originalCenterPos.Y, + targetPosition.Z - originalCenterPos.Z ); double deltaYaw = targetYaw - originalYaw; @@ -219,13 +1212,13 @@ namespace NavisworksTransport.Utils { double cos = Math.Cos(deltaYaw); double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; + double rotatedX = originalCenterPos.X * cos - originalCenterPos.Y * sin; + double rotatedY = originalCenterPos.X * sin + originalCenterPos.Y * cos; components.Translation = new Vector3D( targetPosition.X - rotatedX, targetPosition.Y - rotatedY, - deltaPos.Z + targetPosition.Z - originalCenterPos.Z ); } else @@ -244,183 +1237,8 @@ namespace NavisworksTransport.Utils public class ObjectStateSnapshot { public Point3D Position { get; set; } - public double YawRadians { get; set; } - public Transform3D Transform { get; set; } - } - - /// - /// 保存物体当前状态 - /// 注意:YawRadians 通过当前位置计算,因为 Transform 返回的是CAD原始值 - /// - public static ObjectStateSnapshot SaveObjectState(ModelItem item, double? currentYaw = null) - { - if (item == null) return null; - - var bounds = item.BoundingBox(); - - // 🔥 关键:如果提供了当前朝向(如 PathAnimationManager._currentYaw),使用它 - // 否则从 Transform 获取(但这可能不是实际朝向) - double yaw; - if (currentYaw.HasValue) - { - yaw = currentYaw.Value; - } - else - { - // 尝试通过包围盒方向计算(如果物体有几何特征) - yaw = GetYawFromTransform(item.Transform); - LogManager.Debug($"[SaveObjectState] 未提供当前朝向,使用Transform: {yaw * 180 / Math.PI:F2}°"); - } - - return new ObjectStateSnapshot - { - Position = new Point3D(bounds.Center.X, bounds.Center.Y, bounds.Min.Z), - YawRadians = yaw, - Transform = item.Transform - }; - } - - /// - /// 从状态快照恢复物体位置和朝向(标准版本,会重置缩放) - /// - public static void RestoreObjectState(ModelItem item, ObjectStateSnapshot state) - { - RestoreObjectStateInternal(item, state, preserveScale: false); - } - - /// - /// 从状态快照恢复物体位置和朝向(保留缩放版本,用于虚拟物体) - /// - public static void RestoreObjectStateWithScale(ModelItem item, ObjectStateSnapshot state) - { - RestoreObjectStateInternal(item, state, preserveScale: true); - } - - /// - /// 从状态快照恢复物体位置的内部实现 - /// - private static void RestoreObjectStateInternal(ModelItem item, ObjectStateSnapshot state, bool preserveScale) - { - if (item == null || state == null) return; - - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { item }; - - // 1. 先回到CAD原始位置 - doc.Models.ResetPermanentTransform(modelItems); - - // 2. 从CAD原始位置移动到保存的位置 - // 获取CAD原始状态 - var originalBounds = item.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); - var originalYaw = GetYawFromTransform(item.Transform); - - // 计算到保存位置的增量 - var deltaPos = new Vector3D( - state.Position.X - originalGroundPos.X, - state.Position.Y - originalGroundPos.Y, - state.Position.Z - originalGroundPos.Z - ); - double deltaYaw = state.YawRadians - originalYaw; - - // 应用增量变换 - Transform3D transform; - if (Math.Abs(deltaYaw) > 0.001) - { - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; - - var compensatedTranslation = new Vector3D( - state.Position.X - rotatedX, - state.Position.Y - rotatedY, - deltaPos.Z - ); - - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - components.Translation = compensatedTranslation; - transform = components.Combine(); - } - else - { - transform = Transform3D.CreateTranslation(deltaPos); - } - - doc.Models.OverridePermanentTransform(modelItems, transform, false); - } - - /// - /// 将物体移动到指定位置和朝向,同时保持当前缩放(专为虚拟物体设计) - /// - public static void MoveItemToPositionAndYawWithCurrentScale(ModelItem item, Point3D targetPosition, double targetYaw) - { - var doc = Application.ActiveDocument; - var modelItems = new ModelItemCollection { item }; - - // 获取当前变换中的缩放 - var currentTransform = item.Transform; - var currentComponents = currentTransform.Factor(); - var currentScale = currentComponents.Scale; - - // 重置到CAD原始状态 - doc.Models.ResetPermanentTransform(modelItems); - - // 获取CAD原始状态 - var originalBounds = item.BoundingBox(); - var originalGroundPos = new Point3D( - originalBounds.Center.X, - originalBounds.Center.Y, - originalBounds.Min.Z - ); - var originalYaw = GetYawFromTransform(item.Transform); - - // 计算从CAD原始位置到目标位置的增量 - var deltaPos = new Vector3D( - targetPosition.X - originalGroundPos.X, - targetPosition.Y - originalGroundPos.Y, - targetPosition.Z - originalGroundPos.Z - ); - double deltaYaw = targetYaw - originalYaw; - - // 构建变换组件,保留原有缩放 - var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0)); - var components = identity.Factor(); - components.Scale = currentScale; // 保留当前缩放 - - if (Math.Abs(deltaYaw) > 0.001) - { - components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw); - } - - // 计算平移(考虑旋转带来的位置偏移) - if (Math.Abs(deltaYaw) > 0.001) - { - double cos = Math.Cos(deltaYaw); - double sin = Math.Sin(deltaYaw); - double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin; - double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos; - - components.Translation = new Vector3D( - targetPosition.X - rotatedX, - targetPosition.Y - rotatedY, - deltaPos.Z - ); - } - else - { - components.Translation = deltaPos; - } - - // 应用组合变换 - Transform3D transform = components.Combine(); - doc.Models.OverridePermanentTransform(modelItems, transform, false); + public Rotation3D Rotation { get; set; } + public bool HasCustomRotation { get; set; } } } } diff --git a/src/Utils/PathHelper.cs b/src/Utils/PathHelper.cs index 01c707c..655d35a 100644 --- a/src/Utils/PathHelper.cs +++ b/src/Utils/PathHelper.cs @@ -2,6 +2,7 @@ using System; using System.Drawing.Imaging; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using Autodesk.Navisworks.Api; namespace NavisworksTransport.Utils @@ -83,6 +84,40 @@ namespace NavisworksTransport.Utils return $"{prefix}_{timestamp}.{extension}"; } + /// + /// 生成碰撞检测报告文件名。 + /// 格式:碰撞检测报告_{路径名}_{时间戳}.{extension} + /// + public static string GenerateCollisionReportFileName(string pathName, string extension, DateTime? now = null) + { + string sanitizedName = SanitizeFileName(pathName ?? "未知路径"); + string normalizedExtension = string.IsNullOrWhiteSpace(extension) + ? "html" + : extension.Trim().TrimStart('.'); + string timestamp = (now ?? DateTime.Now).ToString("yyyyMMdd_HHmmss"); + return $"碰撞检测报告_{sanitizedName}_{timestamp}.{normalizedExtension}"; + } + + /// + /// 生成复制路径名称。 + /// 如果原名称以 MMdd_HHmmss 结尾,则重建时间戳;否则仅添加“副本_”前缀。 + /// + public static string BuildDuplicatedPathName(string originalName, DateTime? now = null) + { + var normalizedName = string.IsNullOrWhiteSpace(originalName) ? "路径" : originalName.Trim(); + var timestampPattern = new Regex(@"^(.*)_\d{4}_\d{6}$"); + var match = timestampPattern.Match(normalizedName); + + if (!match.Success) + { + return $"副本_{normalizedName}"; + } + + var baseName = match.Groups[1].Value; + var timestamp = (now ?? DateTime.Now).ToString("MMdd_HHmmss"); + return $"副本_{baseName}_{timestamp}"; + } + /// /// 计算从HTML文件到目标文件的相对路径 /// @@ -203,4 +238,4 @@ namespace NavisworksTransport.Utils } } } -} \ No newline at end of file +} diff --git a/src/Utils/RailPathPoseHelper.cs b/src/Utils/RailPathPoseHelper.cs new file mode 100644 index 0000000..39b7776 --- /dev/null +++ b/src/Utils/RailPathPoseHelper.cs @@ -0,0 +1,756 @@ +using System; +using System.Numerics; +using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils.CoordinateSystem; + +namespace NavisworksTransport.Utils +{ + /// + /// Rail 路径姿态辅助工具。 + /// LegacySuspensionPoint 模式下保持世界 Z 偏移; + /// RailCenterLine 模式下按路径切线推导局部法向,支持斜轨的轨上/轨下偏移。 + /// + public static class RailPathPoseHelper + { + private const double TangentEpsilon = 1e-9; + private static bool _rotationConstructorConventionLogged; + + /// + /// 将宿主坐标系中的 Rail 首选法向统一到宿主 up 半球,避免新建装配路径把“轨上侧”写反。 + /// + public static Vector3 NormalizePreferredNormalToHostUpHemisphere(Vector3 hostPreferredNormal, Vector3 hostUpVector) + { + if (hostPreferredNormal.LengthSquared() < 1e-9f) + { + throw new ArgumentException("Rail 首选法向长度过小,无法归一化。", nameof(hostPreferredNormal)); + } + + if (hostUpVector.LengthSquared() < 1e-9f) + { + throw new ArgumentException("宿主 up 向量长度过小,无法归一化。", nameof(hostUpVector)); + } + + Vector3 normalizedPreferredNormal = Vector3.Normalize(hostPreferredNormal); + Vector3 normalizedHostUp = Vector3.Normalize(hostUpVector); + if (Vector3.Dot(normalizedPreferredNormal, normalizedHostUp) < 0f) + { + normalizedPreferredNormal = -normalizedPreferredNormal; + } + + return normalizedPreferredNormal; + } + + /// + /// 计算构件底面中心相对于 Rail 参考点的 Z 偏移(模型单位)。 + /// + public static double GetBottomZOffset(PathRoute route, double objectHeight) + { + if (route == null || route.PathType != PathType.Rail) + { + return 0.0; + } + + if (PathRoute.IsTopReferenceFaceForMountMode(route.RailMountMode)) + { + return -objectHeight; + } + + return 0.0; + } + + /// + /// 计算通行空间中心相对于 Rail 参考点的 Z 偏移(模型单位)。 + /// 路径参考点位于构件顶面时,通行空间中心位于参考点下方半高; + /// 路径参考点位于构件底面时,通行空间中心位于参考点上方半高。 + /// + public static double GetObjectSpaceCenterZOffset(PathRoute route, double objectSpaceHeight) + { + if (route == null || route.PathType != PathType.Rail) + { + return 0.0; + } + + if (PathRoute.IsTopReferenceFaceForMountMode(route.RailMountMode)) + { + return -objectSpaceHeight / 2.0; + } + + return objectSpaceHeight / 2.0; + } + + /// + /// 根据 Rail 路径参考点计算构件底面中心位置。 + /// + public static Point3D ResolveBottomPosition(PathRoute route, Point3D referencePoint, double objectHeight) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePoint = adapter.ToCanonicalPoint(referencePoint); + var canonicalUp = HostCoordinateAdapter.CanonicalUp; + double bottomOffset = GetBottomOffsetMagnitude(route, objectHeight); + + var canonicalBottomPoint = new Point3D( + canonicalReferencePoint.X + canonicalUp.X * bottomOffset, + canonicalReferencePoint.Y + canonicalUp.Y * bottomOffset, + canonicalReferencePoint.Z + canonicalUp.Z * bottomOffset); + + return adapter.FromCanonicalPoint(canonicalBottomPoint); + } + + /// + /// 根据 Rail 路径参考点和相邻点计算构件底面中心位置。 + /// RailCenterLine 模式下沿轨道局部法向偏移,适用于斜轨。 + /// + public static Point3D ResolveBottomPosition( + PathRoute route, + Point3D referencePoint, + Point3D previousPoint, + Point3D nextPoint, + double objectHeight) + { + if (route == null || route.PathType != PathType.Rail) + { + return referencePoint; + } + + if (route.RailPathDefinitionMode == RailPathDefinitionMode.LegacySuspensionPoint) + { + return ResolveBottomPosition(route, referencePoint, objectHeight); + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePoint = adapter.ToCanonicalPoint(referencePoint); + var normal = ResolveRailNormal(previousPoint, referencePoint, nextPoint); + double bottomOffset = GetBottomOffsetMagnitude(route, objectHeight); + + var canonicalBottomPoint = new Point3D( + canonicalReferencePoint.X + normal.X * bottomOffset, + canonicalReferencePoint.Y + normal.Y * bottomOffset, + canonicalReferencePoint.Z + normal.Z * bottomOffset); + + return adapter.FromCanonicalPoint(canonicalBottomPoint); + } + + /// + /// 根据 Rail 路径参考点和相邻点计算通行空间中心位置。 + /// + public static Point3D ResolveObjectSpaceCenterPosition( + PathRoute route, + Point3D referencePoint, + Point3D previousPoint, + Point3D nextPoint, + double objectSpaceHeight) + { + if (route == null || route.PathType != PathType.Rail) + { + return referencePoint; + } + + double centerOffset = GetObjectSpaceCenterOffsetMagnitude(route, objectSpaceHeight); + + if (route.RailPathDefinitionMode == RailPathDefinitionMode.LegacySuspensionPoint) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePoint = adapter.ToCanonicalPoint(referencePoint); + var canonicalCenterPoint = new Point3D( + canonicalReferencePoint.X, + canonicalReferencePoint.Y, + canonicalReferencePoint.Z + centerOffset); + return adapter.FromCanonicalPoint(canonicalCenterPoint); + } + + var adapterForRail = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalReferencePointForRail = adapterForRail.ToCanonicalPoint(referencePoint); + if (!TryCreateCanonicalLocalFrame(route, previousPoint, referencePoint, nextPoint, out RailLocalFrame frame)) + { + var canonicalCenterPointForRailFallback = new Point3D( + canonicalReferencePointForRail.X, + canonicalReferencePointForRail.Y, + canonicalReferencePointForRail.Z + centerOffset); + return adapterForRail.FromCanonicalPoint(canonicalCenterPointForRailFallback); + } + + Vector3 trackedCenter = CanonicalRailOffsetResolver.ResolveTrackedCenter( + route, + ToNumerics(canonicalReferencePointForRail), + frame, + objectSpaceHeight); + var canonicalCenterPointForRail = new Point3D(trackedCenter.X, trackedCenter.Y, trackedCenter.Z); + return adapterForRail.FromCanonicalPoint(canonicalCenterPointForRail); + } + + internal static Vector3D CalculatePreservedTrackedCenterOffset( + PathRoute route, + Point3D actualTrackedCenter, + Point3D referencePoint, + Point3D previousPoint, + Point3D nextPoint, + double objectSpaceHeight) + { + Point3D semanticTrackedCenter = ResolveObjectSpaceCenterPosition( + route, + referencePoint, + previousPoint, + nextPoint, + objectSpaceHeight); + + return new Vector3D( + actualTrackedCenter.X - semanticTrackedCenter.X, + actualTrackedCenter.Y - semanticTrackedCenter.Y, + actualTrackedCenter.Z - semanticTrackedCenter.Z); + } + + internal static Vector3 CalculatePreservedTrackedCenterOffset(Vector3 actualTrackedCenter, Vector3 semanticTrackedCenter) + { + return actualTrackedCenter - semanticTrackedCenter; + } + + internal static Point3D ResolvePreservedTrackedCenterPosition( + PathRoute route, + Point3D referencePoint, + Point3D previousPoint, + Point3D nextPoint, + double objectSpaceHeight, + Vector3D preservedOffset) + { + Point3D semanticTrackedCenter = ResolveObjectSpaceCenterPosition( + route, + referencePoint, + previousPoint, + nextPoint, + objectSpaceHeight); + + return new Point3D( + semanticTrackedCenter.X + preservedOffset.X, + semanticTrackedCenter.Y + preservedOffset.Y, + semanticTrackedCenter.Z + preservedOffset.Z); + } + + internal static Vector3 ResolvePreservedTrackedCenterPosition(Vector3 semanticTrackedCenter, Vector3 preservedOffset) + { + return semanticTrackedCenter + preservedOffset; + } + + /// + /// 根据轨道路径切向和法向创建 Rail 构件完整三维姿态。 + /// 约定:构件本地 X 轴为前进方向,本地 Z 轴为上方向。 + /// 返回值是可直接用于 Transform3D 的线性矩阵。 + /// + public static bool TryCreateRailLinearTransform( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out Matrix3 linearTransform) + { + return TryCreateRailLinearTransform( + null, + previousPoint, + currentPoint, + nextPoint, + out linearTransform); + } + + public static bool TryCreateRailLinearTransform( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out Matrix3 linearTransform) + { + return TryCreateRailLinearTransform( + route, + previousPoint, + currentPoint, + nextPoint, + ModelAxisConvention.CreateRailAssetConvention(), + out linearTransform); + } + + /// + /// 根据轨道路径切向和法向创建 Rail 构件完整三维姿态。 + /// 通过模型局部轴约定显式指定本地哪个轴代表 forward/up。 + /// + public static bool TryCreateRailLinearTransform( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + out Matrix3 linearTransform) + { + return TryCreateRailLinearTransform( + route, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + 0.0, + out linearTransform); + } + + public static bool TryCreateRailLinearTransform( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + double localUpRotationDegrees, + out Matrix3 linearTransform) + { + return TryCreateRailLinearTransform( + route, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + LocalEulerRotationCorrection.FromConventionUpDegrees(axisConvention, localUpRotationDegrees), + out linearTransform); + } + + public static bool TryCreateRailLinearTransform( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + LocalEulerRotationCorrection correction, + out Matrix3 linearTransform) + { + linearTransform = null; + + if (axisConvention == null) + { + throw new ArgumentNullException(nameof(axisConvention)); + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalPreviousPoint = ToNumerics(adapter.ToCanonicalPoint(previousPoint)); + var canonicalCurrentPoint = ToNumerics(adapter.ToCanonicalPoint(currentPoint)); + var canonicalNextPoint = ToNumerics(adapter.ToCanonicalPoint(nextPoint)); + + if (!CanonicalRailPoseBuilder.TryCreateLocalFrame( + canonicalPreviousPoint, + canonicalCurrentPoint, + canonicalNextPoint, + HostCoordinateAdapter.CanonicalUpVector3, + ResolvePreferredNormal(route), + out RailLocalFrame frame)) + { + return false; + } + + var canonicalForward = frame.Forward; + var canonicalLateral = frame.Lateral; + var canonicalUp = frame.Normal; + Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(correction); + Quaternion canonicalRotation = CanonicalRailPoseBuilder.TryCreateQuaternion( + canonicalPreviousPoint, + canonicalCurrentPoint, + canonicalNextPoint, + HostCoordinateAdapter.CanonicalUpVector3, + axisConvention, + ResolvePreferredNormal(route), + correctionQuaternion, + out var correctedRotation) + ? correctedRotation + : axisConvention.CreateQuaternion(canonicalForward, canonicalUp); + Matrix4x4 canonicalLinear = Matrix4x4.CreateFromQuaternion(canonicalRotation); + + var hostXAxis = Normalize(adapter.FromCanonicalVector(ToNavVector(new Vector3(canonicalLinear.M11, canonicalLinear.M21, canonicalLinear.M31)))); + var hostYAxis = Normalize(adapter.FromCanonicalVector(ToNavVector(new Vector3(canonicalLinear.M12, canonicalLinear.M22, canonicalLinear.M32)))); + var hostZAxis = Normalize(adapter.FromCanonicalVector(ToNavVector(new Vector3(canonicalLinear.M13, canonicalLinear.M23, canonicalLinear.M33)))); + + // Navisworks 在 Transform3D(Matrix3, ...) 中按列读取局部基向量: + // 第 1 列 = 本地 X 轴在世界中的方向 + // 第 2 列 = 本地 Y 轴在世界中的方向 + // 第 3 列 = 本地 Z 轴在世界中的方向 + linearTransform = new Matrix3( + hostXAxis.X, hostYAxis.X, hostZAxis.X, + hostXAxis.Y, hostYAxis.Y, hostZAxis.Y, + hostXAxis.Z, hostYAxis.Z, hostZAxis.Z); + + LogManager.Debug( + $"[Rail姿态] Canonical切向=({canonicalForward.X:F4},{canonicalForward.Y:F4},{canonicalForward.Z:F4}), " + + $"Canonical侧向=({canonicalLateral.X:F4},{canonicalLateral.Y:F4},{canonicalLateral.Z:F4}), " + + $"Canonical法向=({canonicalUp.X:F4},{canonicalUp.Y:F4},{canonicalUp.Z:F4}), " + + $"Host法向=({hostZAxis.X:F4},{hostZAxis.Y:F4},{hostZAxis.Z:F4}), " + + $"模型Forward={axisConvention.ForwardAxis}, 模型Up={axisConvention.UpAxis}, 宿主轴旋转={correction}"); + return true; + } + + /// + /// 根据轨道路径切向和法向创建 Rail 构件完整三维旋转。 + /// 约定:构件本地 X 轴为前进方向,本地 Z 轴为上方向。 + /// + public static bool TryCreateRailRotation( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out Rotation3D rotation) + { + return TryCreateRailRotation( + null, + previousPoint, + currentPoint, + nextPoint, + out rotation); + } + + public static bool TryCreateRailRotation( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out Rotation3D rotation) + { + return TryCreateRailRotation( + route, + previousPoint, + currentPoint, + nextPoint, + ModelAxisConvention.CreateRailAssetConvention(), + out rotation); + } + + public static bool TryCreateRailRotation( + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + out Rotation3D rotation) + { + return TryCreateRailRotation( + null, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + out rotation); + } + + public static bool TryCreateRailRotation( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + out Rotation3D rotation) + { + return TryCreateRailRotation( + route, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + 0.0, + out rotation); + } + + public static bool TryCreateRailRotation( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + double localUpRotationDegrees, + out Rotation3D rotation) + { + return TryCreateRailRotation( + route, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + LocalEulerRotationCorrection.FromConventionUpDegrees(axisConvention, localUpRotationDegrees), + out rotation); + } + + public static bool TryCreateRailRotation( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + ModelAxisConvention axisConvention, + LocalEulerRotationCorrection correction, + out Rotation3D rotation) + { + rotation = Rotation3D.Identity; + LogRotationConstructorConventionOnce(); + + if (!TryCreateRailLinearTransform( + route, + previousPoint, + currentPoint, + nextPoint, + axisConvention, + correction, + out var linearTransform)) + { + return false; + } + + rotation = CreateRotationFromLinearTransform(linearTransform); + var verifiedLinear = new Transform3D(rotation).Linear; + + LogManager.Debug( + $"[Rail旋转验证] X=({verifiedLinear.Get(0, 0):F4},{verifiedLinear.Get(1, 0):F4},{verifiedLinear.Get(2, 0):F4}), " + + $"Y=({verifiedLinear.Get(0, 1):F4},{verifiedLinear.Get(1, 1):F4},{verifiedLinear.Get(2, 1):F4}), " + + $"Z=({verifiedLinear.Get(0, 2):F4},{verifiedLinear.Get(1, 2):F4},{verifiedLinear.Get(2, 2):F4})"); + return true; + } + + private static Rotation3D CreateRotationFromLinearTransform(Matrix3 linearTransform) + { + double m00 = linearTransform.Get(0, 0); + double m01 = linearTransform.Get(0, 1); + double m02 = linearTransform.Get(0, 2); + double m10 = linearTransform.Get(1, 0); + double m11 = linearTransform.Get(1, 1); + double m12 = linearTransform.Get(1, 2); + double m20 = linearTransform.Get(2, 0); + double m21 = linearTransform.Get(2, 1); + double m22 = linearTransform.Get(2, 2); + + double qx; + double qy; + double qz; + double qw; + + double trace = m00 + m11 + m22; + if (trace > 0.0) + { + double s = Math.Sqrt(trace + 1.0) * 2.0; + qw = 0.25 * s; + qx = (m21 - m12) / s; + qy = (m02 - m20) / s; + qz = (m10 - m01) / s; + } + else if (m00 > m11 && m00 > m22) + { + double s = Math.Sqrt(1.0 + m00 - m11 - m22) * 2.0; + qw = (m21 - m12) / s; + qx = 0.25 * s; + qy = (m01 + m10) / s; + qz = (m02 + m20) / s; + } + else if (m11 > m22) + { + double s = Math.Sqrt(1.0 + m11 - m00 - m22) * 2.0; + qw = (m02 - m20) / s; + qx = (m01 + m10) / s; + qy = 0.25 * s; + qz = (m12 + m21) / s; + } + else + { + double s = Math.Sqrt(1.0 + m22 - m00 - m11) * 2.0; + qw = (m10 - m01) / s; + qx = (m02 + m20) / s; + qy = (m12 + m21) / s; + qz = 0.25 * s; + } + + var rotation = new Rotation3D(qx, qy, qz, qw); + double error = CalculateLinearError(new Transform3D(rotation).Linear, linearTransform); + LogManager.Debug( + $"[Rail旋转验证] quaternion候选=({qx:F4},{qy:F4},{qz:F4},{qw:F4}), 误差={error:F6}"); + return rotation; + } + + private static void LogRotationConstructorConventionOnce() + { + if (_rotationConstructorConventionLogged) + { + return; + } + + _rotationConstructorConventionLogged = true; + + LogKnownAxisRotation("X90", new UnitVector3D(1, 0, 0), Math.PI / 2.0); + LogKnownAxisRotation("Y90", new UnitVector3D(0, 1, 0), Math.PI / 2.0); + LogKnownAxisRotation("Z90", new UnitVector3D(0, 0, 1), Math.PI / 2.0); + } + + private static void LogKnownAxisRotation(string name, UnitVector3D axis, double angle) + { + Rotation3D axisAngleRotation = new Rotation3D(axis, angle); + Rotation3D reconstructed = new Rotation3D( + axisAngleRotation.A, + axisAngleRotation.B, + axisAngleRotation.C, + axisAngleRotation.D); + Matrix3 linear = new Transform3D(reconstructed).Linear; + + LogManager.Info( + $"[Rotation构造测试] {name}: " + + $"A={axisAngleRotation.A:F6}, B={axisAngleRotation.B:F6}, C={axisAngleRotation.C:F6}, D={axisAngleRotation.D:F6}, " + + $"LinearX=({linear.Get(0, 0):F4},{linear.Get(1, 0):F4},{linear.Get(2, 0):F4}), " + + $"LinearY=({linear.Get(0, 1):F4},{linear.Get(1, 1):F4},{linear.Get(2, 1):F4}), " + + $"LinearZ=({linear.Get(0, 2):F4},{linear.Get(1, 2):F4},{linear.Get(2, 2):F4})"); + } + + private static double CalculateLinearError(Matrix3 actual, Matrix3 expected) + { + double error = 0.0; + for (int row = 0; row < 3; row++) + { + for (int column = 0; column < 3; column++) + { + double delta = actual.Get(row, column) - expected.Get(row, column); + error += delta * delta; + } + } + + return Math.Sqrt(error); + } + + private static double GetBottomOffsetMagnitude(PathRoute route, double objectHeight) + { + double baseOffset = PathRoute.IsTopReferenceFaceForMountMode(route.RailMountMode) + ? -objectHeight + : 0.0; + return baseOffset + route.RailNormalOffset; + } + + private static double GetObjectSpaceCenterOffsetMagnitude(PathRoute route, double objectSpaceHeight) + { + double baseOffset = PathRoute.IsTopReferenceFaceForMountMode(route.RailMountMode) + ? -objectSpaceHeight / 2.0 + : objectSpaceHeight / 2.0; + return baseOffset + route.RailNormalOffset; + } + + private static Vector3D ResolveRailNormal(Point3D previousPoint, Point3D currentPoint, Point3D nextPoint) + { + if (TryCreateCanonicalLocalFrame(null, previousPoint, currentPoint, nextPoint, out RailLocalFrame frame)) + { + return ToNavVector(frame.Normal); + } + + return HostCoordinateAdapter.CanonicalUp; + } + + private static Vector3D ResolveTangent(Point3D previousPoint, Point3D currentPoint, Point3D nextPoint) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + var canonicalPreviousPoint = adapter.ToCanonicalPoint(previousPoint); + var canonicalCurrentPoint = adapter.ToCanonicalPoint(currentPoint); + var canonicalNextPoint = adapter.ToCanonicalPoint(nextPoint); + + var tangent = new Vector3D( + canonicalNextPoint.X - canonicalPreviousPoint.X, + canonicalNextPoint.Y - canonicalPreviousPoint.Y, + canonicalNextPoint.Z - canonicalPreviousPoint.Z); + + double tangentLengthSquared = tangent.X * tangent.X + tangent.Y * tangent.Y + tangent.Z * tangent.Z; + if (tangentLengthSquared < TangentEpsilon) + { + tangent = new Vector3D( + canonicalNextPoint.X - canonicalCurrentPoint.X, + canonicalNextPoint.Y - canonicalCurrentPoint.Y, + canonicalNextPoint.Z - canonicalCurrentPoint.Z); + } + + return tangent; + } + + private static bool TryCreateCanonicalLocalFrame( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out RailLocalFrame frame) + { + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + return CanonicalRailPoseBuilder.TryCreateLocalFrame( + ToNumerics(adapter.ToCanonicalPoint(previousPoint)), + ToNumerics(adapter.ToCanonicalPoint(currentPoint)), + ToNumerics(adapter.ToCanonicalPoint(nextPoint)), + HostCoordinateAdapter.CanonicalUpVector3, + ResolvePreferredNormal(route), + out frame); + } + + /// + /// 获取 Rail 路径在宿主坐标系中的目标框架。 + /// 统一供真实物体姿态解释层之后的路径目标框架消费。 + /// + public static bool TryCreateHostLocalFrame( + PathRoute route, + Point3D previousPoint, + Point3D currentPoint, + Point3D nextPoint, + out RailLocalFrame hostFrame) + { + hostFrame = null; + + if (!TryCreateCanonicalLocalFrame(route, previousPoint, currentPoint, nextPoint, out var canonicalFrame)) + { + return false; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + hostFrame = new RailLocalFrame( + adapter.FromCanonicalVector3(canonicalFrame.Forward), + adapter.FromCanonicalVector3(canonicalFrame.Lateral), + adapter.FromCanonicalVector3(canonicalFrame.Normal)); + return true; + } + + private static Vector3? ResolvePreferredNormal(PathRoute route) + { + if (route?.RailPreferredNormal == null) + { + return null; + } + + var preferredNormal = new Vector3( + (float)route.RailPreferredNormal.X, + (float)route.RailPreferredNormal.Y, + (float)route.RailPreferredNormal.Z); + + if (preferredNormal.LengthSquared() < 1e-9f) + { + return null; + } + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 canonicalPreferredNormal = adapter.ToCanonicalVector3(preferredNormal); + if (canonicalPreferredNormal.LengthSquared() < 1e-9f) + { + return null; + } + + return Vector3.Normalize(canonicalPreferredNormal); + } + + private static Vector3D Normalize(Vector3D vector) + { + double lengthSquared = vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z; + if (lengthSquared < TangentEpsilon) + { + return new Vector3D(0, 0, 0); + } + + double length = Math.Sqrt(lengthSquared); + return new Vector3D(vector.X / length, vector.Y / length, vector.Z / length); + } + + 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 Vector3 ToNumerics(Point3D point) + { + return new Vector3((float)point.X, (float)point.Y, (float)point.Z); + } + + private static Vector3D ToNavVector(Vector3 vector) + { + return new Vector3D(vector.X, vector.Y, vector.Z); + } + } +} diff --git a/src/Utils/SectionClipHelper.cs b/src/Utils/SectionClipHelper.cs index e74f68c..1bd7757 100644 --- a/src/Utils/SectionClipHelper.cs +++ b/src/Utils/SectionClipHelper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Autodesk.Navisworks.Api; namespace NavisworksTransport.Utils @@ -131,6 +132,78 @@ namespace NavisworksTransport.Utils } } + /// + /// 根据模型项集合设置剖面盒 + /// + /// 模型项集合 + /// 水平边距(米) + /// 高度边距(米,上下各延伸) + /// 是否成功设置 + public static bool SetClipBoxByModelItems( + IEnumerable modelItems, + double marginInMeters = 2.0, + double heightMarginInMeters = 2.0) + { + try + { + if (modelItems == null) + { + LogManager.Warning("[剖面盒] 模型项集合为空"); + return false; + } + + var validItems = modelItems.Where(item => item != null).ToList(); + if (validItems.Count == 0) + { + LogManager.Warning("[剖面盒] 没有可用的模型项,无法设置剖面盒"); + return false; + } + + bool hasBounds = false; + double minX = double.MaxValue, minY = double.MaxValue, minZ = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue, maxZ = double.MinValue; + + foreach (var item in validItems) + { + BoundingBox3D bounds = item.BoundingBox(); + minX = Math.Min(minX, bounds.Min.X); + minY = Math.Min(minY, bounds.Min.Y); + minZ = Math.Min(minZ, bounds.Min.Z); + maxX = Math.Max(maxX, bounds.Max.X); + maxY = Math.Max(maxY, bounds.Max.Y); + maxZ = Math.Max(maxZ, bounds.Max.Z); + hasBounds = true; + } + + if (!hasBounds) + { + LogManager.Warning("[剖面盒] 未能从模型项中计算有效包围盒"); + return false; + } + + double metersToUnits = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); + double margin = marginInMeters * metersToUnits; + double heightMargin = heightMarginInMeters * metersToUnits; + + var selectionBounds = new BoundingBox3D( + new Point3D(minX, minY, minZ), + new Point3D(maxX, maxY, maxZ)); + var clipBox = ExpandBoundingBox(selectionBounds, margin, heightMargin); + + ApplyClipBox(clipBox); + + LogManager.Info($"[剖面盒] 已按选择对象设置 - 对象数: {validItems.Count}, 水平边距: {marginInMeters}m, 高度边距: {heightMarginInMeters}m, " + + $"范围: X[{clipBox.Min.X:F2}, {clipBox.Max.X:F2}], Y[{clipBox.Min.Y:F2}, {clipBox.Max.Y:F2}], Z[{clipBox.Min.Z:F2}, {clipBox.Max.Z:F2}]"); + + return true; + } + catch (Exception ex) + { + LogManager.Error($"[剖面盒] 按选择对象设置失败: {ex.Message}"); + return false; + } + } + /// /// 清除剖面盒(显示全部模型) /// 使用 JSON 字符串方式设置 Enabled = false diff --git a/src/Utils/SelectionClipBoxLockState.cs b/src/Utils/SelectionClipBoxLockState.cs new file mode 100644 index 0000000..22d0060 --- /dev/null +++ b/src/Utils/SelectionClipBoxLockState.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NavisworksTransport.Utils +{ + /// + /// 管理“按选择对象聚焦”的剖面盒锁定状态。 + /// 一旦剖面盒按当前选择启用,就保持该场景稳定,直到用户主动关闭。 + /// + public class SelectionClipBoxLockState + { + private IReadOnlyList _lockedSelection = Array.Empty(); + + public bool IsLocked => _lockedSelection.Count > 0; + + public int LockedSelectionCount => _lockedSelection.Count; + + public void LockSelection(IEnumerable selectedItems) + { + if (selectedItems == null) + { + throw new ArgumentNullException(nameof(selectedItems)); + } + + var lockedItems = selectedItems + .Cast() + .Where(item => item != null) + .ToList(); + + if (lockedItems.Count == 0) + { + throw new InvalidOperationException("锁定剖面盒前必须提供至少一个选中对象。"); + } + + _lockedSelection = lockedItems; + } + + public bool ShouldRefreshOnSelectionChanged(bool isSelectionClipBoxMode) + { + return isSelectionClipBoxMode && !IsLocked; + } + + public void Clear() + { + _lockedSelection = Array.Empty(); + } + } +} diff --git a/src/Utils/ViewpointHelper.cs b/src/Utils/ViewpointHelper.cs index 4ce8656..5afb99a 100644 --- a/src/Utils/ViewpointHelper.cs +++ b/src/Utils/ViewpointHelper.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using Autodesk.Navisworks.Api; +using NavisworksTransport.Core.Config; +using NavisworksTransport.Utils.CoordinateSystem; namespace NavisworksTransport.Utils { @@ -11,6 +14,48 @@ namespace NavisworksTransport.Utils /// public static class ViewpointHelper { + public enum ViewpointStrategy + { + PathGroundSelection, + PathHoistingSelection, + PathRailSelection, + ModelFocus, + CollisionCloseUp + } + + internal enum PathCameraHorizontalMode + { + Top, + Side, + Rear + } + + internal struct PathViewpointProfile + { + public PathViewpointProfile(double cameraDistanceMeters, double elevationDegrees, PathCameraHorizontalMode horizontalMode) + { + CameraDistanceMeters = cameraDistanceMeters; + ElevationDegrees = elevationDegrees; + HorizontalMode = horizontalMode; + } + + public double CameraDistanceMeters { get; } + public double ElevationDegrees { get; } + public PathCameraHorizontalMode HorizontalMode { get; } + } + + internal struct FocusViewpointProfile + { + public FocusViewpointProfile(double viewAngleDegrees, double targetViewRatio) + { + ViewAngleDegrees = viewAngleDegrees; + TargetViewRatio = targetViewRatio; + } + + public double ViewAngleDegrees { get; } + public double TargetViewRatio { get; } + } + /// /// 包围盒扩展系数(用于 ZoomBox 调整视野范围) /// @@ -32,6 +77,34 @@ namespace NavisworksTransport.Utils AdjustViewpointToPathCenter(path); } + /// + /// 按路径类型调整视角。 + /// Ground 使用俯视; + /// Hoisting 使用后上方视角; + /// Rail 使用侧面轻俯视角。 + /// + public static void AdjustViewpointForPath(PathRoute path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + switch (path.PathType) + { + case PathType.Hoisting: + AdjustDirectionalViewpointToPath(path, ResolvePathViewpointProfile(ViewpointStrategy.PathHoistingSelection)); + return; + case PathType.Rail: + AdjustDirectionalViewpointToPath(path, ResolvePathViewpointProfile(ViewpointStrategy.PathRailSelection)); + return; + case PathType.Ground: + default: + AdjustViewpointToPathCenter(path); + return; + } + } + /// /// 调整视角到路径中心,确保整个路径都在视野内 /// @@ -66,6 +139,68 @@ namespace NavisworksTransport.Utils AdjustViewpointToBoundingBox(startEndBoundingBox, pathBoundingBox, baseDimension); } + private static void AdjustDirectionalViewpointToPath(PathRoute path, PathViewpointProfile profile) + { + Document doc = Application.ActiveDocument; + if (doc == null || doc.IsClear) + { + throw new InvalidOperationException("没有活动的文档"); + } + + if (path?.Points == null || path.Points.Count == 0) + { + throw new ArgumentException("路径为空或没有路径点", nameof(path)); + } + + Point3D startPoint = path.Points[0].Position; + Point3D endPoint = path.Points[path.Points.Count - 1].Position; + BoundingBox3D focusBoundingBox = ResolveDirectionalFocusBoundingBox(path); + Point3D focusCenter = GetBoundingBoxCenter(focusBoundingBox); + + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3 hostUp = ToVector3(adapter.HostUpVector); + Vector3 rawPathDirection = new Vector3( + (float)(endPoint.X - startPoint.X), + (float)(endPoint.Y - startPoint.Y), + (float)(endPoint.Z - startPoint.Z)); + Vector3 fallbackForward = ToVector3(ProjectVectorOntoPlane(doc.FrontRightTopViewVector, adapter.HostUpVector)); + Vector3 horizontalForward = ResolveHorizontalPathForward(rawPathDirection, hostUp, fallbackForward); + Vector3 cameraOffsetDirection = ResolveCameraOffsetDirection(profile, hostUp, horizontalForward); + double cameraDistance = UnitsConverter.ConvertFromMeters(profile.CameraDistanceMeters); + Point3D cameraPosition = new Point3D( + focusCenter.X + cameraOffsetDirection.X * (float)cameraDistance, + focusCenter.Y + cameraOffsetDirection.Y * (float)cameraDistance, + focusCenter.Z + cameraOffsetDirection.Z * (float)cameraDistance); + + ApplyViewpoint( + cameraPosition, + focusCenter, + new Vector3D(hostUp.X, hostUp.Y, hostUp.Z), + useAlignDirection: true); + + LogManager.Info( + $"视角已调整完成: 类型={path.PathType}, 焦点=({focusCenter.X:F2},{focusCenter.Y:F2},{focusCenter.Z:F2}), " + + $"相机=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}), " + + $"距离={UnitsConverter.ConvertToMeters(cameraDistance):F2}米, elevation={profile.ElevationDegrees:F1}°, 模式={profile.HorizontalMode}"); + } + + internal static BoundingBox3D ResolveDirectionalFocusBoundingBox(PathRoute path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + if (path.Points == null || path.Points.Count == 0) + { + throw new ArgumentException("路径为空或没有路径点", nameof(path)); + } + + // 侧视路径应聚焦整条路径包络,而不是只看首尾点。 + // 对吊装路径尤其重要:首尾点常在地面,若只取首尾中心,高处主路径会被挤到画面上半部。 + return CalculatePathBoundingBox(path); + } + /// /// 调整视角到指定的包围盒(通用方法) /// 设置俯视位置,确保包围盒完整显示在视野中 @@ -82,6 +217,10 @@ namespace NavisworksTransport.Utils throw new InvalidOperationException("没有活动的文档"); } + var adapter = CoordinateSystemManager.Instance.CreateHostAdapter(); + Vector3D hostUp = adapter.HostUpVector; + Vector3D screenUp = adapter.FromCanonicalVector(new Vector3D(0, 1, 0)); + // 1. 计算聚焦包围盒中心(相机指向的位置) Point3D focusCenter = new Point3D( (focusBoundingBox.Min.X + focusBoundingBox.Max.X) / 2, @@ -93,7 +232,10 @@ namespace NavisworksTransport.Utils // 将基准尺寸(米)转换为模型单位 double baseDimensionInModelUnits = UnitsConverter.ConvertFromMeters(baseDimension); double cameraDistance = baseDimensionInModelUnits; // 相机距离设置为基准尺寸的1倍(可调整比例以获得更好视野) - Point3D cameraPosition = new Point3D(focusCenter.X, focusCenter.Y, focusCenter.Z + cameraDistance); + Point3D cameraPosition = new Point3D( + focusCenter.X + hostUp.X * cameraDistance, + focusCenter.Y + hostUp.Y * cameraDistance, + focusCenter.Z + hostUp.Z * cameraDistance); // 将相机距离转换回米用于日志显示 double cameraDistanceMeters = UnitsConverter.ConvertToMeters(cameraDistance); @@ -105,10 +247,12 @@ namespace NavisworksTransport.Utils double expansionMargin = boxWidth * (expansionFactor - 1) / 2; // 4. 应用视角(使用通用方法,带 ZoomBox) - Vector3D upVector = new Vector3D(0, 1, 0); // Y轴向上 - ApplyViewpointWithZoomBox(cameraPosition, focusCenter, upVector, viewBoundingBox, expansionMargin); + ApplyViewpointWithZoomBox(cameraPosition, focusCenter, screenUp, viewBoundingBox, expansionMargin); - LogManager.Info($"视角已调整完成: 俯视图(已摆正), 基准尺寸={baseDimension:F2}米, 相机距离={cameraDistanceMeters:F2}米, FOV=自动, 包围盒扩展系数={VIEW_BOUNDING_BOX_EXPANSION_FACTOR:F1}, 扩展边距={expansionMargin:F2}米"); + LogManager.Info( + $"视角已调整完成: 俯视图(已摆正), HostUp=({hostUp.X:F2},{hostUp.Y:F2},{hostUp.Z:F2}), " + + $"ScreenUp=({screenUp.X:F2},{screenUp.Y:F2},{screenUp.Z:F2}), 基准尺寸={baseDimension:F2}米, " + + $"相机距离={cameraDistanceMeters:F2}米, FOV=自动, 包围盒扩展系数={VIEW_BOUNDING_BOX_EXPANSION_FACTOR:F1}, 扩展边距={expansionMargin:F2}米"); } /// @@ -152,6 +296,154 @@ namespace NavisworksTransport.Utils return bounds; } + internal static PathViewpointProfile ResolvePathViewpointProfile(PathType pathType) + { + switch (pathType) + { + case PathType.Hoisting: + return ResolvePathViewpointProfile(ViewpointStrategy.PathHoistingSelection); + case PathType.Rail: + return ResolvePathViewpointProfile(ViewpointStrategy.PathRailSelection); + case PathType.Ground: + default: + return ResolvePathViewpointProfile(ViewpointStrategy.PathGroundSelection); + } + } + + internal static PathViewpointProfile ResolvePathViewpointProfile(ViewpointStrategy strategy) + { + PathEditingConfig config = ConfigManager.Instance.Current?.PathEditing ?? new PathEditingConfig(); + switch (strategy) + { + case ViewpointStrategy.PathHoistingSelection: + return new PathViewpointProfile( + cameraDistanceMeters: config.HoistingViewDistanceMeters > 0 ? config.HoistingViewDistanceMeters : 6.0, + elevationDegrees: config.HoistingViewElevationDegrees > 0 ? config.HoistingViewElevationDegrees : 30.0, + horizontalMode: PathCameraHorizontalMode.Side); + case ViewpointStrategy.PathRailSelection: + return new PathViewpointProfile( + cameraDistanceMeters: config.RailViewDistanceMeters > 0 ? config.RailViewDistanceMeters : 6.0, + elevationDegrees: config.RailViewElevationDegrees > 0 ? config.RailViewElevationDegrees : 30.0, + horizontalMode: PathCameraHorizontalMode.Side); + case ViewpointStrategy.PathGroundSelection: + default: + return new PathViewpointProfile( + cameraDistanceMeters: 12.0, + elevationDegrees: 90.0, + horizontalMode: PathCameraHorizontalMode.Top); + } + } + + internal static FocusViewpointProfile ResolveFocusViewpointProfile(ViewpointStrategy strategy) + { + switch (strategy) + { + case ViewpointStrategy.CollisionCloseUp: + return new FocusViewpointProfile(viewAngleDegrees: 60.0, targetViewRatio: 0.25); + case ViewpointStrategy.ModelFocus: + default: + return new FocusViewpointProfile(viewAngleDegrees: 60.0, targetViewRatio: 0.25); + } + } + + internal static Vector3 ResolveHorizontalPathForward(Vector3 rawPathDirection, Vector3 hostUp, Vector3 fallbackForward) + { + Vector3 projected = ProjectOntoPlane(rawPathDirection, hostUp); + if (projected.LengthSquared() > 1e-8f) + { + return Vector3.Normalize(projected); + } + + Vector3 fallbackProjected = ProjectOntoPlane(fallbackForward, hostUp); + if (fallbackProjected.LengthSquared() > 1e-8f) + { + return Vector3.Normalize(fallbackProjected); + } + + Vector3 canonicalFallback = Math.Abs(Vector3.Dot(Vector3.UnitX, hostUp)) > 0.9f + ? Vector3.UnitY + : Vector3.UnitX; + return Vector3.Normalize(ProjectOntoPlane(canonicalFallback, hostUp)); + } + + internal static Vector3 ResolveCameraOffsetDirection(PathViewpointProfile profile, Vector3 hostUp, Vector3 horizontalForward) + { + float elevationRadians = (float)(profile.ElevationDegrees * Math.PI / 180.0); + float horizontalWeight = (float)Math.Cos(elevationRadians); + float verticalWeight = (float)Math.Sin(elevationRadians); + + Vector3 horizontalDirection; + switch (profile.HorizontalMode) + { + case PathCameraHorizontalMode.Rear: + horizontalDirection = -horizontalForward; + break; + case PathCameraHorizontalMode.Side: + horizontalDirection = Vector3.Cross(hostUp, horizontalForward); + if (horizontalDirection.LengthSquared() <= 1e-8f) + { + horizontalDirection = -horizontalForward; + } + break; + default: + horizontalDirection = hostUp; + break; + } + + Vector3 offset = horizontalDirection * horizontalWeight + hostUp * verticalWeight; + return Vector3.Normalize(offset); + } + + private static BoundingBox3D CreateBoundingBoxFromPoints(Point3D pointA, Point3D pointB) + { + return new BoundingBox3D( + new Point3D( + Math.Min(pointA.X, pointB.X), + Math.Min(pointA.Y, pointB.Y), + Math.Min(pointA.Z, pointB.Z)), + new Point3D( + Math.Max(pointA.X, pointB.X), + Math.Max(pointA.Y, pointB.Y), + Math.Max(pointA.Z, pointB.Z))); + } + + private static Point3D GetBoundingBoxCenter(BoundingBox3D boundingBox) + { + return new Point3D( + (boundingBox.Min.X + boundingBox.Max.X) / 2.0, + (boundingBox.Min.Y + boundingBox.Max.Y) / 2.0, + (boundingBox.Min.Z + boundingBox.Max.Z) / 2.0); + } + + private static double GetMaxDimension(BoundingBox3D boundingBox) + { + return Math.Max( + boundingBox.Max.X - boundingBox.Min.X, + Math.Max( + boundingBox.Max.Y - boundingBox.Min.Y, + boundingBox.Max.Z - boundingBox.Min.Z)); + } + + private static Vector3D ProjectVectorOntoPlane(Vector3D vector, Vector3D planeNormal) + { + double dot = vector.X * planeNormal.X + vector.Y * planeNormal.Y + vector.Z * planeNormal.Z; + return new Vector3D( + vector.X - planeNormal.X * dot, + vector.Y - planeNormal.Y * dot, + vector.Z - planeNormal.Z * dot); + } + + private static Vector3 ProjectOntoPlane(Vector3 vector, Vector3 planeNormal) + { + float dot = Vector3.Dot(vector, planeNormal); + return vector - planeNormal * dot; + } + + private static Vector3 ToVector3(Vector3D vector) + { + return new Vector3((float)vector.X, (float)vector.Y, (float)vector.Z); + } + /// /// 计算所有碰撞位置的总包围盒 /// @@ -391,6 +683,42 @@ namespace NavisworksTransport.Utils } } + public static void FocusOnModelItem(ModelItem modelItem, ViewpointStrategy strategy) + { + FocusViewpointProfile profile = ResolveFocusViewpointProfile(strategy); + FocusOnModelItem(modelItem, profile.ViewAngleDegrees, profile.TargetViewRatio); + } + + public static void FocusOnModelItems(IEnumerable modelItems, ViewpointStrategy strategy) + { + if (modelItems == null) + { + throw new ArgumentNullException(nameof(modelItems)); + } + + List items = modelItems.Where(item => item != null).ToList(); + if (items.Count == 0) + { + throw new ArgumentException("至少需要一个模型元素", nameof(modelItems)); + } + + BoundingBox3D combinedBounds = items[0].BoundingBox(); + for (int i = 1; i < items.Count; i++) + { + BoundingBox3D itemBounds = items[i].BoundingBox(); + combinedBounds.Extend(itemBounds.Min); + combinedBounds.Extend(itemBounds.Max); + } + + Point3D center = new Point3D( + (combinedBounds.Min.X + combinedBounds.Max.X) / 2.0, + (combinedBounds.Min.Y + combinedBounds.Max.Y) / 2.0, + (combinedBounds.Min.Z + combinedBounds.Max.Z) / 2.0); + double targetSize = GetMaxDimension(combinedBounds); + + FocusOnPosition(center, targetSize, strategy); + } + /// /// 聚焦到多个模型元素(如碰撞中的两个对象) /// @@ -458,6 +786,12 @@ namespace NavisworksTransport.Utils } } + public static void FocusOnCollision(ModelItem item1, ModelItem item2, ViewpointStrategy strategy) + { + FocusViewpointProfile profile = ResolveFocusViewpointProfile(strategy); + FocusOnCollision(item1, item2, profile.ViewAngleDegrees, profile.TargetViewRatio); + } + /// /// 聚焦到指定位置和尺寸 /// @@ -489,6 +823,12 @@ namespace NavisworksTransport.Utils } } + public static void FocusOnPosition(Point3D center, double targetSize, ViewpointStrategy strategy) + { + FocusViewpointProfile profile = ResolveFocusViewpointProfile(strategy); + FocusOnPosition(center, targetSize, profile.ViewAngleDegrees, profile.TargetViewRatio); + } + /// /// 计算相机位置(使用模型标准前右上视角) /// 基于 Navisworks 的标准 FrontRightTopViewVector 确定相机方向 @@ -518,4 +858,4 @@ namespace NavisworksTransport.Utils #endregion } -} \ No newline at end of file +} diff --git a/start-navisworks.bat b/start-navisworks.bat new file mode 100644 index 0000000..fcffa3c --- /dev/null +++ b/start-navisworks.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start-navisworks.ps1" %* + +if errorlevel 1 exit /b 1 +exit /b 0