diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj
index 4d62891..83011a4 100644
--- a/NavisworksTransport.UnitTests.csproj
+++ b/NavisworksTransport.UnitTests.csproj
@@ -72,6 +72,7 @@
+
diff --git a/UnitTests/Integration/NavisworksTestAutomationClient.cs b/UnitTests/Integration/NavisworksTestAutomationClient.cs
index 7b461d8..1d77f38 100644
--- a/UnitTests/Integration/NavisworksTestAutomationClient.cs
+++ b/UnitTests/Integration/NavisworksTestAutomationClient.cs
@@ -58,6 +58,38 @@ namespace NavisworksTransport.UnitTests.Integration
return await RunVirtualCollisionTestCoreAsync(routeName, pathType, timeoutSeconds).ConfigureAwait(false);
}
+ ///
+ /// 运行碰撞测试(虚拟或真实物体)。
+ /// useVirtualObject=false 时必须提供 animatedObjectPath(ModelPath 定位,格式如 0/0/769/0)。
+ ///
+ public async Task RunCollisionTestAsync(
+ string pathType,
+ int timeoutSeconds,
+ string routeName = null,
+ bool useVirtualObject = true,
+ string animatedObjectPath = null)
+ {
+ var queryParts = new System.Collections.Generic.List
+ {
+ "pathType=" + Uri.EscapeDataString(pathType),
+ "timeoutSeconds=" + timeoutSeconds.ToString(CultureInfo.InvariantCulture),
+ "useVirtualObject=" + (useVirtualObject ? "true" : "false")
+ };
+
+ if (!string.IsNullOrWhiteSpace(routeName))
+ {
+ queryParts.Add("routeName=" + Uri.EscapeDataString(routeName));
+ }
+
+ if (!string.IsNullOrWhiteSpace(animatedObjectPath))
+ {
+ queryParts.Add("animatedObjectPath=" + Uri.EscapeDataString(animatedObjectPath));
+ }
+
+ string requestUri = "/api/test/run-virtual-collision-test?" + string.Join("&", queryParts);
+ return await PostJsonAsync(requestUri).ConfigureAwait(false);
+ }
+
private async Task RunVirtualCollisionTestCoreAsync(string routeName, string pathType, int timeoutSeconds)
{
string requestUri = string.Format(
diff --git a/UnitTests/Integration/RealObjectAnimationAutomationTests.cs b/UnitTests/Integration/RealObjectAnimationAutomationTests.cs
new file mode 100644
index 0000000..e7f0a95
--- /dev/null
+++ b/UnitTests/Integration/RealObjectAnimationAutomationTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace NavisworksTransport.UnitTests.Integration
+{
+ ///
+ /// 真实物体动画集成测试(覆盖计划 T01-T03,高风险区:真实物体姿态链)。
+ ///
+ /// 验证点:
+ /// - 通过 ModelPath(PathId)唯一定位真实物体(DisplayName 可能重名)
+ /// - 真实物体(非虚拟物体)沿三类路径生成动画并完整播放
+ /// - 真实物体模式使用 fragment 代表姿态 / 真实尺寸,而非 unit_cube 资产
+ ///
+ /// 依赖模型数据(Floor2_mobile_yup.nwd):
+ /// - 0/0/660/0 = 42" Diameter 管道(Rail 终点附近,同时用于 Ground/Rail)
+ /// - 0/0/769/0 = 25" x 25" 方柱(Hoisting 路径上)
+ /// 模型几何变化可能导致 PathId 失效,届时需重新定位。
+ ///
+ [TestClass]
+ [TestCategory("NavisworksIntegration")]
+ public class RealObjectAnimationAutomationTests
+ {
+ private const string Pipe42DiameterPathId = "0/0/660/0";
+ private const string Column25x25PathId = "0/0/769/0";
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task GroundRealObject_Animation_Completes()
+ {
+ await AssertRealObjectFlowAsync("Ground", Pipe42DiameterPathId).ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task HoistingRealObject_Animation_Completes()
+ {
+ await AssertRealObjectFlowAsync("Hoisting", Column25x25PathId).ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task RailRealObject_Animation_Completes()
+ {
+ await AssertRealObjectFlowAsync("Rail", Pipe42DiameterPathId).ConfigureAwait(false);
+ }
+
+ private static async Task AssertRealObjectFlowAsync(string pathType, string animatedObjectPath)
+ {
+ using (var client = new NavisworksTestAutomationClient())
+ {
+ await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false);
+
+ JObject response = await client.RunCollisionTestAsync(
+ pathType,
+ 240,
+ useVirtualObject: false,
+ animatedObjectPath: animatedObjectPath).ConfigureAwait(false);
+
+ Assert.IsTrue(response.Value("ok"), "真实物体碰撞测试 HTTP 接口返回失败: " + (string)response["error"]);
+
+ JObject data = (JObject)response["data"];
+ Assert.IsNotNull(data, "缺少测试结果 data");
+
+ JObject animatedObject = (JObject)data["animatedObject"];
+ Assert.IsNotNull(animatedObject, "缺少 animatedObject");
+ Assert.AreEqual("RealObject", (string)animatedObject["mode"], "当前测试应使用真实物体模式");
+ Assert.IsFalse(string.IsNullOrWhiteSpace((string)animatedObject["displayName"]), "真实物体显示名为空");
+
+ JObject route = (JObject)data["route"];
+ Assert.IsNotNull(route, "缺少 route");
+ Assert.AreEqual(pathType, (string)route["pathType"], "返回的路径类型不匹配");
+
+ JObject animation = (JObject)data["animation"];
+ Assert.IsNotNull(animation, "缺少 animation");
+ Assert.AreEqual("Finished", (string)animation["currentState"], "动画未完成");
+ Assert.IsTrue((int)animation["totalFrames"] > 0, "动画总帧数应大于 0");
+ Assert.IsTrue(animation["detectionRecordId"] != null && (int)animation["detectionRecordId"] > 0, "检测记录 ID 无效");
+
+ Assert.IsNotNull(data["report"], "缺少碰撞报告");
+ }
+ }
+ }
+}
diff --git a/doc/working/2026-08-02-integration-test-coverage-plan.md b/doc/working/2026-08-02-integration-test-coverage-plan.md
index e222479..72965ed 100644
--- a/doc/working/2026-08-02-integration-test-coverage-plan.md
+++ b/doc/working/2026-08-02-integration-test-coverage-plan.md
@@ -25,13 +25,15 @@
### P0 高风险区(AGENTS.md 标注高风险:真实物体姿态链 / 动画逐帧状态)
-- [ ] **T01 真实物体 Ground 动画与姿态**
+- [x] **T01 真实物体 Ground 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`)
- 验证:`select-animated-object` 选真实物体 → 生成动画 → 断言物体实际位置/姿态(fragment 代表姿态、平面姿态链)
- - 支撑:服务端 `select-animated-object` 端点已实现(按名称/选择集);`export-debug-snapshot` 返回 trackedState/geometryState/boundingBox
-- [ ] **T02 真实物体 Hoisting 动画与姿态**
+ - 实现:`run-virtual-collision-test?useVirtualObject=false&animatedObjectPath=0/0/660/0`(42" Diameter)
+- [x] **T02 真实物体 Hoisting 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`)
- 验证:真实物体吊装路径动画(起吊/平移/下降三段姿态)
-- [ ] **T03 真实物体 Rail 动画与姿态**
+ - 实现:`animatedObjectPath=0/0/769/0`(25" x 25",Hoisting 路径上)
+- [x] **T03 真实物体 Rail 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`)
- 验证:真实物体空轨路径(canonical → rail pose 链、角度修正不污染 0° 基线)
+ - 实现:`animatedObjectPath=0/0/660/0`(42" Diameter)
- [ ] **T04 动画逐帧状态验证**
- 验证:动画中途/结束时物体位置沿路径移动、姿态符合预期(起点=路径起点、逐帧插值、终点=路径终点);Ground 平面链 yaw 正确
- 支撑:`export-debug-snapshot` trackedState(position/yaw/quaternion)+ boundingBox
@@ -59,8 +61,8 @@
|---|---|---|
| ping / status / routes / selection | ✅ | 健康检查、状态、路径列表、选择集 |
| select-route / select-default-route | ✅ | 切换路径(自动测试_ 前缀自动导入) |
-| **select-animated-object** | ✅ 已实现未用 | 真实物体选择(P0 测试用) |
-| run-virtual-collision-test / run-ground-collision-test | ✅ | 虚拟/真实物体碰撞流程 |
+| **select-animated-object** | ✅ 已实现 | 真实物体选择(T01-T03 已用) |
+| run-virtual-collision-test / run-ground-collision-test | ✅ | 虚拟/真实物体碰撞流程(支持 useVirtualObject=false + animatedObjectPath) |
| analyze-auto-path-grid / run-auto-path | ✅ | 自动路径规划(已支持 strategy) |
| **route-grid-diagnostics** | ✅ 已实现未用 | 网格诊断(T09 用) |
| **export-debug-snapshot** | ✅ 已实现未用 | 动画逐帧状态(T04 用) |
@@ -74,3 +76,4 @@
| 2026-08-02 | `43df07c` | 集成测试标准路径自动导入机制(前置基础) |
| 2026-08-02 | `c9c29c7` | 路径导入验证迁入集成测试(PathImportValidity) |
| 2026-08-02 | `5bdfa82` | AGENTS.md 单位原则强化(测试断言按文档单位换算) |
+| 2026-08-04 | 待提交 | T01-T03 真实物体动画(RealObjectAnimationAutomationTests,服务端支持 useVirtualObject=false + animatedObjectPath/InstanceGuid 校验) |
diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs
index 20d2fb6..7282f74 100644
--- a/src/Core/Services/TestAutomationHttpService.cs
+++ b/src/Core/Services/TestAutomationHttpService.cs
@@ -598,7 +598,7 @@ namespace NavisworksTransport.Core.Services
animationViewModel.UseVirtualObject = false;
animationViewModel.SelectAnimatedObjectCommand.Execute(null);
- if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject))
+ if (!IsSameModelItem(animationViewModel.SelectedAnimatedObject, animatedObject))
{
throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象");
}
@@ -1995,22 +1995,54 @@ namespace NavisworksTransport.Core.Services
animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route));
animationViewModel.IsManualCollisionTargetEnabled = false;
- animationViewModel.UseVirtualObject = true;
- object animatedObjectPayload = new
+
+ bool useVirtualObject = ParseBooleanQuery(query, "useVirtualObject", true);
+ object animatedObjectPayload;
+
+ if (useVirtualObject)
{
- mode = "VirtualObject",
- displayName = "虚拟物体"
- };
+ animationViewModel.UseVirtualObject = true;
+ animatedObjectPayload = new
+ {
+ mode = "VirtualObject",
+ displayName = "虚拟物体"
+ };
+ }
+ else
+ {
+ // 真实物体模式:按名称/当前选择集选择真实物体
+ ModelItem realObject = ResolveAnimatedObjectFromQuery(query);
+ SelectDocumentItem(realObject);
+
+ if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null))
+ {
+ throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令");
+ }
+
+ animationViewModel.UseVirtualObject = false;
+ animationViewModel.SelectAnimatedObjectCommand.Execute(null);
+
+ if (!IsSameModelItem(animationViewModel.SelectedAnimatedObject, realObject))
+ {
+ throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象");
+ }
+
+ animatedObjectPayload = new
+ {
+ mode = "RealObject",
+ displayName = realObject.DisplayName
+ };
+ }
if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null))
{
- throw new InvalidOperationException($"{route.PathType} 虚拟物体碰撞测试准备失败:当前条件下不能生成动画");
+ throw new InvalidOperationException($"{route.PathType} 碰撞测试准备失败:当前条件下不能生成动画");
}
animationViewModel.GenerateAnimationCommand.Execute(null);
LogManager.Info(
- $"[测试HTTP] 已开始准备虚拟物体碰撞测试: 路径={route.Name}, 类型={route.PathType}");
+ $"[测试HTTP] 已开始准备{(useVirtualObject ? "虚拟物体" : "真实物体")}碰撞测试: 路径={route.Name}, 类型={route.PathType}");
return new PreparedVirtualCollisionTest
{
@@ -2099,6 +2131,24 @@ namespace NavisworksTransport.Core.Services
return route;
}
+ ///
+ /// 判断两个 ModelItem 是否指向同一模型对象。
+ /// NW API 中同一逻辑对象通过不同途径(Selection/Descendants)获取的实例不保证同一引用,
+ /// 因此用 InstanceGuid 比较,禁止用 ReferenceEquals。
+ ///
+ private static bool IsSameModelItem(ModelItem a, ModelItem b)
+ {
+ if (a == null || b == null)
+ {
+ return false;
+ }
+
+ return string.Equals(
+ a.InstanceGuid.ToString(),
+ b.InstanceGuid.ToString(),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private static PathType ParsePathTypeOrThrow(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
@@ -2195,7 +2245,9 @@ namespace NavisworksTransport.Core.Services
}
string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName");
- if (string.IsNullOrWhiteSpace(animatedObjectName))
+ string animatedObjectPath = GetOptionalQueryValue(query, "animatedObjectPath");
+
+ if (string.IsNullOrWhiteSpace(animatedObjectName) && string.IsNullOrWhiteSpace(animatedObjectPath))
{
return ResolveSingleSelectedItem(activeDocument);
}
@@ -2210,7 +2262,29 @@ namespace NavisworksTransport.Core.Services
foreach (ModelItem item in model.RootItem.DescendantsAndSelf)
{
- if (item != null && string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase))
+ if (item == null)
+ {
+ continue;
+ }
+
+ bool nameMatches = !string.IsNullOrWhiteSpace(animatedObjectName) &&
+ string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase);
+
+ bool pathMatches = false;
+ if (!string.IsNullOrWhiteSpace(animatedObjectPath))
+ {
+ try
+ {
+ var pathId = activeDocument.Models.CreatePathId(item);
+ pathMatches = string.Equals(pathId.PathId, animatedObjectPath, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ // 忽略单点路径解析失败,继续匹配
+ }
+ }
+
+ if (nameMatches || pathMatches)
{
matches.Add(item);
}
@@ -2219,12 +2293,14 @@ namespace NavisworksTransport.Core.Services
if (matches.Count == 0)
{
- throw new InvalidOperationException($"找不到指定真实物体: {animatedObjectName}");
+ string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath;
+ throw new InvalidOperationException($"找不到指定真实物体: {target}");
}
if (matches.Count > 1)
{
- throw new InvalidOperationException($"找到多个同名真实物体,请改用更唯一的名字: {animatedObjectName}");
+ string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath;
+ throw new InvalidOperationException($"找到多个匹配真实物体,请改用更唯一的名字或 PathId: {target}");
}
return matches[0];