Merge codex/rail-mount-modes into stable

This commit is contained in:
tian 2026-05-13 09:45:31 +08:00
commit 928edb8328
187 changed files with 34806 additions and 3676 deletions

View File

@ -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.

View File

@ -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

View File

@ -0,0 +1,53 @@
# 几何问题排查清单
## 先问自己
1. 这是虚拟物体还是真实物体?
2. 路径类型是 `Ground / Hoisting / Rail` 哪一种?
3. 错的是:
- 旋转轴
- 起点位置
- 逐帧位置
- 通行空间
- 终点诊断
## 日志优先看什么
### 平面路径
- `[移动到起点] 路径方向yaw`
- `[动画姿态入口] ... 目标姿态`
- `[模型增量姿态] ... 当前/目标/增量`
### Rail
- `[移动到起点] Rail旋转姿态`
- `RailPathPoseHelper` 相关诊断
### 虚拟物体
- `[虚拟物体姿态] 应用前`
- `[虚拟物体姿态] 应用后`
- 关注 `BoundingBox.Center` 是否符合资源原点预期
## 需要同步检查的链
出现几何问题时,至少对齐这几条:
1. 起点落位
2. 逐帧位置
3. 通行空间
4. 终点诊断
如果其中一条使用的是“原始高度”,另一条使用的是“旋转后法线尺寸”,就很容易出现看起来互相矛盾的问题。
## 测试建议
至少补一个最短链测试:
- `YUp``ZUp`
- 指定单一路径类型
- 指定单个旋转轴
- 验证尺寸或姿态的关键不变量
能测数学层,就不要先靠现场猜。

View File

@ -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.

View File

@ -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 应用变换的方法是否有问题

View File

@ -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 文档搜索方法

View File

@ -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 项目工具类使用指南
## 概述

2
.gitignore vendored
View File

@ -15,3 +15,5 @@ navisworks_api/
*.exe
*.db
.codex-temp

1215
AGENTS.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">x64</Platform>
<ProjectGuid>{7DDB41A4-A10B-4EA4-A658-0D5A9178A1F5}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>NavisworksTransport.UnitTests</RootNamespace>
<AssemblyName>NavisworksTransport.UnitTests</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x64\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
<OutputPath>bin\x64\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<ItemGroup>
<Reference Include="Autodesk.Navisworks.Api">
<HintPath>..\..\..\..\Program Files\Autodesk\Navisworks Manage 2026\Autodesk.Navisworks.Api.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework">
<HintPath>packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.dll</HintPath>
</Reference>
<Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions">
<HintPath>packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Numerics" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="UnitTests\Core\PathHelperTests.cs" />
<Compile Include="UnitTests\Core\SelectionClipBoxLockStateTests.cs" />
<Compile Include="UnitTests\Core\PathPlanningManagerHoistingCompletionTests.cs" />
<Compile Include="UnitTests\Core\PathPersistenceTests.cs" />
<Compile Include="UnitTests\Core\PathRouteCloneTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\AutoPathPlanningCoordinateSemanticsTests.cs" />
<Compile Include="UnitTests\Integration\AutoPathGridGenerationAutomationTests.cs" />
<Compile Include="UnitTests\Integration\NavisworksTestAutomationClient.cs" />
<Compile Include="UnitTests\Integration\VirtualCollisionAutomationTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\HostCoordinateAdapterTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\CanonicalRailPoseBuilderTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\CanonicalPlanarPoseBuilderTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\ObjectSpaceOrientationHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\ObjectStartPlacementRequestTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\CanonicalRailOffsetResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\CanonicalTrackedPositionResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\GroundPathObjectLiftOffsetTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\GroundPassageSpaceOffsetTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\FragmentRepresentativePoseHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\HoistingCoordinateHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\HoistingRealObjectPoseHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\ModelAxisConventionTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\ProjectReferenceFrameTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RealObjectPlanarPoseSolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RealObjectProjectedExtentResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RealObjectReferencePoseResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RealObjectRailAxisConventionResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RealObjectRailExtentResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RotatedObjectExtentHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\PathTargetFrameResolverTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\AssemblyEndFaceAnalyzerTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\AssemblyInstallationReferenceBuilderTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\RailAssemblyWorkflowContextTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\ViewpointHelperTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\PathPointVisualizationTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\FragmentDefaultUpContextTests.cs" />
<Compile Include="UnitTests\CoordinateSystem\VirtualGroundPoseCharacterizationTests.cs" />
<Compile Include="UnitTests\Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="TransportPlugin.csproj">
<Project>{1A0124F6-3DEB-4153-8760-F568AD9393EE}</Project>
<Name>TransportPlugin</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -31,4 +31,5 @@ using System.Runtime.InteropServices;
//
// 主版本和次版本手动维护Build和Revision在编译时自动更新
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: InternalsVisibleTo("NavisworksTransport.UnitTests")]

View File

@ -63,6 +63,7 @@
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Numerics" />
<Reference Include="System.Web.Extensions" />
<Reference Include="Newtonsoft.Json">
<HintPath>packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
@ -133,10 +134,13 @@
<Compile Include="src\Core\IdleEventManager.cs" />
<!-- Core - Document State Management -->
<Compile Include="src\Core\DocumentStateManager.cs" />
<Compile Include="src\Core\AssemblyReferencePathManager.cs" />
<!-- Core - Virtual Object Management -->
<Compile Include="src\Core\VirtualObjectManager.cs" />
<!-- Core - Section Box Export -->
<Compile Include="src\Core\SectionBoxExporter.cs" />
<Compile Include="src\Utils\Assembly\AssemblyEndFaceAnalyzer.cs" />
<Compile Include="src\Utils\Assembly\AssemblyInstallationReferenceBuilder.cs" />
<!-- Core - Configuration Management -->
<Compile Include="src\Core\Config\SystemConfig.cs" />
<Compile Include="src\Core\Config\ConfigManager.cs" />
@ -146,6 +150,7 @@
<Compile Include="src\Core\Services\TimeTagService.cs" />
<Compile Include="src\Core\Services\TimeTagExporter.cs" />
<Compile Include="src\Core\Services\TimeTagTimeLinerIntegration.cs" />
<Compile Include="src\Core\Services\TestAutomationHttpService.cs" />
<!-- Commands - Command Pattern Framework (for testing) -->
<Compile Include="src\Commands\IPathPlanningCommand.cs" />
<Compile Include="src\Commands\CommandBase.cs" />
@ -161,6 +166,7 @@
<Compile Include="src\Commands\SetLogisticsAttributeCommand.cs" />
<Compile Include="src\Commands\StartAnimationCommand.cs" />
<Compile Include="src\Commands\GenerateCollisionReportCommand.cs" />
<Compile Include="src\Commands\TransportRibbonHandler.cs" />
<Compile Include="src\Commands\VoxelGridSDFTestCommand.cs" />
<Compile Include="src\Commands\VoxelPathFindingTestCommand.cs" />
<!-- Core - Animation System -->
@ -280,6 +286,7 @@
<Compile Include="src\UI\WPF\ViewModels\ModelSettingsViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\AnimationControlViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\PathEditingViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\RailAssemblyWorkflowContext.cs" />
<Compile Include="src\UI\WPF\ViewModels\SystemManagementViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\CollisionReportViewModel.cs" />
<Compile Include="src\UI\WPF\ViewModels\PathAnalysisViewModel.cs" />
@ -302,6 +309,7 @@
<Compile Include="src\UI\WPF\Converters\BoolToOpacityConverter.cs" />
<Compile Include="src\UI\WPF\Converters\IndexConverter.cs" />
<Compile Include="src\UI\WPF\Converters\CountToVisibilityConverter.cs" />
<Compile Include="src\UI\WPF\Converters\EditableNumberConverter.cs" />
<Compile Include="src\UI\WPF\Converters\PathTypeConverter.cs" />
<Compile Include="src\UI\WPF\Converters\BatchQueueStatusConverter.cs" />
<Compile Include="src\UI\WPF\Converters\BatchQueueStatusHelper.cs" />
@ -329,6 +337,34 @@
<!-- Coordinate System -->
<Compile Include="src\Utils\CoordinateSystem\CoordinateSystemType.cs" />
<Compile Include="src\Utils\CoordinateSystem\ICoordinateSystem.cs" />
<Compile Include="src\Utils\CoordinateSystem\CanonicalBounds3.cs" />
<Compile Include="src\Utils\CoordinateSystem\LocalAxisPoseBuilder.cs" />
<Compile Include="src\Utils\CoordinateSystem\LocalEulerRotationCorrection.cs" />
<Compile Include="src\Utils\CoordinateSystem\ObjectStartPlacementRequest.cs" />
<Compile Include="src\Utils\CoordinateSystem\CanonicalPlanarPoseBuilder.cs" />
<Compile Include="src\Utils\CoordinateSystem\CanonicalRailOffsetResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\CanonicalRailPoseBuilder.cs" />
<Compile Include="src\Utils\CoordinateSystem\CanonicalTrackedPositionResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\FragmentDefaultUpContext.cs" />
<Compile Include="src\Utils\CoordinateSystem\FragmentRepresentativePoseHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectReferencePose.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectReferencePoseResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\HoistingRealObjectPoseHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\PathTargetFrame.cs" />
<Compile Include="src\Utils\CoordinateSystem\PathTargetFrameResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectPlanarPoseSolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectProjectedExtentResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectRailAxisConventionResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\RealObjectRailExtentResolver.cs" />
<Compile Include="src\Utils\CoordinateSystem\ObjectSpaceOrientationHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\RotatedObjectExtentHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\HostCoordinateAdapter.cs" />
<Compile Include="src\Utils\CoordinateSystem\HostPlanarGridHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\HoistingCoordinateHelper.cs" />
<Compile Include="src\Utils\CoordinateSystem\LocalAxisDirection.cs" />
<Compile Include="src\Utils\CoordinateSystem\ModelAxisConvention.cs" />
<Compile Include="src\Utils\CoordinateSystem\ProjectReferenceFrame.cs" />
<Compile Include="src\Utils\CoordinateSystem\RailLocalFrame.cs" />
<Compile Include="src\Utils\CoordinateSystem\ZUpCoordinateSystem.cs" />
<Compile Include="src\Utils\CoordinateSystem\YUpCoordinateSystem.cs" />
<Compile Include="src\Utils\CoordinateSystem\CoordinateSystemManager.cs" />
@ -336,8 +372,11 @@
<Compile Include="src\Utils\VisibilityHelper.cs" />
<Compile Include="src\Utils\NwdExportHelper.cs" />
<Compile Include="src\Utils\ModelItemTransformHelper.cs" />
<Compile Include="src\Utils\SelectionClipBoxLockState.cs" />
<Compile Include="src\Utils\CachedTriangle3D.cs" />
<Compile Include="src\Utils\ClipboardHelper.cs" />
<Compile Include="src\Utils\PathHelper.cs" />
<Compile Include="src\Utils\RailPathPoseHelper.cs" />
<Compile Include="src\Utils\CollisionSceneHelper.cs" />
<Compile Include="src\Utils\SectionClipHelper.cs" />
<!-- Assembly Info -->
@ -468,13 +507,6 @@
</Page>
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Microsoft\WinFX\3.0\Microsoft.WinFX.targets" Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\WinFX\3.0\Microsoft.WinFX.targets')" />
<ItemGroup>
<!-- Localization Files -->
<None Include="resources\TransportPlugin.name.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TransportPlugin.name.txt</Link>
</None>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- Import NETStandard.Library targets to support .NET Standard 2.0 libraries -->
<Import Project="packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets" Condition="Exists('packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets')" />
@ -484,16 +516,38 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>resources\default_config.toml</Link>
</None>
<!-- SQLite native x64 runtime dependency -->
<None Include="packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.118.0\build\net46\x64\SQLite.Interop.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>SQLite.Interop.dll</Link>
</None>
<!-- Plugin Name File (in resources folder) -->
<None Include="resources\TransportPlugin.name.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>resources\TransportPlugin.name.txt</Link>
</None>
<None Include="resources\TransportRibbon.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TransportRibbon.xaml</Link>
</None>
<None Include="resources\TransportRibbon_16.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TransportRibbon_16.png</Link>
</None>
<None Include="resources\TransportRibbon_32.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TransportRibbon_32.png</Link>
</None>
<!-- Unit Cube NWC Model File -->
<None Include="resources\unit_cube.nwc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>resources\unit_cube.nwc</Link>
</None>
<!-- Unit Cylinder NWC Model File -->
<None Include="resources\unit_cylinder.nwc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>resources\unit_cylinder.nwc</Link>
</None>
</ItemGroup>
</Project>
</Project>

View File

@ -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

View File

@ -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<AnalysisTriangle3>();
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<AnalysisTriangle3>();
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<AnalysisTriangle3>();
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<AnalysisTriangle3> 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<AnalysisTriangle3> 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);
}
}
}

View File

@ -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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => AssemblyInstallationReferenceBuilder.Build(axisBase, axisDirection, pickPoint));
StringAssert.Contains(ex.Message, "距离光轴过小");
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<double[]>(),
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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<Vector3>
{
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<Vector3>
{
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<Vector3>
{
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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<double[]>
{
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<double[]>
{
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<double[]>
{
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<double[]>
{
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<double[]>
{
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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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,
@"<?xml version=""1.0"" encoding=""UTF-8""?>
<PathPlanningData xmlns=""http://www.3ds.com/delmia/pathplanning"" version=""1.0"" generator=""test"" timestamp=""2026-03-25T00:00:00"">
<ProjectInfo name=""test"" description=""test"" units=""meters"" coordinateSystem=""Global"" />
<Routes>
<Route id=""route-1"" name=""rail-test"" description="""" pathType=""Rail"" railMountMode=""OverRail"" railPathDefinitionMode=""RailCenterLine"" railNormalOffset=""0.25"" railPreferredNormalX=""0.0"" railPreferredNormalY=""1.0"" railPreferredNormalZ=""-0.5"" totalLength=""10.0"" maxObjectLength=""0.0"" maxObjectWidth=""0.0"" maxObjectHeight=""0.0"" safetyMargin=""0.0"" gridSize=""1.0"" liftHeight=""0.0"" created=""2026-03-25T00:00:00"">
<Points>
<Point id=""p1"" name=""start"" type=""StartPoint"" index=""0"" x=""0"" y=""0"" z=""0"" created=""2026-03-25T00:00:00"" />
<Point id=""p2"" name=""end"" type=""EndPoint"" index=""1"" x=""10"" y=""0"" z=""0"" created=""2026-03-25T00:00:00"" />
</Points>
</Route>
</Routes>
</PathPlanningData>");
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
{
}
}
}
}

View File

@ -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<PathPoint>
{
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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<object> { new object() });
state.Clear();
Assert.IsFalse(state.IsLocked);
Assert.AreEqual(0, state.LockedSelectionCount);
}
}
}

View File

@ -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<bool>("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"], "生成路径存在落在网格外的采样点");
}
}
}
}

View File

@ -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<bool?>("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<JObject> 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<JObject> 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<JObject> 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<JObject> 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<JObject> 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();
}
}
}

View File

@ -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<bool>("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"], "缺少碰撞报告");
}
}
}
}

View File

@ -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")]

View File

@ -0,0 +1,400 @@
using System;
using Autodesk.Navisworks.Api;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NavisworksTransport.Utils;
namespace NavisworksTransport.UnitTests.Utils
{
/// <summary>
/// BoundingBoxGeometryUtils 的单元测试
/// 重点验证旋转后包围盒中心偏移计算的数学正确性
/// 使用大尺寸和夸张比例,确保结果清晰可见
/// </summary>
[TestClass]
public class BoundingBoxGeometryUtilsTests
{
#region CalculateRotatedBoundingBoxCenterOffset Tests -
/// <summary>
/// X方向超长的长方体类似长沙发/长桌绕Z轴旋转90度
/// 尺寸: 10000 x 100 x 50
/// 预期: X方向大偏移
/// </summary>
[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");
}
/// <summary>
/// Y方向超长的长方体绕X轴旋转90度
/// 尺寸: 100 x 10000 x 50
/// </summary>
[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");
}
/// <summary>
/// Z方向超长的长方体绕Y轴旋转90度
/// 尺寸: 100 x 100 x 10000
/// </summary>
[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");
}
/// <summary>
/// 三轴都不同比例的长方体绕Z轴旋转45度
/// 尺寸: 1000 x 200 x 100
/// </summary>
[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");
}
/// <summary>
/// 实际沙发案例放大版
/// 模拟沙发: 长2000宽100高80中心偏移
/// </summary>
[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");
}
/// <summary>
/// X方向超长绕Z轴旋转45度 - 测试非90度旋转
/// 尺寸: 10000 x 100 x 50
/// </summary>
[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");
}
/// <summary>
/// XY方向都非对称的长方体绕Z轴旋转45度 - 测试双向偏移
/// 尺寸: 8000 x 4000 x 100
/// </summary>
[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");
}
/// <summary>
/// 零旋转验证 - 无论什么形状不旋转偏移都应为0
/// </summary>
[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");
}
/// <summary>
/// 180度旋转验证 - 包围盒应该不变(只是翻转)
/// </summary>
[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
}
}

View File

@ -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

View File

@ -1,4 +1,4 @@
# Navisworks API 使用方法指南
# Navisworks API 使用方法指南
基于真实官方示例的正确API用法总结
@ -505,455 +505,464 @@ public void BatchNavisworksOperations(List<ModelItem> 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
/// <summary>
/// 将物体移动到指定位置和朝向先回到CAD原始位置
/// </summary>
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属性和自定义属性访问
基于官方示例的正确属性访问方法总结。

View File

@ -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 SpaceZ-up**
- **业务基准点单独建模**
- **UI 输入输出统一使用宿主坐标系**
- **资产坐标系只属于插件自带资源,不得泛化成“所有模型都有本地轴”**
- **坐标转换只发生在边界层**
这是一条更接近业界三维软件成熟做法的路线。

View File

@ -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. 再进入轨道参考线提取的升级
这样风险更低,也更容易验证。

View File

@ -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 使用指南。

View File

@ -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` 接管当前显示姿态读取
- 测试先行
- 分阶段接入

View File

@ -0,0 +1,142 @@
# 直线装配路径用户操作说明
## 适用场景
适用于以下场景:
- 已知箱体最终安装后的准确位置和朝向
- 只需要做起点到终点的直线运动仿真
- 目标是检查运动过程中的碰撞
不推荐优先用于以下场景:
- 需要沿复杂轨道中心线连续编辑路径
- 需要依赖传统空轨模型吸附逐点绘制路径
## 推荐流程
### 1. 选择终点箱体
先在 Navisworks 三维视图中选中终点处已经安装好的箱体。
然后在路径编辑页的“直线装配参考”区域点击:
- `捕获终点箱体`
### 2. 设置装配构型
在“直线装配参考”区域设置:
- `安装方式`
- `轨下安装`
- `轨上安装`
- `对接基准`
- `顶面对接`
- `底面对接`
### 3. 生成参考杆
设置参考杆长度、直径后点击:
- `生成参考杆`
生成后,三维中会看到:
- 一根可点击的参考杆
- 一个终点对接点标记
说明:
- 终点对接点标记用于确认当前终点是落在顶面中心还是底面中心
### 4. 取起点并生成路径
点击:
- `取起点并生成路径`
然后在三维里点击参考杆附近位置。系统会:
- 自动吸附到参考杆轴线
- 用吸附点作为起点
- 用终点对接点作为终点
- 自动生成一条两点直线路径
### 5. 检查路径与动画
路径生成后,建议检查:
- 终点对接点是否正确
- 起点是否吸附在参考杆上
- 安装方式是否正确
- 顶面/底面对接是否正确
- 动画过程中构件位置是否合理
## 当前界面入口
主要入口:
- `路径编辑页 -> 直线装配参考`
当前主要按钮:
- `捕获终点箱体`
- `生成参考杆`
- `取起点并生成路径`
- `隐藏参考杆`
## 与传统空轨路径的区别
### 直线装配路径
特点:
- 以终点箱体位姿为主输入
- 自动生成参考杆
- 只需要点一次起点
- 适合直线装配仿真
### 传统空轨路径
入口:
- `传统空轨路径`
特点:
- 基于空轨基准线吸附
- 适合沿空轨逐点编辑路径
- 不作为当前装配仿真场景的首选入口
## 建议试用检查项
建议至少试以下四组:
1. `轨下安装 + 顶面对接`
2. `轨下安装 + 底面对接`
3. `轨上安装 + 顶面对接`
4. `轨上安装 + 底面对接`
每组建议检查:
- 终点标记位置是否正确
- 参考杆方向是否正确
- 起点吸附是否准确
- 路径生成后动画是否符合预期
## 常见提示
如果无法生成参考杆,先检查:
- 是否已经在三维里选中终点箱体
- 当前选择对象是否是有效模型对象
如果无法取起点,先检查:
- 是否已经生成参考杆
- 是否进入了“取起点并生成路径”状态
如果终点位置不符合预期,优先检查:
- 当前是否选对了 `轨上安装 / 轨下安装`
- 当前是否选对了 `顶面对接 / 底面对接`

View File

@ -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` 恢复分支
---
*本文档将随着项目发展持续更新,确保设计指导的有效性和实用性。*

View File

@ -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]

File diff suppressed because it is too large Load Diff

View File

@ -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` 轴修正
- 是否表现为“起点一次性按宿主轴旋转”
- 播放过程中不应每帧累加放大

View File

@ -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` 硬编码

View File

@ -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. 备注
本清单服务于“先稳定非自动路径规划主线”的目标,不代表自动路径规划相关模块不重要。
后续如要继续推进全项目坐标系统一,应单独启动自动路径规划相关的第二阶段清理计划。

View File

@ -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` 坐标
- 业务参考点
- 模型局部轴约定

View File

@ -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"
}
]
}
]
}
}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<RibbonControl
xmlns="clr-namespace:Autodesk.Windows;assembly=AdWindows"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Autodesk.Navisworks.Gui.Roamer.AIRLook;assembly=navisworks.gui.roamer">
<RibbonTab x:Uid="RibbonTab_Transport" Id="ID_TransportTab" Title="物流路径规划" KeyTip="WL">
<RibbonPanel x:Uid="RibbonPanel_TransportMain">
<RibbonPanelSource x:Uid="RibbonPanelSource_TransportMain" Title="物流路径规划" KeyTip="G">
<local:NWRibbonButton
x:Uid="RibbonButton_OpenLogisticsPanel"
Id="OpenLogisticsPanel"
Orientation="Vertical"
Size="Large"
ShowText="True"
Text="物流路径&#xA;规划"
ToolTip="打开物流路径规划面板"
Image="TransportRibbon_16.png"
LargeImage="TransportRibbon_32.png"
KeyTip="P" />
</RibbonPanelSource>
</RibbonPanel>
</RibbonTab>
</RibbonControl>

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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 = "狭窄通道"

Binary file not shown.

BIN
resources/unit_cylinder.nwc Normal file

Binary file not shown.

149
resources/unit_cylinder.obj Normal file
View File

@ -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

View File

@ -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

50
run-unit-tests.bat Normal file
View File

@ -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!

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 中启动,首次测试前请确保插件面板已打开。'

View File

@ -0,0 +1,4 @@
{
"defaultModelPath": "C:\\Users\\Tellme\\Documents\\NavisworksTransport\\模型\\Floor2_mobile_yup.nwd",
"defaultRouteNames": []
}

View File

@ -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<CollisionReportResult>.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);
}
/// <summary>
@ -1097,4 +1141,4 @@ namespace NavisworksTransport.Commands
}
}
}
}
}

View File

@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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
{
/// <summary>
/// 直线装配参考路径管理器。
/// 使用单位立方体资源生成一根可点击的临时参考杆,供用户在 3D 视图中选择起点。
/// 参考杆本质上表达的是一段 start->end 参考轴线,而不是终点处的独立构件。
/// </summary>
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;
}
/// <summary>
/// 创建或更新参考杆。
/// 先记录参考线主数据,再基于参考线更新可点击实体外壳。
/// </summary>
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;
}
/// <summary>
/// 将任意点击点投影到当前参考线。
/// </summary>
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];
}
}
}

View File

@ -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; }
}
}
}

View File

@ -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
/// </summary>
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<CollisionResult> 实现:基于碰撞对象进行去重
@ -2199,4 +2208,4 @@ namespace NavisworksTransport
CollisionCount = collisionCount;
}
}
}
}

View File

@ -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);
}
}

View File

@ -119,6 +119,26 @@ namespace NavisworksTransport.Core.Config
/// </summary>
public double ArcSamplingStepMeters { get; set; }
/// <summary>
/// 吊装路径自动视角距离(米)
/// </summary>
public double HoistingViewDistanceMeters { get; set; }
/// <summary>
/// 吊装路径自动视角仰角(度)
/// </summary>
public double HoistingViewElevationDegrees { get; set; }
/// <summary>
/// Rail 路径自动视角距离(米)
/// </summary>
public double RailViewDistanceMeters { get; set; }
/// <summary>
/// Rail 路径自动视角仰角(度)
/// </summary>
public double RailViewElevationDegrees { get; set; }
// === 模型单位接口(用于内部计算) ===
/// <summary>
@ -160,6 +180,16 @@ namespace NavisworksTransport.Core.Config
/// 圆弧采样步长(模型单位)
/// </summary>
public double ArcSamplingStep => ArcSamplingStepMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor();
/// <summary>
/// 吊装路径自动视角距离(模型单位)
/// </summary>
public double HoistingViewDistance => HoistingViewDistanceMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor();
/// <summary>
/// Rail 路径自动视角距离(模型单位)
/// </summary>
public double RailViewDistance => RailViewDistanceMeters * Utils.UnitsConverter.GetMetersToUnitsConversionFactor();
}
/// <summary>
@ -278,5 +308,6 @@ namespace NavisworksTransport.Core.Config
/// YUp: 强制使用 Y-Up 坐标系
/// </summary>
public string Type { get; set; } = "AutoDetect";
}
}

View File

@ -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
}
}
}
}

View File

@ -653,14 +653,14 @@ namespace NavisworksTransport.Core
var collisions = new List<CollisionResult>();
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)
});
}

View File

@ -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);
}
/// <summary>
@ -510,4 +508,4 @@ namespace NavisworksTransport
// 长度会自动从 Edges 或 Points 计算
}
}
}
}

View File

@ -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; }
}
/// <summary>
/// JSON格式的项目信息
/// </summary>
@ -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<RailMountMode>(jsonRoute.railMountMode, out var railMountMode))
{
route.RailMountMode = railMountMode;
}
if (!string.IsNullOrEmpty(jsonRoute.railPathDefinitionMode) &&
Enum.TryParse<RailPathDefinitionMode>(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<PathPoint>();
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<RailMountMode>(routeNode.Attributes?["railMountMode"]?.Value, out var railMountMode))
{
route.RailMountMode = railMountMode;
}
if (Enum.TryParse<RailPathDefinitionMode>(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);
}
}
}
}

View File

@ -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
/// <summary>
/// 检查并扩展 PathRoutes 表结构
/// </summary>
public void EnsurePathRoutesTableExtended()
{
try
{
var checkSql = "PRAGMA table_info(PathRoutes)";
var existingColumns = new HashSet<string>();
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<string, string>
{
["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
/// <summary>
@ -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; }
}

View File

@ -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
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,38 @@ namespace NavisworksTransport
Lateral
}
/// <summary>
/// Rail 路径安装方式
/// </summary>
public enum RailMountMode
{
/// <summary>
/// 轨下安装
/// </summary>
UnderRail = 0,
/// <summary>
/// 轨上安装
/// </summary>
OverRail = 1
}
/// <summary>
/// Rail 路径的参考线定义方式
/// </summary>
public enum RailPathDefinitionMode
{
/// <summary>
/// 旧版兼容模式:路径点即轨下悬挂参考点
/// </summary>
LegacySuspensionPoint = 0,
/// <summary>
/// 新版模式:路径点表示双轨参考中心线
/// </summary>
RailCenterLine = 1
}
/// <summary>
/// 通道检测结果
/// </summary>
@ -343,6 +375,13 @@ namespace NavisworksTransport
/// </summary>
public double? CustomTurnRadius { get; set; }
/// <summary>
/// 仅用于视图显示的点直径(模型单位)。
/// 为 null 或非正数时使用默认点类型尺寸。
/// </summary>
[XmlIgnore]
public double? VisualizationDiameter { get; set; }
/// <summary>
/// 方向类型(仅吊装路径使用)
/// 用于标识吊装路径点的移动方向垂直、纵向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;
}
/// <summary>
/// 解析最终用于渲染的点半径(模型单位)。
/// </summary>
/// <param name="defaultRadius">默认点半径(模型单位)</param>
/// <returns>最终半径(模型单位)</returns>
public double ResolveVisualizationRadius(double defaultRadius)
{
if (!VisualizationDiameter.HasValue || VisualizationDiameter.Value <= 0)
{
return defaultRadius;
}
return VisualizationDiameter.Value * 0.5;
}
/// <summary>
/// 返回路径点描述字符串
/// </summary>
@ -510,6 +566,11 @@ namespace NavisworksTransport
[Serializable]
public class PathRoute
{
public static bool IsTopReferenceFaceForMountMode(RailMountMode railMountMode)
{
return railMountMode == RailMountMode.UnderRail;
}
/// <summary>
/// 路径点集合(控制点)
/// </summary>
@ -645,27 +706,27 @@ namespace NavisworksTransport
public NavisworksTransport.PathPlanning.GridMap AssociatedGridMap { get; set; }
/// <summary>
/// 网格大小(模型单位
/// 网格大小(
/// </summary>
public double GridSize { get; set; }
/// <summary>
/// 最大物体长度(模型单位 - 路径适用的物体长度限制
/// 最大物体长度( - 路径适用的物体长度限制
/// </summary>
public double MaxObjectLength { get; set; }
/// <summary>
/// 最大物体宽度(模型单位 - 路径适用的物体宽度限制
/// 最大物体宽度( - 路径适用的物体宽度限制
/// </summary>
public double MaxObjectWidth { get; set; }
/// <summary>
/// 最大物体高度(模型单位 - 路径适用的物体高度限制
/// 最大物体高度( - 路径适用的物体高度限制
/// </summary>
public double MaxObjectHeight { get; set; }
/// <summary>
/// 安全间隙(模型单位
/// 安全间隙(
/// </summary>
public double SafetyMargin { get; set; }
@ -690,6 +751,28 @@ namespace NavisworksTransport
/// </summary>
public double LiftHeight { get; set; }
/// <summary>
/// Rail 路径安装方式(仅当 PathType == Rail 时有效)
/// </summary>
public RailMountMode RailMountMode { get; set; }
/// <summary>
/// Rail 路径参考线定义方式(仅当 PathType == Rail 时有效)
/// </summary>
public RailPathDefinitionMode RailPathDefinitionMode { get; set; }
/// <summary>
/// Rail 路径优先法向(宿主坐标)。
/// 仅在存在显式安装面/业务法向时使用;为空时退回当前默认求解。
/// </summary>
public Point3D RailPreferredNormal { get; set; }
/// <summary>
/// Rail 安装法向偏移(模型单位)。
/// 沿 Rail 最终法向对安装参考点施加额外偏移,用于快速微调路径。
/// </summary>
public double RailNormalOffset { get; set; }
// 数据库分析相关属性
/// <summary>
/// 碰撞数量(从数据库加载)
@ -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;
}
/// <summary>
@ -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;
}
/// <summary>
@ -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<string>(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<string, string>();
// 克隆所有路径点
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<Point3D>(edge.SampledPoints)
: new List<Point3D>()
};
clonedRoute.Edges.Add(clonedEdge);
}
return clonedRoute;
@ -2627,4 +2762,4 @@ namespace NavisworksTransport
}
#endregion
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More