using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Autodesk.Navisworks.Api; using NavisworksTransport.Commands; using NavisworksTransport.Core.Animation; using NavisworksTransport.Core.Config; using NavisworksTransport.PathPlanning; using NavisworksTransport.UI.WPF.ViewModels; using NavisworksTransport.Utils; using NavisworksTransport.Utils.CoordinateSystem; using Newtonsoft.Json; namespace NavisworksTransport.Core.Services { /// /// 本地测试自动化 HTTP 控制面。 /// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。 /// public sealed class TestAutomationHttpService : IDisposable { private const int DefaultPort = 18777; private const string DefaultHost = "127.0.0.1"; private const string PortEnvironmentVariable = "TRANSPORTPLUGIN_TEST_PORT"; private const string DefaultAutoTestRoutePrefix = "自动测试_"; private const string AutoTestRoutesResourceFileName = "auto-test-routes.xml"; // 多实例支持:每个 Navisworks 实例通过环境变量 TRANSPORTPLUGIN_TEST_PORT 分配独立端口(默认 18777)。 private int _resolvedPort; private bool _portResolved; private static readonly Lazy _instance = new Lazy(() => new TestAutomationHttpService()); private readonly object _syncRoot = new object(); private static int _autoConfirmCollisionAnalysisDialogRequestCount; private static int _autoChooseCreateNewDetectionRecordRequestCount; // 测试执行串行锁:动画/路径/碰撞类请求共享 UI 线程与单例动画管理器, // 客户端超时断开后服务端任务仍在运行,并发会污染 UI 状态(如物体选择串扰),必须串行。 private static readonly SemaphoreSlim _testExecutionLock = new SemaphoreSlim(1, 1); private TcpListener _listener; private CancellationTokenSource _cts; private Task _acceptLoopTask; private DateTime _startedAtUtc; private string _lastStartError; private TestAutomationHttpService() { } public static TestAutomationHttpService Instance => _instance.Value; public bool IsRunning { get { lock (_syncRoot) { return _listener != null; } } } public int Port => ResolvePort(); public string BaseUrl => $"http://{DefaultHost}:{Port}"; public static bool ShouldAutoConfirmCollisionAnalysisDialogs => Interlocked.CompareExchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0, 0) > 0; public static bool ShouldAutoChooseCreateNewDetectionRecord => Interlocked.CompareExchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0, 0) > 0; public void Start() { lock (_syncRoot) { if (_listener != null) { return; } try { _cts = new CancellationTokenSource(); _listener = new TcpListener(IPAddress.Loopback, ResolvePort()); _listener.Start(); _startedAtUtc = DateTime.UtcNow; _lastStartError = null; _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); LogManager.Info($"[测试HTTP] 服务已启动: {BaseUrl}"); } catch (Exception ex) { _lastStartError = ex.Message; _listener = null; _cts?.Dispose(); _cts = null; _acceptLoopTask = null; LogManager.Error($"[测试HTTP] 服务启动失败: {ex.Message}", ex); } } } public void Stop() { Task acceptLoopTask = null; lock (_syncRoot) { if (_listener == null) { return; } try { _cts?.Cancel(); _listener.Stop(); acceptLoopTask = _acceptLoopTask; } catch (Exception ex) { LogManager.Warning($"[测试HTTP] 服务停止时出现警告: {ex.Message}"); } finally { _listener = null; _acceptLoopTask = null; _cts?.Dispose(); _cts = null; } } try { acceptLoopTask?.Wait(1000); } catch { // 忽略退出等待异常 } LogManager.Info("[测试HTTP] 服务已停止"); } private async Task AcceptLoopAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { TcpClient client = null; try { client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); } catch (ObjectDisposedException) { break; } catch (SocketException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception ex) { LogManager.Error($"[测试HTTP] 接收请求失败: {ex.Message}", ex); client?.Dispose(); } } } private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) { using (client) using (var stream = client.GetStream()) using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true)) using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, true) { NewLine = "\r\n", AutoFlush = true }) { try { // 自动模式:测试请求全程窗口自动选择默认动作并立即关闭(与批处理一致) using (AutomationModeContext.EnterAutomaticMode()) { string requestLine = await reader.ReadLineAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(requestLine)) { return; } string[] requestLineParts = requestLine.Split(' '); if (requestLineParts.Length < 2) { await WriteJsonResponseAsync(writer, 400, BuildEnvelope(false, error: "Invalid request line")).ConfigureAwait(false); return; } string method = requestLineParts[0].Trim().ToUpperInvariant(); string requestTarget = requestLineParts[1].Trim(); var request = ParseRequestTarget(requestTarget); string headerLine; while (!string.IsNullOrEmpty(headerLine = await reader.ReadLineAsync().ConfigureAwait(false))) { if (cancellationToken.IsCancellationRequested) { return; } } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/ping", StringComparison.OrdinalIgnoreCase)) { var payload = new { ok = true, service = "NavisworksTransport.TestAutomation", protocol = "http", version = 1, baseUrl = BaseUrl, serverTimeUtc = DateTime.UtcNow.ToString("o") }; await WriteJsonResponseAsync(writer, 200, payload).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/bbox-profiling", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => BuildBBoxProfilingPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildStatusPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/routes", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildRoutesPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/route-grid-diagnostics", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => BuildRouteGridDiagnosticsPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/tree-dump", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => BuildTreeDumpPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/traversable-items", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildTraversableItemsPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/selection", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildSelectionPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/list-model-items", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => BuildModelItemsPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/detection-record", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => BuildDetectionRecordPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/analyze-path", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => AnalyzePathPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/batch-queue-add", StringComparison.OrdinalIgnoreCase)) { object payload = await BatchQueueAddAsync(request.Query).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/batch-queue-process", StringComparison.OrdinalIgnoreCase)) { object payload = BatchQueueProcessPayload(request.Query); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/batch-queue-status", StringComparison.OrdinalIgnoreCase)) { object payload = await BatchQueueStatusPayload(request.Query).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/edit-route", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => EditRoutePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/export-section-box", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => ExportSectionBoxPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/export-debug-snapshot", StringComparison.OrdinalIgnoreCase)) { object snapshotPayload = InvokeOnUiThread(BuildDebugSnapshotPayload); string snapshotFilePath = WriteSnapshotToDisk(snapshotPayload); var payload = new { snapshotFilePath, snapshot = snapshotPayload }; await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-route", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectRoutePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-default-route", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectDefaultRoutePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-animated-object", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectAnimatedObjectPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-ground-collision-test", StringComparison.OrdinalIgnoreCase)) { object payload = await RunVirtualCollisionTestAsync(request.Query, PathType.Ground).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-virtual-collision-test", StringComparison.OrdinalIgnoreCase)) { object payload = await RunVirtualCollisionTestAsync(request.Query, null).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/analyze-auto-path-grid", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => AnalyzeAutoPathGridPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-auto-path", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => RunAutoPathPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/import-route-file", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => ImportRouteFilePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/export-route-file", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => ExportRouteFilePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/probe-animation-frames", StringComparison.OrdinalIgnoreCase)) { object payload = await ProbeAnimationFramesAsync(request.Query).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/animation-playback-control", StringComparison.OrdinalIgnoreCase)) { object payload = await AnimationPlaybackControlAsync(request.Query).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase)) { await WriteJsonResponseAsync(writer, 405, BuildEnvelope(false, error: "Only GET and POST are supported in the current test API")).ConfigureAwait(false); return; } await WriteJsonResponseAsync(writer, 404, BuildEnvelope(false, error: $"Unknown endpoint: {request.Path}")).ConfigureAwait(false); } } catch (Exception ex) { LogManager.Error($"[测试HTTP] 处理请求失败: {ex.Message}", ex); await WriteJsonResponseAsync(writer, 500, BuildEnvelope(false, error: ex.Message)).ConfigureAwait(false); } } } /// /// 【实验端点】NW 组节点/几何节点 BoundingBox 缓存行为分析。 /// 验证:组节点聚合盒是否为 O(1) 缓存读取(用于剖面盒遍历子树剪枝)。 /// private object BuildBBoxProfilingPayload(Dictionary query) { Document doc = Autodesk.Navisworks.Api.Application.ActiveDocument; if (doc == null || doc.IsClear) { return new { error = "无活动文档" }; } bool enumOnly = ParseBooleanQuery(query, "enum", true); var sw = new System.Diagnostics.Stopwatch(); // 根盒(不枚举,O(1))——大模型下全量枚举需数分钟,根盒/剪枝验证不需要枚举 sw.Restart(); var rootBox = doc.Models[0].RootItem.BoundingBox(); long rootFirstMs = sw.ElapsedMilliseconds; sw.Restart(); var rootBox2 = doc.Models[0].RootItem.BoundingBox(); long rootSecondMs = sw.ElapsedMilliseconds; if (!enumOnly) { return new { enumSkipped = true, rootFirstMs, rootSecondMs, rootBox = $"({rootBox.Min.X:F1},{rootBox.Min.Y:F1},{rootBox.Min.Z:F1})~({rootBox.Max.X:F1},{rootBox.Max.Y:F1},{rootBox.Max.Z:F1})" }; } // 全量枚举计时(RootItemDescendantsAndSelf 本身耗时) sw.Restart(); var allItems = doc.Models.SelectMany(m => m.RootItem?.DescendantsAndSelf ?? Enumerable.Empty()).ToList(); long enumMs = sw.ElapsedMilliseconds; int totalCount = allItems.Count; var groupNodes = allItems.Where(i => !i.HasGeometry && i.Children != null && i.Children.Count() > 0).Take(200).ToList(); var geometryNodes = allItems.Where(i => i.HasGeometry).Take(200).ToList(); // 实验A(冷):不预先调用任何 BoundingBox,直接批量测组节点盒 sw.Restart(); long groupColdTotalMs = 0; int groupColdCount = 0; foreach (var g in groupNodes) { sw.Restart(); var b = g.BoundingBox(); groupColdTotalMs += sw.ElapsedMilliseconds; groupColdCount++; } // 实验A2(冷):几何节点盒 sw.Restart(); long geomColdTotalMs = 0; int geomColdCount = 0; foreach (var g in geometryNodes) { sw.Restart(); var b = g.BoundingBox(); geomColdTotalMs += sw.ElapsedMilliseconds; geomColdCount++; } // 实验B(热):根盒已在开头测过(缓存),直接批量测组节点盒 sw.Restart(); long groupHotTotalMs = 0; int groupHotCount = 0; foreach (var g in groupNodes) { sw.Restart(); var b = g.BoundingBox(); groupHotTotalMs += sw.ElapsedMilliseconds; groupHotCount++; } // 重复调用同一组节点(缓存特征:第二次起应接近 0) ModelItem sampleGroup = groupNodes.FirstOrDefault(); long repeatFirstMs = 0; long repeatSecondMs = 0; if (sampleGroup != null) { sw.Restart(); var b1 = sampleGroup.BoundingBox(); repeatFirstMs = sw.ElapsedMilliseconds; sw.Restart(); var b2 = sampleGroup.BoundingBox(); repeatSecondMs = sw.ElapsedMilliseconds; } return new { enumMs, totalCount, groupSampleCount = groupColdCount, geometrySampleCount = geomColdCount, groupColdAvgMs = groupColdCount > 0 ? groupColdTotalMs / (double)groupColdCount : 0, geomColdAvgMs = geomColdCount > 0 ? geomColdTotalMs / (double)geomColdCount : 0, groupColdTotalMs, geomColdTotalMs, rootFirstMs, rootSecondMs, groupHotAvgMs = groupHotCount > 0 ? groupHotTotalMs / (double)groupHotCount : 0, groupHotTotalMs, repeatFirstMs, repeatSecondMs, rootBox = $"({rootBox.Min.X:F1},{rootBox.Min.Y:F1},{rootBox.Min.Z:F1})~({rootBox.Max.X:F1},{rootBox.Max.Y:F1},{rootBox.Max.Z:F1})" }; } private object BuildTreeDumpPayload(Dictionary query) { // 【实验端点】模型树结构对比(主 NW vs 副实例导出模型) // 支持 ?name=xxx:返回匹配对象的完整层级链(对比主/副实例同一对象) Document doc = Autodesk.Navisworks.Api.Application.ActiveDocument; if (doc == null || doc.IsClear) { return new { error = "无活动文档" }; } string matchName = GetOptionalQueryValue(query, "name"); if (!string.IsNullOrWhiteSpace(matchName)) { var chains = new List(); int matchCount = 0; foreach (var model in doc.Models) { if (model.RootItem == null) { continue; } foreach (var item in model.RootItem.DescendantsAndSelf) { if (string.Equals(item.DisplayName, matchName, StringComparison.OrdinalIgnoreCase)) { matchCount++; chains.Add(BuildAncestorNameChain(item)); if (chains.Count >= 5) { break; } } } } return new { documentName = doc.FileName, modelCount = doc.Models.Count, nameMatch = matchName, matchCount, chains }; } var models = new List(); foreach (var model in doc.Models) { var root = model.RootItem; var topChildren = new List(); if (root?.Children != null) { foreach (var child in root.Children) { topChildren.Add(child.DisplayName ?? ""); if (topChildren.Count >= 8) { break; } } } models.Add(new { modelName = root?.DisplayName ?? "", topChildren, totalModels = doc.Models.Count }); } return new { documentName = doc.FileName, modelCount = doc.Models.Count, models }; } private object BuildTraversableItemsPayload() { // 【实验端点】查询当前文档的可通行物流属性对象(验证通道属性是否在模型文件中) var items = CategoryAttributeManager.GetAllTraversableLogisticsItems(); return new { count = items?.Count ?? 0, names = items?.Select(i => i.DisplayName) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct() .ToList() ?? new List() }; } private object BuildStatusPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); PathRoute currentRoute = pathManager?.CurrentRoute; List allRoutes = pathManager?.GetAllRoutes(); return new { service = new { name = "NavisworksTransport.TestAutomation", protocol = "http", version = 1, isRunning = IsRunning, baseUrl = BaseUrl, port = Port, startedAtUtc = _startedAtUtc == default(DateTime) ? null : _startedAtUtc.ToString("o"), lastStartError = _lastStartError }, environment = new { processId = System.Diagnostics.Process.GetCurrentProcess().Id, machineName = Environment.MachineName, logFilePath = LogManager.LogFilePath }, document = new { hasActiveDocument = activeDocument != null, fileName = activeDocument?.FileName, title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), modelCount = activeDocument?.Models?.Count ?? 0 }, pathManager = new { isAvailable = pathManager != null, isDatabaseReady = pathManager != null && pathManager.IsDatabaseReady, routeCount = allRoutes?.Count ?? 0, currentRouteId = currentRoute?.Id, currentRouteName = currentRoute?.Name, currentRoutePathType = currentRoute?.PathType.ToString(), currentRoutePointCount = currentRoute?.Points?.Count ?? 0 }, animation = new { isAvailable = animationManager != null, currentState = animationManager?.CurrentState.ToString(), isAnimating = animationManager?.IsAnimating ?? false, currentFrame = animationManager?.CurrentFrame ?? 0, totalFrames = animationManager?.TotalFrames ?? 0, hasTrackedRotation = animationManager?.HasTrackedRotation ?? false, currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI } }; } private object BuildDebugSnapshotPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); PathRoute currentRoute = pathManager?.CurrentRoute; ModelItem controlledObject = ResolveControlledObject(animationManager); var trackedState = ResolveTrackedState(animationManager, controlledObject); var geometryState = ResolveGeometryState(controlledObject); var boundingBoxState = ResolveBoundingBoxState(controlledObject); return new { snapshotVersion = 1, capturedAtUtc = DateTime.UtcNow.ToString("o"), document = new { hasActiveDocument = activeDocument != null, fileName = activeDocument?.FileName, title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), modelCount = activeDocument?.Models?.Count ?? 0 }, route = new { isAvailable = currentRoute != null, id = currentRoute?.Id, name = currentRoute?.Name, pathType = currentRoute?.PathType.ToString(), pointCount = currentRoute?.Points?.Count ?? 0 }, animation = new { isAvailable = animationManager != null, currentState = animationManager?.CurrentState.ToString(), isAnimating = animationManager?.IsAnimating ?? false, currentFrame = animationManager?.CurrentFrame ?? 0, totalFrames = animationManager?.TotalFrames ?? 0, currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI, hasTrackedRotation = animationManager?.HasTrackedRotation ?? false }, controlledObject = new { exists = controlledObject != null, displayName = controlledObject?.DisplayName, instanceGuid = controlledObject == null ? null : controlledObject.InstanceGuid.ToString(), isVirtualObject = controlledObject != null && VirtualObjectManager.Instance.IsVirtualObjectActive && ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, controlledObject), trackedState, geometryState, boundingBox = boundingBoxState }, environment = new { processId = System.Diagnostics.Process.GetCurrentProcess().Id, machineName = Environment.MachineName, logFilePath = LogManager.LogFilePath } }; } private object BuildRoutesPayload() { PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); List routes = pathManager?.GetAllRoutes() ?? new List(); PathRoute currentRoute = pathManager?.CurrentRoute; return new { isAvailable = pathManager != null, currentRouteId = currentRoute?.Id, currentRouteName = currentRoute?.Name, currentRoutePathType = currentRoute?.PathType.ToString(), totalRouteCount = routes.Count, routes = routes.Select(route => SerializeRoute(route, currentRoute)).ToList(), suggestedAutoTestRoutes = new { ground = TryFindAutoTestRoute(routes, PathType.Ground)?.Name, hoisting = TryFindAutoTestRoute(routes, PathType.Hoisting)?.Name, rail = TryFindAutoTestRoute(routes, PathType.Rail)?.Name } }; } /// /// 枚举模型叶子物体:DisplayName + ModelPath(PathId) + 祖先名链。 /// 供集成测试动态定位真实物体(ModelPath 跨会话不稳定,测试不应硬编码 PathId)。 /// private object BuildModelItemsPayload(Dictionary query) { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; if (activeDocument == null) { throw new InvalidOperationException("当前没有活动文档"); } string nameFilter = GetOptionalQueryValue(query, "name"); int maxResults = 200; if (int.TryParse(GetOptionalQueryValue(query, "maxResults"), out int parsedMax) && parsedMax > 0) { maxResults = Math.Min(parsedMax, 500); } var items = new List(); foreach (Model model in activeDocument.Models) { if (model?.RootItem == null) { continue; } foreach (ModelItem item in model.RootItem.DescendantsAndSelf) { if (item == null) { continue; } // 仅枚举有显示名的节点(叶子几何节点 DisplayName 多为空,名字在层级节点上) if (string.IsNullOrWhiteSpace(item.DisplayName)) { continue; } if (!string.IsNullOrWhiteSpace(nameFilter) && item.DisplayName.IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) < 0 && BuildAncestorNameChain(item).IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) < 0) { continue; } string pathId = null; try { var path = activeDocument.Models.CreatePathId(item); pathId = path.PathId; } catch { // 忽略路径解析失败 } // 仅在有名字过滤时计算包围盒中心(枚举全量时避免性能开销) object boundingBoxCenter = null; if (!string.IsNullOrWhiteSpace(nameFilter)) { try { var bbox = item.BoundingBox(); boundingBoxCenter = new { x = bbox.Center.X, y = bbox.Center.Y, z = bbox.Center.Z }; } catch { // 忽略包围盒解析失败 } } items.Add(new { displayName = item.DisplayName, instanceGuid = item.InstanceGuid.ToString(), pathId, boundingBoxCenter, ancestorChain = BuildAncestorNameChain(item) }); if (items.Count >= maxResults) { break; } } } return new { totalCount = items.Count, maxResults, nameFilter, items }; } private static string BuildAncestorNameChain(ModelItem item) { try { var segments = new List(); var current = item; int guard = 0; while (current != null && guard < 15) { var name = current.DisplayName; if (!string.IsNullOrWhiteSpace(name)) { segments.Add(name.Trim()); } current = current.Parent; guard++; } segments.Reverse(); return string.Join(" / ", segments); } catch { return null; } } /// /// 查询碰撞检测记录(集成测试验证检测记录持久化:动画参数/关联路径正确落库)。 /// /// /// 路径分析(效率/安全评分),集成测试验证分析链路与评分结构。 /// /// /// 剖面盒导出(T16):mode=bbox 用指定包围盒导出 NWD(集成测试可控,不依赖 UI 剖面盒状态); /// mode=activeView 用当前视图剖面盒(需 UI 已激活 Box 模式)。 /// private static object ExportSectionBoxPayload(Dictionary query) { Document document = Autodesk.Navisworks.Api.Application.ActiveDocument; if (document == null) { throw new InvalidOperationException("当前没有活动文档"); } string mode = GetOptionalQueryValue(query, "mode") ?? "bbox"; string filePath = GetOptionalQueryValue(query, "path"); if (string.IsNullOrWhiteSpace(filePath)) { filePath = Path.Combine(Path.GetTempPath(), $"sectionbox-export-{Guid.NewGuid():N}.nwd"); } var exporter = new SectionBoxExporter(); if (string.Equals(mode, "bbox", StringComparison.OrdinalIgnoreCase)) { double x1 = ParseRequiredDouble(query, "x1"); double y1 = ParseRequiredDouble(query, "y1"); double z1 = ParseRequiredDouble(query, "z1"); double x2 = ParseRequiredDouble(query, "x2"); double y2 = ParseRequiredDouble(query, "y2"); double z2 = ParseRequiredDouble(query, "z2"); var bounds = new BoundingBox3D( new Point3D(Math.Min(x1, x2), Math.Min(y1, y2), Math.Min(z1, z2)), new Point3D(Math.Max(x1, x2), Math.Max(y1, y2), Math.Max(z1, z2))); bool prune = ParseBooleanQuery(query, "prune", true); var sw = new System.Diagnostics.Stopwatch(); sw.Start(); var traversal = exporter.GetObjectsAndHiddenItems(document, bounds, prune); sw.Stop(); if (traversal.ObjectsInBox.Count == 0) { throw new InvalidOperationException("指定包围盒内没有找到对象"); } var traversalResultPayload = new { traversalMs = sw.ElapsedMilliseconds, prune }; string exportedPath = exporter.ExportToNwd(document, traversal, filePath); if (string.IsNullOrEmpty(exportedPath) || !File.Exists(exportedPath)) { throw new InvalidOperationException($"NWD 导出失败: {filePath}"); } LogManager.Info($"[测试HTTP] 剖面盒导出完成(bbox): 对象={traversal.ObjectsInBox.Count}, 隐藏={traversal.ItemsToHide.Count}, 文件={exportedPath}"); return new { mode = "bbox", filePath = exportedPath, objectCount = traversal.ObjectsInBox.Count, hiddenNodeCount = traversal.ItemsToHide.Count, fileSize = new FileInfo(exportedPath).Length, traversalMs = traversalResultPayload.traversalMs, prune = traversalResultPayload.prune }; } // activeView 模式:依赖当前视图剖面盒 var result = exporter.ExportSectionBoxFromActiveView(document, filePath); if (!result.Success) { throw new InvalidOperationException(result.ErrorMessage ?? "剖面盒导出失败"); } return new { mode = "activeView", filePath = result.FilePath, objectCount = result.ObjectCount, hiddenNodeCount = result.HiddenNodeCount, fileSize = result.FileSize }; } /// /// 路径编辑集成测试端点(T11):action=remove-point/update-point/orthogonalize/remove-loops。 /// 直接操作临时测试路径,验证 PathPlanningManager 编辑链路。 /// private static object EditRoutePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string action = GetRequiredQueryValue(query, "action"); string routeName = GetRequiredQueryValue(query, "routeName"); PathRoute route = pathManager .GetAllRoutes() ?.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } List orderedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List(); switch (action.ToLowerInvariant()) { case "remove-point": { int pointIndex = ParseRequiredInt(query, "pointIndex"); if (pointIndex < 0 || pointIndex >= orderedPoints.Count) { throw new InvalidOperationException($"路径点索引越界: {pointIndex}"); } // 真正的删点走 PathRoute.RemovePoint(更新数据 + 重算 + 入库), // RemovePathPoint 仅处理 3D 标记可视化 if (!route.RemovePoint(orderedPoints[pointIndex])) { throw new InvalidOperationException($"删除路径点失败: {routeName}[{pointIndex}]"); } break; } case "update-point": { int pointIndex = ParseRequiredInt(query, "pointIndex"); double x = ParseRequiredDouble(query, "x"); double y = ParseRequiredDouble(query, "y"); double z = ParseRequiredDouble(query, "z"); if (pointIndex < 0 || pointIndex >= orderedPoints.Count) { throw new InvalidOperationException($"路径点索引越界: {pointIndex}"); } var newPosition = new Point3D(x, y, z); if (!pathManager.UpdatePathPointWithConstraints(route, pointIndex, newPosition)) { throw new InvalidOperationException($"更新路径点失败: {routeName}[{pointIndex}]"); } break; } case "orthogonalize": { if (!pathManager.OrthogonalizePath(route)) { throw new InvalidOperationException($"路径正交化失败: {routeName}"); } break; } case "remove-loops": { // 无矩形环可去(返回 false)属正常情况,不视为失败 pathManager.RemoveRectangularLoops(route); break; } default: throw new InvalidOperationException($"未知编辑动作: {action}(支持 remove-point/update-point/orthogonalize/remove-loops)"); } LogManager.Info($"[测试HTTP] 路径编辑完成: action={action}, route={routeName}"); List updatedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List(); return new { action, routeName, pointCount = updatedPoints.Count, points = updatedPoints.Select(point => new { index = point.Index, name = point.Name, type = point.Type.ToString(), position = SerializePoint3D(point.Position) }).ToList() }; } private static int ParseRequiredInt(Dictionary query, string key) { string raw = GetRequiredQueryValue(query, key); if (!int.TryParse(raw, out int value)) { throw new InvalidOperationException($"无法解析整数参数 {key}: {raw}"); } return value; } private static double ParseRequiredDouble(Dictionary query, string key) { string raw = GetRequiredQueryValue(query, key); if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) { throw new InvalidOperationException($"无法解析数值参数 {key}: {raw}"); } return value; } /// /// 批量任务队列添加(集成测试验证队列管理,不执行 ProcessQueueAsync 以免长时间批量动画)。 /// query:routeName(必填)、frameRate、durationSeconds。 /// /// /// 触发批处理队列执行(F1):后台执行,立即返回(ProcessQueueAsync 含模态进度条, /// 同步等待会阻塞 HTTP 响应;调用方轮询数据库 BatchQueueItems 状态跟踪)。 /// private static object BatchQueueProcessPayload(Dictionary query) { var manager = BatchQueueManager.Instance; manager.SetPathPlanningManager(RequirePathManager()); LogManager.Info("[测试HTTP] 开始后台处理批处理队列(F1)"); Task.Run(async () => { try { await manager.ProcessQueueAsync().ConfigureAwait(false); } catch (Exception ex) { LogManager.Error($"[测试HTTP] 后台批处理执行异常: {ex.Message}", ex); } }); return new { started = true, queueCount = manager.QueueCount, isExecuting = manager.IsExecuting }; } private static async Task BatchQueueStatusPayload(Dictionary query) { var manager = BatchQueueManager.Instance; manager.SetPathPlanningManager(RequirePathManager()); var database = BatchQueueManager.Instance.GetDatabase(); int limit = int.TryParse(GetOptionalQueryValue(query, "limit"), out int parsedLimit) && parsedLimit > 0 ? parsedLimit : 10; var items = await database.GetBatchQueueItemsAsync( statusFilter: NavisworksTransport.Core.Models.BatchQueueStatus.All, limit: limit).ConfigureAwait(false); return new { isExecuting = manager.IsExecuting, queueCount = manager.QueueCount, items = items.Select(item => new { itemId = item.Id, routeName = item.PathRouteName, pathType = item.PathType.ToString(), status = item.Status.ToString(), isVirtualObject = item.IsVirtualObject, movingObjectName = item.MovingObjectName, collisionCount = item.CollisionCount, detectionRecordId = item.DetectionRecordId, errorMessage = item.ErrorMessage, createdTime = item.CreatedTime.ToString("O"), endTime = item.EndTime?.ToString("O") }).ToList() }; } private static async Task BatchQueueAddAsync(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetRequiredQueryValue(query, "routeName"); PathRoute route = pathManager .GetAllRoutes() ?.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } int frameRate = 15; if (int.TryParse(GetOptionalQueryValue(query, "frameRate"), out int parsedFrameRate) && parsedFrameRate > 0) { frameRate = parsedFrameRate; } double durationSeconds = 5.0; if (double.TryParse(GetOptionalQueryValue(query, "durationSeconds"), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedDuration) && parsedDuration > 0) { durationSeconds = parsedDuration; } bool isVirtualObject = ParseBooleanQuery(query, "isVirtualObject", true); string movingObjectName = GetOptionalQueryValue(query, "movingObjectName"); var manager = BatchQueueManager.Instance; manager.SetPathPlanningManager(pathManager); var item = new NavisworksTransport.Core.Models.BatchQueueItem { RouteId = route.Id, PathRouteName = route.Name, PathType = route.PathType, Status = NavisworksTransport.Core.Models.BatchQueueStatus.Pending, CreatedTime = DateTime.Now, FrameRate = frameRate, DurationSeconds = durationSeconds, IsVirtualObject = isVirtualObject, MovingObjectName = isVirtualObject ? null : movingObjectName, // 虚拟物体尺寸(米 → 模型单位);DetectAllObjects 保持默认 true(F1 剖面盒自动检测场景) VirtualObjectLength = isVirtualObject ? 0.4 * UnitsConverter.GetMetersToUnitsConversionFactor() : 0, VirtualObjectWidth = isVirtualObject ? 0.4 * UnitsConverter.GetMetersToUnitsConversionFactor() : 0, VirtualObjectHeight = isVirtualObject ? 0.4 * UnitsConverter.GetMetersToUnitsConversionFactor() : 0 }; int itemId = await manager.AddQueueItemAsync(item).ConfigureAwait(false); // 创建检测记录(与 UI“加入批处理”一致):F1 副实例检测结果回写主库检测记录 // (动画检测历史表格读主库 CollisionDetectionRecords) int? detectionRecordId = null; try { var pathDatabase = pathManager?.GetPathDatabase(); if (pathDatabase != null) { var record = new NavisworksTransport.CollisionDetectionRecord { RouteId = route.Id, CreatedTime = DateTime.Now, FrameRate = frameRate, DurationSeconds = durationSeconds, IsVirtualObject = isVirtualObject, AnimatedObjectName = isVirtualObject ? "虚拟物体" : movingObjectName, DetectAllObjects = true, Description = $"测试批处理添加于 {DateTime.Now:yyyy-MM-dd HH:mm:ss}", TestName = $"批处理任务创建_{DateTime.Now:MMdd_HHmmssfff}", TestTime = DateTime.Now, CollisionCount = 0, AnimationCollisionCount = 0 }; detectionRecordId = pathDatabase.SaveCollisionDetectionRecord(record); item.DetectionRecordId = detectionRecordId; await manager.GetDatabase().UpdateBatchQueueItemAsync(item).ConfigureAwait(false); LogManager.Info($"[测试HTTP] 批处理项已创建检测记录 (Id={detectionRecordId}, itemId={itemId})"); } } catch (Exception recordEx) { LogManager.Warning($"[测试HTTP] 创建批处理检测记录失败: {recordEx.Message}"); } // 真实物体:主实例解析名字并写入 MovingObject 引用(BatchQueueItems 表无 MovingObjectName 列, // F1 副实例检测从 ModelItemReferences 读取移动物体名) if (!isVirtualObject) { if (string.IsNullOrWhiteSpace(movingObjectName)) { throw new InvalidOperationException("真实物体批处理必须提供 movingObjectName"); } string resolvedDisplayName = null; string resolvedPathId = null; int resolvedModelIndex = 0; InvokeOnUiThread(() => { Document doc = Autodesk.Navisworks.Api.Application.ActiveDocument; foreach (Model model in doc.Models) { if (model?.RootItem == null) { continue; } foreach (ModelItem match in model.RootItem.DescendantsAndSelf) { if (match == null) { continue; } if (string.Equals(match.DisplayName, movingObjectName, StringComparison.OrdinalIgnoreCase)) { resolvedDisplayName = match.DisplayName; try { var path = doc.Models.CreatePathId(match); resolvedModelIndex = path.ModelIndex; resolvedPathId = path.PathId; } catch { } return true; } } } return true; }); if (string.IsNullOrWhiteSpace(resolvedDisplayName)) { throw new InvalidOperationException($"找不到移动物体: {movingObjectName}"); } await pathManager.AddModelItemReferenceAsync( itemId, "BatchQueueItem", resolvedModelIndex, resolvedPathId, resolvedDisplayName, movingObjectName, "MovingObject").ConfigureAwait(false); LogManager.Info($"[测试HTTP] 已写入批处理移动物体引用: {movingObjectName} (itemId={itemId})"); } LogManager.Info($"[测试HTTP] 已添加批量队列项: {routeName}, itemId={itemId}, queueCount={manager.QueueCount}"); return new { itemId, routeName, queueCount = manager.QueueCount, isExecuting = manager.IsExecuting }; } private static object AnalyzePathPayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetRequiredQueryValue(query, "routeName"); string strategy = GetOptionalQueryValue(query, "strategy") ?? "安全优先"; PathRoute route = pathManager .GetAllRoutes() ?.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } PathAnalysisResult result = pathManager.AnalyzeRoute(route, strategy); return new { routeName, routeId = result.RouteId, strategy = result.Strategy, collisionCount = result.CollisionCount, safetyScore = result.SafetyScore, efficiencyScore = result.EfficiencyScore, overallScore = result.OverallScore, turnDifficultyScore = result.TurnDifficultyScore, tortuosityScore = result.TortuosityScore, redundancyScore = result.RedundancyScore, hotspotCount = result.HotspotCount, analyzedAt = result.AnalysisTime.ToString("o") }; } private static object BuildDetectionRecordPayload(Dictionary query) { string raw = GetRequiredQueryValue(query, "id"); if (!int.TryParse(raw, out int id) || id <= 0) { throw new InvalidOperationException($"无法解析检测记录 ID: {raw}"); } PathPlanningManager pathManager = RequirePathManager(); CollisionDetectionRecord record = pathManager.GetDetectionRecordById(id); if (record == null) { throw new InvalidOperationException($"检测记录不存在: {id}"); } return new { id = record.Id, routeId = record.RouteId, testName = record.TestName, frameRate = record.FrameRate, durationSeconds = record.DurationSeconds, isVirtualObject = record.IsVirtualObject, animatedObjectName = record.AnimatedObjectName, collisionCount = record.CollisionCount, createdAt = record.CreatedTime.ToString("o") }; } /// /// 解析实例端口:优先环境变量 TRANSPORTPLUGIN_TEST_PORT,默认 18777。 /// 支持多 Navisworks 实例并行(E4 副实例检测 worker)。 /// private int ResolvePort() { if (_portResolved) { return _resolvedPort; } int port = DefaultPort; string raw = Environment.GetEnvironmentVariable(PortEnvironmentVariable); if (!string.IsNullOrWhiteSpace(raw) && int.TryParse(raw, out int parsedPort) && parsedPort > 0 && parsedPort < 65536) { port = parsedPort; } _resolvedPort = port; _portResolved = true; return port; } /// /// F2:序列化碰撞对象详情(name + 副实例 PathId + 层级链), /// 供主 NW 按层级链(导出保留树结构,去根一致)映射回主模型。 /// private static List SerializeCollidedObjects( List allCollisions, List nameList) { var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); if (allCollisions != null) { foreach (var collision in allCollisions) { if (collision?.Item2 == null) { continue; } string name = collision.Item2.DisplayName; if (string.IsNullOrWhiteSpace(name) || !seen.Add(name)) { continue; } if (result.Count >= 50) { break; } string pathId = null; try { var path = Autodesk.Navisworks.Api.Application.ActiveDocument.Models.CreatePathId(collision.Item2); pathId = path.PathId; } catch { } result.Add(new { name, pathId, ancestorChain = BuildAncestorNameChain(collision.Item2) }); } } // fallback:AllCollisions 无 Item2 引用时退回名字列表 if (result.Count == 0 && nameList != null) { foreach (string name in nameList.Take(50)) { if (seen.Add(name)) { result.Add(new { name, pathId = (string)null, ancestorChain = (string)null }); } } } return result; } private object BuildSelectionPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; var selectedItems = activeDocument?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() ?? new List(); return new { hasActiveDocument = activeDocument != null, selectionCount = selectedItems.Count, selectedItems = selectedItems.Select(SerializeModelItem).ToList() }; } private object SelectRoutePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetRequiredQueryValue(query, "name"); List routes = pathManager.GetAllRoutes() ?? new List(); PathRoute selectedRoute = routes.FirstOrDefault(route => string.Equals(route.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (selectedRoute == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } pathManager.SetCurrentRoute(selectedRoute); LogManager.Info($"[测试HTTP] 已切换当前路径: {selectedRoute.Name} ({selectedRoute.PathType})"); return new { selected = SerializeRoute(selectedRoute, selectedRoute), totalRouteCount = routes.Count }; } private object SelectDefaultRoutePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string pathTypeValue = GetRequiredQueryValue(query, "pathType"); string prefix = GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix; if (!Enum.TryParse(pathTypeValue, true, out PathType pathType)) { throw new InvalidOperationException($"无法解析路径类型: {pathTypeValue}"); } List routes = pathManager.GetAllRoutes() ?? new List(); PathRoute selectedRoute = routes .FirstOrDefault(route => route.PathType == pathType && !string.IsNullOrWhiteSpace(route.Name) && route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); if (selectedRoute == null && string.Equals(prefix, DefaultAutoTestRoutePrefix, StringComparison.Ordinal)) { // 标准自动测试路径缺失时自动导入后重试(仅限自动测试_ 前缀) EnsureAutoTestRoutesImported(pathManager); routes = pathManager.GetAllRoutes() ?? new List(); selectedRoute = routes .FirstOrDefault(route => route.PathType == pathType && !string.IsNullOrWhiteSpace(route.Name) && route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } if (selectedRoute == null) { throw new InvalidOperationException( $"找不到默认测试路径: prefix={prefix}, pathType={pathType}"); } pathManager.SetCurrentRoute(selectedRoute); LogManager.Info($"[测试HTTP] 已切换默认测试路径: {selectedRoute.Name} ({selectedRoute.PathType})"); return new { selectionRule = new { prefix, pathType = pathType.ToString() }, selected = SerializeRoute(selectedRoute, selectedRoute) }; } private object SelectAnimatedObjectPayload(Dictionary query) { AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); PathPlanningManager pathManager = RequirePathManager(); animationViewModel.SetPathPlanningManager(pathManager); ModelItem animatedObject = ResolveAnimatedObjectFromQuery(query); SelectDocumentItem(animatedObject); if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null)) { throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令"); } animationViewModel.UseVirtualObject = false; animationViewModel.SelectAnimatedObjectCommand.Execute(null); if (!IsSameModelItem(animationViewModel.SelectedAnimatedObject, animatedObject)) { throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); } return new { selectedAnimatedObject = SerializeModelItem(animatedObject), canGenerateAnimation = animationViewModel.CanGenerateAnimation, isManualCollisionTargetEnabled = animationViewModel.IsManualCollisionTargetEnabled }; } private async Task RunVirtualCollisionTestAsync(Dictionary query, PathType? fixedPathType) { await _testExecutionLock.WaitAsync().ConfigureAwait(false); try { return await RunVirtualCollisionTestCoreAsync(query, fixedPathType).ConfigureAwait(false); } finally { _testExecutionLock.Release(); } } private async Task RunVirtualCollisionTestCoreAsync(Dictionary query, PathType? fixedPathType) { using (EnableAutoConfirmCollisionAnalysisDialogs()) using (EnableAutoChooseCreateNewDetectionRecord()) { int timeoutSeconds = ParseTimeoutSeconds(query, 180); DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); var setupResult = InvokeOnUiThread(() => PrepareVirtualCollisionTest(query, fixedPathType)); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationManager = PathAnimationManager.GetInstance(); return animationManager != null && animationManager.TotalFrames > 0 && (animationManager.CurrentState == AnimationState.Ready || animationManager.CurrentState == AnimationState.Finished); }), "等待动画生成完成超时").ConfigureAwait(false); InvokeOnUiThread(() => { StartPreparedAnimation(); return true; }); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentState == AnimationState.Finished), "等待动画播放和碰撞检测完成超时").ConfigureAwait(false); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationVm = RequireAnimationControlViewModel(); return animationVm.HasGeneratedCollisionReport; }), "等待碰撞报告生成超时").ConfigureAwait(false); return InvokeOnUiThread(() => { AnimationControlViewModel animationVm = RequireAnimationControlViewModel(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); CollisionReportResult report = animationVm.LastGeneratedReport; // 🔥 通道排除诊断:打印通道对象 vs 碰撞对象对比(验证楼板等通道是否被正确排除) try { var channelCache = ClashDetectiveIntegration.GetChannelObjectsCache(); var collidedNames = report?.AllCollisions?.Select(c => c.Item2?.DisplayName) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct() .ToList() ?? new List(); var channelNames = channelCache?.Select(c => c.DisplayName) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct() .ToList() ?? new List(); var channelHit = collidedNames.Intersect(channelNames, StringComparer.OrdinalIgnoreCase).ToList(); LogManager.Info($"[通道诊断] 通道对象({channelNames.Count})={string.Join(", ", channelNames)}"); LogManager.Info($"[通道诊断] 碰撞对象({collidedNames.Count})={string.Join(", ", collidedNames)}"); LogManager.Info($"[通道诊断] 碰撞中含通道: {channelHit.Count} 个 = {string.Join(", ", channelHit)}"); } catch (Exception diagEx) { LogManager.Warning($"[通道诊断] 输出失败: {diagEx.Message}"); } return new { route = setupResult.route, animatedObject = setupResult.animatedObject, requestedPathType = setupResult.pathType.ToString(), timeoutSeconds, automation = new { autoConfirmedCollisionAnalysisDialog = true, autoChooseCreateNewDetectionRecord = true }, animation = new { currentState = animationManager?.CurrentState.ToString(), totalFrames = animationManager?.TotalFrames ?? 0, currentFrame = animationManager?.CurrentFrame ?? 0, detectionRecordId = animationManager?.CurrentDetectionRecordId }, report = report == null ? null : new { totalCollisions = report.TotalCollisions, uniqueCollidedObjectsCount = report.UniqueCollidedObjectsCount, pathName = report.PathName, movingObjectInfo = report.MovingObjectInfo, hasScreenshots = report.Screenshots != null && report.Screenshots.Count > 0, screenshotCount = report.Screenshots?.Count ?? 0, resultId = report.ResultId, routeId = report.RouteId, // F2:碰撞对象详情(name + 副实例 PathId + 层级链),用于跨实例报告映射回主模型 collidedObjects = SerializeCollidedObjects(report.AllCollisions, report.CollidedObjectsList) } }; }); } } private static object BuildEnvelope(bool ok, object data = null, string error = null) { return new Dictionary { ["ok"] = ok, ["data"] = data, ["error"] = error }; } private static T InvokeOnUiThread(Func func) { var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher == null || dispatcher.CheckAccess()) { return func(); } return dispatcher.Invoke(func); } private static ModelItem ResolveControlledObject(PathAnimationManager animationManager) { if (VirtualObjectManager.Instance.IsVirtualObjectActive && VirtualObjectManager.Instance.CurrentVirtualObject != null) { return VirtualObjectManager.Instance.CurrentVirtualObject; } return animationManager?.AnimatedObject; } private static object SerializeModelItem(ModelItem item) { if (item == null) { return null; } return new { displayName = item.DisplayName, instanceGuid = item.InstanceGuid.ToString() }; } private static object SerializeRoute(PathRoute route, PathRoute currentRoute) { return new { id = route?.Id, name = route?.Name, pathType = route?.PathType.ToString(), pointCount = route?.Points?.Count ?? 0, isCurrent = currentRoute != null && ReferenceEquals(route, currentRoute), matchesAutoTestPrefix = !string.IsNullOrWhiteSpace(route?.Name) && route.Name.StartsWith(DefaultAutoTestRoutePrefix, StringComparison.OrdinalIgnoreCase) }; } private static PathRoute TryFindAutoTestRoute( IEnumerable routes, PathType pathType, string routePrefix = DefaultAutoTestRoutePrefix) { string effectivePrefix = string.IsNullOrWhiteSpace(routePrefix) ? DefaultAutoTestRoutePrefix : routePrefix; return routes?.FirstOrDefault(route => route != null && route.PathType == pathType && !string.IsNullOrWhiteSpace(route.Name) && route.Name.StartsWith(effectivePrefix, StringComparison.OrdinalIgnoreCase)); } private static object AnalyzeAutoPathGridPayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null); if (route.Points == null || route.Points.Count < 2) { throw new InvalidOperationException($"路径点不足,无法分析自动路径网格: {route.Name}"); } List orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); PathPoint startPoint = orderedPoints.First(); PathPoint endPoint = orderedPoints.Last(); BoundingBox3D bounds = InvokePathManagerPrivate(pathManager, "GetModelBounds"); double gridSize = InvokePathManagerPrivate(pathManager, "CalculateOptimalGridSize", bounds); var config = ConfigManager.Instance.Current?.PathEditing ?? throw new InvalidOperationException("缺少 PathEditing 配置,无法分析自动路径网格"); double objectLengthInMeters = config.ObjectLengthMeters; double objectWidthInMeters = config.ObjectWidthMeters; double objectHeightInMeters = config.ObjectHeightMeters; double safetyMarginInMeters = config.SafetyMarginMeters; double objectRadiusInMeters = Math.Max(objectLengthInMeters, objectWidthInMeters) / 2.0; var gridMapGenerator = new GridMapGenerator(); GridMap gridMap = gridMapGenerator.GenerateFromBIM( bounds, gridSize, objectRadiusInMeters, safetyMarginInMeters, startPoint.Position, endPoint.Position, objectHeightInMeters); return new { requestedPathType = route.PathType.ToString(), route = new { id = route.Id, name = route.Name, pathType = route.PathType.ToString(), pointCount = route.Points.Count, startPoint = SerializePathPoint(startPoint), endPoint = SerializePathPoint(endPoint) }, planningParameters = new { gridSizeInMeters = gridSize, objectLengthInMeters, objectWidthInMeters, objectHeightInMeters, objectRadiusInMeters, safetyMarginInMeters }, bounds = new { min = SerializePoint3D(bounds.Min), max = SerializePoint3D(bounds.Max) }, gridStats = SerializeGridStats(gridMap), obstacleDiagnostics = BuildObstacleDiagnostics(gridMapGenerator, gridMap, objectHeightInMeters, safetyMarginInMeters) }; } private static object RunAutoPathPayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null); if (route.Points == null || route.Points.Count < 2) { throw new InvalidOperationException($"路径点不足,无法执行自动路径规划: {route.Name}"); } List orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); PathPoint startPoint = orderedPoints.First(); PathPoint endPoint = orderedPoints.Last(); var config = ConfigManager.Instance.Current?.PathEditing ?? throw new InvalidOperationException("缺少 PathEditing 配置,无法执行自动路径规划"); BoundingBox3D bounds = InvokePathManagerPrivate(pathManager, "GetModelBounds"); double gridSize = ParsePositiveDoubleQuery( query, "gridSizeInMeters", InvokePathManagerPrivate(pathManager, "CalculateOptimalGridSize", bounds)); double objectHeightInMeters = config.ObjectHeightMeters; double objectLengthInMeters = ParsePositiveDoubleQuery(query, "objectLengthInMeters", config.ObjectLengthMeters); double objectWidthInMeters = ParsePositiveDoubleQuery(query, "objectWidthInMeters", config.ObjectWidthMeters); double safetyMarginInMeters = ParseNonNegativeDoubleQuery(query, "safetyMarginInMeters", config.SafetyMarginMeters); double objectRadiusInMeters = Math.Max(objectLengthInMeters, objectWidthInMeters) / 2.0; PathStrategy strategy = PathStrategy.Shortest; string strategyText = GetOptionalQueryValue(query, "strategy"); if (!string.IsNullOrWhiteSpace(strategyText) && Enum.TryParse(strategyText, true, out PathStrategy parsedStrategy)) { strategy = parsedStrategy; } PathRoute autoRoute = pathManager .AutoPlanPath( startPoint, endPoint, objectRadiusInMeters, safetyMarginInMeters, gridSize, objectHeightInMeters, strategy) .GetAwaiter() .GetResult(); if (autoRoute == null) { throw new InvalidOperationException("AutoPlanPath 返回空结果"); } return new { sourceRoute = new { id = route.Id, name = route.Name, pathType = route.PathType.ToString() }, generatedRoute = new { id = autoRoute.Id, name = autoRoute.Name, pathType = autoRoute.PathType.ToString(), pointCount = autoRoute.Points?.Count ?? 0, length = autoRoute.TotalLength }, planningParameters = new { gridSizeInMeters = gridSize, objectHeightInMeters, objectLengthInMeters, objectWidthInMeters, objectRadiusInMeters, safetyMarginInMeters, strategy = strategy.ToString() }, segmentValidation = ValidateRouteSegmentsAgainstGridMap(autoRoute, autoRoute.AssociatedGridMap) }; } /// /// 导入路径文件(JSON/XML)并注册到路径管理器,返回导入路径的完整信息。 /// 供集成测试验证“导入 → 属性映射 → 长度计算 → 路径可用性”完整流程。 /// overwrite=true 时同名路径先删除后导入(保证测试可重复执行且不累积数据)。 /// /// /// 导出路径到文件(XML/JSON),供集成测试验证导出→导入回环一致性。 /// query:routeName(必填)、format(xml/json,默认 xml)、path(可选导出路径)。 /// private static object ExportRouteFilePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetRequiredQueryValue(query, "routeName"); string formatText = GetOptionalQueryValue(query, "format") ?? "xml"; PathRoute route = pathManager .GetAllRoutes() ?.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } bool isJson = string.Equals(formatText, "json", StringComparison.OrdinalIgnoreCase); string extension = isJson ? ".json" : ".xml"; string exportPath = GetOptionalQueryValue(query, "path"); if (string.IsNullOrWhiteSpace(exportPath)) { exportPath = Path.Combine(Path.GetTempPath(), $"route-export-{route.Id}-{Guid.NewGuid():N}{extension}"); } string directory = Path.GetDirectoryName(exportPath); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } var dataManager = new PathDataManager(); bool exported = isJson ? dataManager.ExportToJson(new List { route }, exportPath) : dataManager.ExportToXml(new List { route }, exportPath); if (!exported) { throw new InvalidOperationException($"导出路径失败: {routeName} -> {exportPath}"); } LogManager.Info($"[测试HTTP] 已导出路径: {routeName} ({formatText}) -> {exportPath}"); return new { routeName, format = isJson ? "json" : "xml", exportedFilePath = exportPath, pointCount = route.Points?.Count ?? 0, totalLengthInMeters = route.TotalLength }; } private static object ImportRouteFilePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string filePath = GetRequiredQueryValue(query, "path"); string formatText = GetOptionalQueryValue(query, "format"); bool overwrite = ParseBooleanQuery(query, "overwrite", false); if (!File.Exists(filePath)) { throw new InvalidOperationException($"导入文件不存在: {filePath}"); } ExportFormat format; if (string.IsNullOrWhiteSpace(formatText)) { string extension = Path.GetExtension(filePath).ToLowerInvariant(); format = string.Equals(extension, ".json", StringComparison.Ordinal) ? ExportFormat.Json : ExportFormat.Xml; } else if (Enum.TryParse(formatText, true, out ExportFormat parsedFormat) && (parsedFormat == ExportFormat.Json || parsedFormat == ExportFormat.Xml)) { format = parsedFormat; } else { throw new InvalidOperationException($"无法解析导入格式: {formatText}(仅支持 json/xml)"); } var dataManager = new PathDataManager(); List importedRoutes = format == ExportFormat.Json ? dataManager.ImportFromJson(filePath) : dataManager.ImportFromXml(filePath); if (importedRoutes == null || importedRoutes.Count == 0) { throw new InvalidOperationException($"导入文件中没有有效路径: {filePath}"); } var serializedRoutes = new List(); foreach (PathRoute importedRoute in importedRoutes) { if (overwrite) { // 覆盖模式:先删除同名路径,保证导入名称精确、测试可重复执行 PathRoute existingRoute = pathManager .GetAllRoutes() ?.FirstOrDefault(r => string.Equals(r.Name, importedRoute.Name, StringComparison.Ordinal)); if (existingRoute != null) { pathManager.DeleteRoute(existingRoute); LogManager.Info($"[测试HTTP] 已删除同名路径以便覆盖导入: {existingRoute.Name}"); } } // 注册到路径管理器(自动入库),供后续切换/动画流程使用 pathManager.AddRoute(importedRoute); serializedRoutes.Add(new { id = importedRoute.Id, name = importedRoute.Name, pathType = importedRoute.PathType.ToString(), pointCount = importedRoute.Points?.Count ?? 0, totalLengthInMeters = importedRoute.TotalLength, railMountMode = importedRoute.RailMountMode.ToString(), railPathDefinitionMode = importedRoute.RailPathDefinitionMode.ToString(), railNormalOffset = importedRoute.RailNormalOffset, railPreferredNormal = importedRoute.RailPreferredNormal == null ? null : new { x = importedRoute.RailPreferredNormal.X, y = importedRoute.RailPreferredNormal.Y, z = importedRoute.RailPreferredNormal.Z } }); } LogManager.Info($"[测试HTTP] 已导入路径文件 {Path.GetFileName(filePath)},共 {serializedRoutes.Count} 条"); Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; double metersToModelUnits = activeDocument == null ? 1.0 : UnitsConverter.GetMetersToUnitsConversionFactor(activeDocument.Units); return new { filePath, format = format.ToString(), importedCount = serializedRoutes.Count, metersToModelUnits, routes = serializedRoutes }; } private static object ValidateRouteSegmentsAgainstGridMap(PathRoute route, GridMap gridMap) { if (route?.Points == null || route.Points.Count < 2 || gridMap == null) { return new { isAvailable = false, segmentCount = 0, sampleCount = 0, invalidSampleCount = 0, blockedSampleCount = 0, failures = new object[0] }; } var orderedPoints = route.Points.OrderBy(point => point.Index).ToList(); var failures = new List(); int sampleCount = 0; int invalidSampleCount = 0; int blockedSampleCount = 0; int blockedCellInteriorIntersectionCount = 0; double sampleSpacing = Math.Max(gridMap.CellSize * 0.25, 1e-6); for (int segmentIndex = 0; segmentIndex < orderedPoints.Count - 1; segmentIndex++) { Point3D start = orderedPoints[segmentIndex].Position; Point3D end = orderedPoints[segmentIndex + 1].Position; double segmentLength = CalculateHostHorizontalDistance(start, end, gridMap.CoordinateSystemType); int samples = Math.Max(1, (int)Math.Ceiling(segmentLength / sampleSpacing)); for (int sampleIndex = 0; sampleIndex <= samples; sampleIndex++) { double t = samples == 0 ? 0.0 : (double)sampleIndex / samples; Point3D samplePoint = InterpolateHostPoint(start, end, t); double sampleElevation = gridMap.GetWorldElevation(samplePoint); GridPoint2D gridPosition = gridMap.WorldToGrid(samplePoint); sampleCount++; if (!gridMap.IsValidGridPosition(gridPosition)) { invalidSampleCount++; AddFailure(failures, segmentIndex, sampleIndex, samplePoint, gridPosition, "GridOutOfRange"); continue; } if (!gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1)) { blockedSampleCount++; AddFailure(failures, segmentIndex, sampleIndex, samplePoint, gridPosition, "NotPassableAtElevation"); } } blockedCellInteriorIntersectionCount += CountBlockedCellInteriorIntersections( start, end, gridMap, segmentIndex, failures); } return new { isAvailable = true, segmentCount = Math.Max(0, orderedPoints.Count - 1), sampleCount, invalidSampleCount, blockedSampleCount, blockedCellInteriorIntersectionCount, failures = failures.Take(20).ToList() }; } private static object BuildRouteGridDiagnosticsPayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetOptionalQueryValue(query, "routeName"); List routes = pathManager.GetAllRoutes() ?? new List(); PathRoute route = string.IsNullOrWhiteSpace(routeName) ? pathManager.CurrentRoute : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException( string.IsNullOrWhiteSpace(routeName) ? "当前没有选中的路径" : $"找不到指定路径: {routeName}"); } GridMap gridMap = route.AssociatedGridMap; if (gridMap == null) { if (!pathManager.TryRestoreGridMapForRoute(route, out gridMap, out string restoreMessage)) { throw new InvalidOperationException($"路径没有关联 GridMap,且无法按保存参数重建,无法诊断: {route.Name}。{restoreMessage}"); } } var orderedPoints = route.Points?.OrderBy(point => point.Index).ToList() ?? new List(); var segmentDiagnostics = new List(); var blockedIntersections = new List(); int intersectedCellCount = 0; for (int segmentIndex = 0; segmentIndex < orderedPoints.Count - 1; segmentIndex++) { PathPoint startPathPoint = orderedPoints[segmentIndex]; PathPoint endPathPoint = orderedPoints[segmentIndex + 1]; Point3D start = startPathPoint.Position; Point3D end = endPathPoint.Position; var intersectedCells = GetSegmentIntersectedCells(start, end, gridMap); intersectedCellCount += intersectedCells.Count; var blockedCells = intersectedCells .Where(cell => !cell.isPassableAtSegmentElevation) .ToList(); foreach (var blockedCell in blockedCells) { if (blockedIntersections.Count < 100) { blockedIntersections.Add(new { segmentIndex, startPointIndex = startPathPoint.Index, endPointIndex = endPathPoint.Index, cell = blockedCell }); } } segmentDiagnostics.Add(new { segmentIndex, startPointIndex = startPathPoint.Index, endPointIndex = endPathPoint.Index, startGrid = SerializeGridPosition(gridMap.WorldToGrid(start)), endGrid = SerializeGridPosition(gridMap.WorldToGrid(end)), startHorizontal = SerializeHorizontalPoint(start, gridMap), endHorizontal = SerializeHorizontalPoint(end, gridMap), intersectedCellCount = intersectedCells.Count, blockedCellCount = blockedCells.Count, intersectedCells = intersectedCells.Take(200).ToList() }); } return new { route = new { id = route.Id, name = route.Name, pathType = route.PathType.ToString(), pointCount = orderedPoints.Count, length = route.TotalLength }, grid = new { coordinateSystemType = gridMap.CoordinateSystemType.ToString(), width = gridMap.Width, height = gridMap.Height, cellSize = gridMap.CellSize, cellSizeInMeters = UnitsConverter.ConvertToMeters(gridMap.CellSize), origin = SerializePoint3D(gridMap.Origin), originHorizontal = SerializeHorizontalPoint(gridMap.Origin, gridMap) }, points = orderedPoints.Select(point => SerializePathPointWithGrid(point, gridMap)).ToList(), segmentCount = Math.Max(0, orderedPoints.Count - 1), intersectedCellCount, blockedIntersectionCount = blockedIntersections.Count, blockedIntersections, segments = segmentDiagnostics }; } private static double CalculateHostHorizontalDistance(Point3D start, Point3D end, CoordinateSystemType coordinateSystemType) { var adapter = new HostCoordinateAdapter(coordinateSystemType); var startHorizontal = HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(start), adapter); var endHorizontal = HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(end), adapter); double dh1 = endHorizontal.h1 - startHorizontal.h1; double dh2 = endHorizontal.h2 - startHorizontal.h2; return Math.Sqrt(dh1 * dh1 + dh2 * dh2); } private static Point3D InterpolateHostPoint(Point3D start, Point3D end, double t) { return new Point3D( start.X + t * (end.X - start.X), start.Y + t * (end.Y - start.Y), start.Z + t * (end.Z - start.Z)); } private static System.Numerics.Vector3 ToVector3(Point3D point) { return new System.Numerics.Vector3((float)point.X, (float)point.Y, (float)point.Z); } private static int CountBlockedCellInteriorIntersections( Point3D start, Point3D end, GridMap gridMap, int segmentIndex, List failures) { var startHorizontal = GetHostHorizontalCoords(start, gridMap); var endHorizontal = GetHostHorizontalCoords(end, gridMap); var originHorizontal = GetHostHorizontalCoords(gridMap.Origin, gridMap); int minGridX = (int)Math.Round((Math.Min(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) - 1; int maxGridX = (int)Math.Round((Math.Max(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) + 1; int minGridY = (int)Math.Round((Math.Min(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) - 1; int maxGridY = (int)Math.Round((Math.Max(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) + 1; minGridX = Math.Max(0, minGridX); minGridY = Math.Max(0, minGridY); maxGridX = Math.Min(gridMap.Width - 1, maxGridX); maxGridY = Math.Min(gridMap.Height - 1, maxGridY); int blockedIntersections = 0; for (int x = minGridX; x <= maxGridX; x++) { for (int y = minGridY; y <= maxGridY; y++) { var gridPosition = new GridPoint2D(x, y); if (!gridMap.TryGetSegmentGridCellInteriorIntersection(start, end, gridPosition, out double enterT, out double exitT)) { continue; } double sampleT = Math.Max(0.0, Math.Min(1.0, (enterT + exitT) * 0.5)); Point3D samplePoint = InterpolateHostPoint(start, end, sampleT); double sampleElevation = gridMap.GetWorldElevation(samplePoint); if (!gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1)) { blockedIntersections++; AddFailure( failures, segmentIndex, -1, samplePoint, gridPosition, $"BlockedCellInteriorIntersection:t=[{enterT:F6},{exitT:F6}]"); } } } return blockedIntersections; } private static List GetSegmentIntersectedCells(Point3D start, Point3D end, GridMap gridMap) { var startHorizontal = GetHostHorizontalCoords(start, gridMap); var endHorizontal = GetHostHorizontalCoords(end, gridMap); var originHorizontal = GetHostHorizontalCoords(gridMap.Origin, gridMap); int minGridX = (int)Math.Round((Math.Min(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) - 1; int maxGridX = (int)Math.Round((Math.Max(startHorizontal.h1, endHorizontal.h1) - originHorizontal.h1) / gridMap.CellSize) + 1; int minGridY = (int)Math.Round((Math.Min(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) - 1; int maxGridY = (int)Math.Round((Math.Max(startHorizontal.h2, endHorizontal.h2) - originHorizontal.h2) / gridMap.CellSize) + 1; minGridX = Math.Max(0, minGridX); minGridY = Math.Max(0, minGridY); maxGridX = Math.Min(gridMap.Width - 1, maxGridX); maxGridY = Math.Min(gridMap.Height - 1, maxGridY); var result = new List(); for (int x = minGridX; x <= maxGridX; x++) { for (int y = minGridY; y <= maxGridY; y++) { var gridPosition = new GridPoint2D(x, y); if (!gridMap.TryGetSegmentGridCellInteriorIntersection(start, end, gridPosition, out double enterT, out double exitT)) { continue; } double sampleT = Math.Max(0.0, Math.Min(1.0, (enterT + exitT) * 0.5)); Point3D samplePoint = InterpolateHostPoint(start, end, sampleT); double sampleElevation = gridMap.GetWorldElevation(samplePoint); GridCell cell = gridMap.Cells[x, y]; var bounds = gridMap.GetGridCellPlanarBounds(gridPosition); bool isPassableAtSegmentElevation = gridMap.IsPassableAtElevation(gridPosition, sampleElevation, 0.1); result.Add(new { grid = SerializeGridPosition(gridPosition), cellType = cell.CellType, hasAnyWalkableLayer = cell.HasAnyWalkableLayer(), isPassableAtSegmentElevation, sampleT, enterT, exitT, sampleElevation, samplePoint = SerializePoint3D(samplePoint), center = SerializePoint3D(gridMap.GridToWorld2D(gridPosition)), planarBounds = new { bounds.minH1, bounds.maxH1, bounds.minH2, bounds.maxH2 }, heightLayers = SerializeHeightLayers(cell) }); } } return result .OrderBy(cell => cell.grid.x) .ThenBy(cell => cell.grid.y) .ToList(); } private static (double h1, double h2) GetHostHorizontalCoords(Point3D point, GridMap gridMap) { var adapter = new HostCoordinateAdapter(gridMap.CoordinateSystemType); return HostPlanarGridHelper.GetHorizontalCoords3(ToVector3(point), adapter); } private static object SerializeGridPosition(GridPoint2D gridPosition) { return new { x = gridPosition.X, y = gridPosition.Y }; } private static object SerializeHorizontalPoint(Point3D point, GridMap gridMap) { var horizontal = GetHostHorizontalCoords(point, gridMap); return new { h1 = horizontal.h1, h2 = horizontal.h2, elevation = gridMap.GetWorldElevation(point) }; } private static object SerializePathPointWithGrid(PathPoint point, GridMap gridMap) { GridPoint2D gridPosition = gridMap.WorldToGrid(point.Position); return new { id = point.Id, name = point.Name, type = point.Type.ToString(), index = point.Index, position = SerializePoint3D(point.Position), horizontal = SerializeHorizontalPoint(point.Position, gridMap), grid = SerializeGridPosition(gridPosition), cell = SerializeGridCell(gridMap, gridPosition, point.Position) }; } private static object SerializeGridCell(GridMap gridMap, GridPoint2D gridPosition, Point3D referencePoint) { if (!gridMap.IsValidGridPosition(gridPosition)) { return new { isValid = false }; } GridCell cell = gridMap.Cells[gridPosition.X, gridPosition.Y]; var bounds = gridMap.GetGridCellPlanarBounds(gridPosition); double elevation = gridMap.GetWorldElevation(referencePoint); return new { isValid = true, cellType = cell.CellType, hasAnyWalkableLayer = cell.HasAnyWalkableLayer(), isPassableAtReferenceElevation = gridMap.IsPassableAtElevation(gridPosition, elevation, 0.1), planarBounds = new { bounds.minH1, bounds.maxH1, bounds.minH2, bounds.maxH2 }, heightLayers = SerializeHeightLayers(cell) }; } private static object SerializeHeightLayers(GridCell cell) { return cell.HeightLayers? .Select(layer => new { z = layer.Z, passableMin = layer.PassableHeight.MinZ, passableMax = layer.PassableHeight.MaxZ, isWalkable = layer.IsWalkable, isBoundary = layer.IsBoundary, type = layer.Type }) .ToList(); } private static void AddFailure( List failures, int segmentIndex, int sampleIndex, Point3D samplePoint, GridPoint2D gridPosition, string reason) { if (failures.Count >= 20) { return; } failures.Add(new { segmentIndex, sampleIndex, reason, point = SerializePoint3D(samplePoint), grid = new { x = gridPosition.X, y = gridPosition.Y } }); } private static PathPlanningManager RequirePathManager() { PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); if (pathManager == null) { throw new InvalidOperationException("PathPlanningManager 当前不可用"); } return pathManager; } private static string GetRequiredQueryValue(Dictionary query, string key) { string value = GetOptionalQueryValue(query, key); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException($"缺少必填参数: {key}"); } return value; } private static string GetOptionalQueryValue(Dictionary query, string key) { if (query == null || string.IsNullOrWhiteSpace(key)) { return null; } query.TryGetValue(key, out string value); return string.IsNullOrWhiteSpace(value) ? null : value; } private static double ParsePositiveDoubleQuery(Dictionary query, string key, double defaultValue) { double value = ParseDoubleQuery(query, key, defaultValue); if (value <= 0) { throw new InvalidOperationException($"参数 {key} 必须大于 0,当前值: {value}"); } return value; } private static double ParseNonNegativeDoubleQuery(Dictionary query, string key, double defaultValue) { double value = ParseDoubleQuery(query, key, defaultValue); if (value < 0) { throw new InvalidOperationException($"参数 {key} 不能小于 0,当前值: {value}"); } return value; } private static double ParseDoubleQuery(Dictionary query, string key, double defaultValue) { string raw = GetOptionalQueryValue(query, key); if (string.IsNullOrWhiteSpace(raw)) { return defaultValue; } if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) { throw new InvalidOperationException($"无法解析数值参数 {key}: {raw}"); } return value; } private static RequestTarget ParseRequestTarget(string requestTarget) { string path = requestTarget ?? "/"; var query = new Dictionary(StringComparer.OrdinalIgnoreCase); int queryStartIndex = path.IndexOf('?'); if (queryStartIndex >= 0) { string queryString = path.Substring(queryStartIndex + 1); path = path.Substring(0, queryStartIndex); foreach (string pair in queryString.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) { string[] kv = pair.Split(new[] { '=' }, 2); string key = Uri.UnescapeDataString(kv[0] ?? string.Empty); string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1] ?? string.Empty) : string.Empty; if (!string.IsNullOrWhiteSpace(key)) { query[key] = value; } } } return new RequestTarget { Path = string.IsNullOrWhiteSpace(path) ? "/" : path, Query = query }; } private sealed class RequestTarget { public string Path { get; set; } public Dictionary Query { get; set; } } private static object ResolveTrackedState(PathAnimationManager animationManager, ModelItem controlledObject) { if (animationManager == null || controlledObject == null || !animationManager.ControlsAnimatedObject(controlledObject)) { return new { isAvailable = false, reason = animationManager == null ? "PathAnimationManagerUnavailable" : "ControlledObjectUnavailable" }; } var currentState = animationManager.GetObjectCurrentPosition(controlledObject); return new { isAvailable = true, position = SerializePoint3D(currentState.Position), yawRadians = currentState.Yaw, yawDegrees = currentState.Yaw * 180.0 / Math.PI, hasTrackedRotation = animationManager.HasTrackedRotation, trackedRotation = animationManager.HasTrackedRotation ? SerializeRotation3D(animationManager.TrackedRotation) : null }; } private static object ResolveGeometryState(ModelItem controlledObject) { if (controlledObject == null) { return new { isAvailable = false, reason = "ControlledObjectUnavailable" }; } bool hasGeometryRotation = ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out Rotation3D geometryRotation); bool hasOverrideRotation = ModelItemTransformHelper.TryGetCurrentOverrideRotation(controlledObject, out Rotation3D overrideRotation); return new { isAvailable = true, geometryRotation = hasGeometryRotation ? SerializeRotation3D(geometryRotation) : null, overrideRotation = hasOverrideRotation ? SerializeRotation3D(overrideRotation) : null }; } private static object ResolveBoundingBoxState(ModelItem controlledObject) { if (controlledObject == null) { return new { isAvailable = false, reason = "ControlledObjectUnavailable" }; } BoundingBox3D boundingBox = controlledObject.BoundingBox(); return new { isAvailable = true, min = SerializePoint3D(boundingBox.Min), max = SerializePoint3D(boundingBox.Max), center = SerializePoint3D(boundingBox.Center) }; } private static object SerializePoint3D(Point3D point) { if (point == null) { return null; } return new { x = point.X, y = point.Y, z = point.Z }; } private static object SerializePathPoint(PathPoint point) { if (point == null) { return null; } return new { id = point.Id, name = point.Name, type = point.Type.ToString(), index = point.Index, position = SerializePoint3D(point.Position) }; } private static object SerializeGridStats(GridMap gridMap) { if (gridMap == null) { return null; } int walkableCellCount = 0; int nonWalkableCellCount = 0; int obstacleCellCount = 0; int holeCellCount = 0; int boundaryLayerCount = 0; for (int x = 0; x < gridMap.Width; x++) { for (int y = 0; y < gridMap.Height; y++) { GridCell cell = gridMap.Cells[x, y]; bool isWalkable = cell.HasAnyWalkableLayer(); if (isWalkable) { walkableCellCount++; } else { nonWalkableCellCount++; } if (string.Equals(cell.CellType, "障碍物", StringComparison.Ordinal)) { obstacleCellCount++; } if (string.Equals(cell.CellType, "空洞", StringComparison.Ordinal)) { holeCellCount++; } if (cell.HeightLayers != null) { boundaryLayerCount += cell.HeightLayers.Count(layer => layer.IsBoundary); } } } int totalCellCount = gridMap.Width * gridMap.Height; return new { width = gridMap.Width, height = gridMap.Height, totalCellCount, walkableCellCount, nonWalkableCellCount, obstacleCellCount, holeCellCount, boundaryLayerCount, obstacleRatio = totalCellCount == 0 ? 0.0 : (double)obstacleCellCount / totalCellCount, nonWalkableRatio = totalCellCount == 0 ? 0.0 : (double)nonWalkableCellCount / totalCellCount }; } private static object BuildObstacleDiagnostics(GridMapGenerator gridMapGenerator, GridMap gridMap, double objectHeightInMeters, double safetyMarginInMeters) { if (gridMapGenerator == null || gridMap == null) { return null; } double metersToModelUnits = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); double scanHeightInModelUnits = (objectHeightInMeters + safetyMarginInMeters) * metersToModelUnits; var traversableItems = CategoryAttributeManager.GetAllTraversableLogisticsItems().ToList(); var irrelevantItems = CategoryAttributeManager.GetLogisticsItemsByType("无关项"); var collectRelatedItemsMethod = typeof(GridMapGenerator).GetMethod( "CollectRelatedItems", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var postProcessGeometryItemsMethod = typeof(GridMapGenerator).GetMethod( "PostProcessGeometryItems", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var getObstacleElevationRangeMethod = typeof(GridMapGenerator).GetMethod( "GetObstacleElevationRange", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, new[] { typeof(Point3D), typeof(Point3D), typeof(GridMap) }, null); var calculateBoundingBoxGridCoverageMethod = typeof(GridMapGenerator).GetMethod( "CalculateBoundingBoxGridCoverage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var traversableRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { traversableItems }); var irrelevantRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { irrelevantItems }); var geometryItems = ModelItemAnalysisHelper.GetAllVisibleGeometryItems(); var itemCache = (Dictionary)postProcessGeometryItemsMethod.Invoke( gridMapGenerator, new object[] { geometryItems, traversableRelatedItems, irrelevantRelatedItems }); var validItems = itemCache .Where(kvp => kvp.Value.HasGeometry && !kvp.Value.IsContainer && !kvp.Value.IsChannelItem && !kvp.Value.IsChildOfChannel && kvp.Value.BoundingBox != null && kvp.Value.IsInScanHeightRange) .ToList(); double walkableMinElevation = double.MaxValue; double walkableMaxElevation = double.MinValue; int walkableGridCount = 0; for (int x = 0; x < gridMap.Width; x++) { for (int y = 0; y < gridMap.Height; y++) { GridCell cell = gridMap.Cells[x, y]; if (!cell.HasAnyWalkableLayer() || cell.HeightLayers == null || cell.HeightLayers.Count == 0) { continue; } walkableGridCount++; double cellElevation = cell.HeightLayers[0].Z; walkableMinElevation = Math.Min(walkableMinElevation, cellElevation); walkableMaxElevation = Math.Max(walkableMaxElevation, cellElevation); } } if (walkableGridCount == 0) { walkableMinElevation = 0.0; walkableMaxElevation = 0.0; } double scanMin = walkableMinElevation; double scanMax = walkableMaxElevation + scanHeightInModelUnits; var keptItems = new List(); var filteredSampleItems = new List(); int filteredByHeightCount = 0; foreach (var kvp in validItems) { BoundingBox3D bbox = kvp.Value.BoundingBox; var elevationRange = ((double min, double max))getObstacleElevationRangeMethod.Invoke( gridMapGenerator, new object[] { bbox.Min, bbox.Max, gridMap }); bool isInRange = !(elevationRange.max <= scanMin || elevationRange.min > scanMax); if (!isInRange) { filteredByHeightCount++; if (filteredSampleItems.Count < 10) { filteredSampleItems.Add(new { displayName = kvp.Key.DisplayName, bboxMin = SerializePoint3D(bbox.Min), bboxMax = SerializePoint3D(bbox.Max), minElevation = elevationRange.min, maxElevation = elevationRange.max }); } continue; } if (keptItems.Count < 30) { var coveredCells = (List<(int x, int y)>)calculateBoundingBoxGridCoverageMethod.Invoke( gridMapGenerator, new object[] { bbox, gridMap }); object triangleBounds = keptItems.Count < 8 ? SerializeTriangleBounds(kvp.Key) : null; keptItems.Add(new { displayName = kvp.Key.DisplayName, bboxMin = SerializePoint3D(bbox.Min), bboxMax = SerializePoint3D(bbox.Max), triangleBounds, minElevation = elevationRange.min, maxElevation = elevationRange.max, coveredCellCount = coveredCells.Count }); } } return new { geometryItemCount = geometryItems.Count, traversableItemCount = traversableItems.Count, traversableRelatedItemCount = traversableRelatedItems.Count, irrelevantItemCount = irrelevantItems.Count, irrelevantRelatedItemCount = irrelevantRelatedItems.Count, postProcessedItemCount = itemCache.Count, validItemCount = validItems.Count, walkableGridCount, walkableMinElevation, walkableMaxElevation, scanHeightInModelUnits, scanMin, scanMax, filteredByHeightCount, keptItemSample = keptItems, filteredByHeightSample = filteredSampleItems }; } private static object SerializeTriangleBounds(ModelItem item) { if (item == null) { return null; } List triangles = GeometryHelper.ExtractTriangles(new[] { item }); if (triangles == null || triangles.Count == 0) { return null; } double minX = triangles.Min(t => Math.Min(t.Point1.X, Math.Min(t.Point2.X, t.Point3.X))); double minY = triangles.Min(t => Math.Min(t.Point1.Y, Math.Min(t.Point2.Y, t.Point3.Y))); double minZ = triangles.Min(t => Math.Min(t.Point1.Z, Math.Min(t.Point2.Z, t.Point3.Z))); double maxX = triangles.Max(t => Math.Max(t.Point1.X, Math.Max(t.Point2.X, t.Point3.X))); double maxY = triangles.Max(t => Math.Max(t.Point1.Y, Math.Max(t.Point2.Y, t.Point3.Y))); double maxZ = triangles.Max(t => Math.Max(t.Point1.Z, Math.Max(t.Point2.Z, t.Point3.Z))); return new { min = SerializePoint3D(new Point3D(minX, minY, minZ)), max = SerializePoint3D(new Point3D(maxX, maxY, maxZ)) }; } private static T InvokePathManagerPrivate(PathPlanningManager pathManager, string methodName, params object[] args) { var method = typeof(PathPlanningManager).GetMethod( methodName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (method == null) { throw new MissingMethodException(typeof(PathPlanningManager).FullName, methodName); } object result = method.Invoke(pathManager, args); if (result == null) { return default(T); } return (T)result; } private static object SerializeRotation3D(Rotation3D rotation) { var linear = new Transform3D(rotation).Linear; return new { quaternion = new { x = rotation.A, y = rotation.B, z = rotation.C, w = rotation.D }, axes = new { hostX = new { x = linear.Get(0, 0), y = linear.Get(1, 0), z = linear.Get(2, 0) }, hostY = new { x = linear.Get(0, 1), y = linear.Get(1, 1), z = linear.Get(2, 1) }, hostZ = new { x = linear.Get(0, 2), y = linear.Get(1, 2), z = linear.Get(2, 2) } } }; } private static string WriteSnapshotToDisk(object snapshotPayload) { string logDirectory = Path.GetDirectoryName(LogManager.LogFilePath); string snapshotDirectory = Path.Combine(logDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "test-automation"); Directory.CreateDirectory(snapshotDirectory); string fileName = string.Format( CultureInfo.InvariantCulture, "debug-snapshot-{0:yyyyMMdd-HHmmss-fff}.json", DateTime.Now); string fullPath = Path.Combine(snapshotDirectory, fileName); string json = JsonConvert.SerializeObject(snapshotPayload, Formatting.Indented); File.WriteAllText(fullPath, json, new UTF8Encoding(false)); LogManager.Info($"[测试HTTP] 已导出调试快照: {fullPath}"); return fullPath; } private static AnimationControlViewModel RequireAnimationControlViewModel() { var animationViewModel = AnimationControlViewModel.Instance; if (animationViewModel == null) { throw new InvalidOperationException("AnimationControlViewModel 当前不可用,请先打开插件动画页签"); } return animationViewModel; } private static PreparedVirtualCollisionTest PrepareVirtualCollisionTest(Dictionary query, PathType? fixedPathType) { PathPlanningManager pathManager = RequirePathManager(); AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); animationViewModel.SetPathPlanningManager(pathManager); ApplyTestAnimationParameters(animationViewModel, query); PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, fixedPathType); pathManager.SetCurrentRoute(route); animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); // 清除上一次测试残留的碰撞报告,避免 HasGeneratedCollisionReport/LastGeneratedReport 跨测试串扰 animationViewModel.InvalidateLastGeneratedReport(); animationViewModel.IsManualCollisionTargetEnabled = false; bool useVirtualObject = ParseBooleanQuery(query, "useVirtualObject", true); object animatedObjectPayload; if (useVirtualObject) { 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)) { string detail = animationViewModel.SelectedAnimatedObject == null ? "选择真实物体失败:物体在当前文档不可用(导出局部模型中未解析到该物体,常见于物体不在路径剖面盒内或层级链不匹配;请改用虚拟物体或确认物体在盒内)" : "选择真实物体失败:动画视图模型未保留选中的对象"; throw new InvalidOperationException(detail); } animatedObjectPayload = new { mode = "RealObject", displayName = realObject.DisplayName }; } if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) { throw new InvalidOperationException($"{route.PathType} 碰撞测试准备失败:当前条件下不能生成动画"); } animationViewModel.GenerateAnimationCommand.Execute(null); LogManager.Info( $"[测试HTTP] 已开始准备{(useVirtualObject ? "虚拟物体" : "真实物体")}碰撞测试: 路径={route.Name}, 类型={route.PathType}"); return new PreparedVirtualCollisionTest { pathType = route.PathType, route = SerializeRoute(route, route), animatedObject = animatedObjectPayload }; } /// /// 动画逐帧状态探针:生成动画后 seek 到指定进度点,返回各点物体跟踪位置/yaw。 /// 供集成测试验证物体沿路径移动(起点→中间→终点)与平面姿态链。 /// /// /// 动画播放控制集成测试序列:prepare → start → pause(验证帧停止)→ resume(验证帧前进)→ finished。 /// 验证 PathAnimationManager 的暂停/恢复/播放完整链路。 /// private static async Task AnimationPlaybackControlAsync(Dictionary query) { await _testExecutionLock.WaitAsync().ConfigureAwait(false); try { return await AnimationPlaybackControlCoreAsync(query).ConfigureAwait(false); } finally { _testExecutionLock.Release(); } } private static async Task AnimationPlaybackControlCoreAsync(Dictionary query) { int timeoutSeconds = ParseTimeoutSeconds(query, 180); DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); PathRoute route = InvokeOnUiThread(() => PrepareProbeAnimation(query)); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationManager = PathAnimationManager.GetInstance(); return animationManager != null && animationManager.TotalFrames > 0 && (animationManager.CurrentState == AnimationState.Ready || animationManager.CurrentState == AnimationState.Finished); }), "等待动画生成完成超时").ConfigureAwait(false); var stages = new List(); int totalFrames = 0; // 1. 开始播放 InvokeOnUiThread(() => { StartPreparedAnimation(); return true; }); // 2. 等待播放开始(帧前进) await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentFrame > 0), "等待动画开始播放超时").ConfigureAwait(false); totalFrames = InvokeOnUiThread(() => PathAnimationManager.GetInstance().TotalFrames); int playingFrame = InvokeOnUiThread(() => PathAnimationManager.GetInstance().CurrentFrame); stages.Add(RecordStage("playing", playingFrame)); // 等待至少前进一帧,确保暂停观测窗口有效(帧间隔 ~66ms @15fps) await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentFrame > playingFrame), "等待播放前进超时").ConfigureAwait(false); // 3. 暂停并验证帧停止 InvokeOnUiThread(() => { PathAnimationManager.GetInstance().PauseAnimation(); return true; }); int pausedFrame = InvokeOnUiThread(() => PathAnimationManager.GetInstance().CurrentFrame); stages.Add(RecordStage("paused", pausedFrame)); // 暂停期间等待 1.2 秒,帧不应前进 await Task.Delay(1200).ConfigureAwait(false); int frameAfterPause = InvokeOnUiThread(() => PathAnimationManager.GetInstance().CurrentFrame); bool pauseVerified = frameAfterPause == pausedFrame; // 4. 恢复播放,帧继续前进 InvokeOnUiThread(() => { PathAnimationManager.GetInstance().ResumeAnimation(); return true; }); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentFrame > pausedFrame), "等待动画恢复播放超时").ConfigureAwait(false); stages.Add(RecordStage("resumed", InvokeOnUiThread(() => PathAnimationManager.GetInstance().CurrentFrame))); // 5. 等待播放完成 await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentState == AnimationState.Finished), "等待动画播放完成超时").ConfigureAwait(false); stages.Add(RecordStage("finished", InvokeOnUiThread(() => PathAnimationManager.GetInstance().CurrentFrame))); return new { route = SerializeRoute(route, route), totalFrames, pauseVerified, pausedFrame, frameAfterPause, stages }; } private static object RecordStage(string stage, int frame) { return new { stage, frame, state = PathAnimationManager.GetInstance()?.CurrentState.ToString() }; } private static async Task ProbeAnimationFramesAsync(Dictionary query) { await _testExecutionLock.WaitAsync().ConfigureAwait(false); try { return await ProbeAnimationFramesCoreAsync(query).ConfigureAwait(false); } finally { _testExecutionLock.Release(); } } private static async Task ProbeAnimationFramesCoreAsync(Dictionary query) { int timeoutSeconds = ParseTimeoutSeconds(query, 180); DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); PathRoute route = InvokeOnUiThread(() => PrepareProbeAnimation(query)); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationManager = PathAnimationManager.GetInstance(); return animationManager != null && animationManager.TotalFrames > 0 && (animationManager.CurrentState == AnimationState.Ready || animationManager.CurrentState == AnimationState.Finished); }), "等待动画生成完成超时").ConfigureAwait(false); return InvokeOnUiThread(() => { var animationManager = PathAnimationManager.GetInstance(); var controlledObject = VirtualObjectManager.Instance.CurrentVirtualObject; if (animationManager == null || controlledObject == null) { throw new InvalidOperationException("虚拟物体或动画管理器当前不可用,无法执行帧探针"); } int totalFrames = animationManager.TotalFrames; string probeText = GetOptionalQueryValue(query, "probeProgress") ?? "0,0.5,1.0"; var probes = new List(); foreach (string part in probeText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { if (!double.TryParse(part, NumberStyles.Float, CultureInfo.InvariantCulture, out double progress)) { throw new InvalidOperationException($"无法解析探针进度: {part}"); } progress = Math.Max(0.0, Math.Min(1.0, progress)); animationManager.SeekToProgress(progress); var (position, yaw) = animationManager.GetObjectCurrentPosition(controlledObject); probes.Add(new { progress, frameIndex = animationManager.CurrentFrame, position = SerializePoint3D(position), yawRadians = yaw, yawDegrees = yaw * 180.0 / Math.PI }); } List orderedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List(); return new { route = SerializeRoute(route, route), totalFrames, pathStart = SerializePoint3D(orderedPoints.FirstOrDefault()?.Position), pathEnd = SerializePoint3D(orderedPoints.LastOrDefault()?.Position), probes }; }); } /// /// 生成虚拟物体动画(帧探针准备阶段),返回当前路径。 /// private static PathRoute PrepareProbeAnimation(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); animationViewModel.SetPathPlanningManager(pathManager); ApplyTestAnimationParameters(animationViewModel, query); PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null); pathManager.SetCurrentRoute(route); animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); // 清除上一次测试残留的碰撞报告(帧探针场景同样避免跨测试串扰) animationViewModel.InvalidateLastGeneratedReport(); animationViewModel.IsManualCollisionTargetEnabled = false; animationViewModel.UseVirtualObject = true; if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) { throw new InvalidOperationException($"{route.PathType} 帧探针测试准备失败:当前条件下不能生成动画"); } animationViewModel.GenerateAnimationCommand.Execute(null); LogManager.Info($"[测试HTTP] 帧探针测试动画已开始生成: 路径={route.Name}, 类型={route.PathType}"); return route; } /// /// 应用测试动画参数(帧率/时长),加速集成测试: /// query 支持 frameRate(FPS)与 durationSeconds,缺省保持配置默认值。 /// private static void ApplyTestAnimationParameters(AnimationControlViewModel animationViewModel, Dictionary query) { if (int.TryParse(GetOptionalQueryValue(query, "frameRate"), out int frameRate) && frameRate > 0) { animationViewModel.SelectedFrameRate = frameRate; } if (double.TryParse(GetOptionalQueryValue(query, "durationSeconds"), NumberStyles.Float, CultureInfo.InvariantCulture, out double durationSeconds) && durationSeconds > 0) { animationViewModel.AnimationDuration = durationSeconds; } } private static void StartPreparedAnimation() { AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); if (animationManager == null) { throw new InvalidOperationException("PathAnimationManager 当前不可用,无法开始播放"); } if (animationManager.CurrentState == AnimationState.Paused) { animationManager.ResumeAnimation(); LogManager.Info("[测试HTTP] 已从暂停状态恢复自动测试动画播放"); return; } if (animationManager.IsAnimating) { LogManager.Info("[测试HTTP] 动画已经在播放中,跳过重复开始"); return; } if (animationManager.CurrentState != AnimationState.Ready && animationManager.CurrentState != AnimationState.Finished) { throw new InvalidOperationException($"动画尚未就绪,当前状态={animationManager.CurrentState}"); } animationManager.ClearExclusionCache(); ModelHighlightHelper.ClearCollisionHighlights(); animationManager.StartAnimation(); LogManager.Info("[测试HTTP] 已启动自动测试动画播放"); } private static PathRoute ResolveVirtualCollisionRoute(PathPlanningManager pathManager, Dictionary query, PathType? fixedPathType) { string routeName = GetOptionalQueryValue(query, "routeName"); string pathTypeText = fixedPathType.HasValue ? fixedPathType.Value.ToString() : GetOptionalQueryValue(query, "pathType"); List routes = pathManager.GetAllRoutes() ?? new List(); PathType pathType = fixedPathType ?? ParsePathTypeOrThrow(pathTypeText, "pathType"); string routePrefix = GetOptionalQueryValue(query, "routePrefix") ?? GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix; PathRoute route = string.IsNullOrWhiteSpace(routeName) ? TryFindAutoTestRoute(routes, pathType, routePrefix) : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { // 集成测试依赖的标准路径缺失时,自动从插件资源文件导入后重试。 // 这是唯一允许的自动导入路径(仅限标准测试路径,不影响用户数据)。 EnsureAutoTestRoutesImported(pathManager); routes = pathManager.GetAllRoutes() ?? new List(); route = string.IsNullOrWhiteSpace(routeName) ? TryFindAutoTestRoute(routes, pathType, routePrefix) : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); } if (route == null) { throw new InvalidOperationException( string.IsNullOrWhiteSpace(routeName) ? $"找不到默认自动测试路径: prefix={routePrefix}, pathType={pathType}" : $"找不到指定路径: {routeName}"); } if (route.PathType != pathType) { throw new InvalidOperationException($"指定路径类型不匹配: 期望={pathType}, 实际={route.PathType}, 路径={route.Name}"); } return route; } /// /// 判断两个 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)) { throw new InvalidOperationException($"缺少必填参数: {parameterName}"); } if (!Enum.TryParse(value, true, out PathType pathType)) { throw new InvalidOperationException($"无法解析路径类型: {value}"); } return pathType; } /// /// 检查并导入集成测试标准路径(自动测试_ 前缀 + 测试基准)。 /// 仅当对应名称的路径缺失时,从插件部署目录 resources\auto-test-routes.xml 导入。 /// 已存在或重名的路径不会被覆盖/重命名,确保不破坏用户数据。 /// private static void EnsureAutoTestRoutesImported(PathPlanningManager pathManager) { string[] standardRouteNames = new[] { "自动测试_Ground", "自动测试_Hoisting", "自动测试_Rail", "自动测试_Free", "测试基准" }; List existingRoutes = pathManager.GetAllRoutes() ?? new List(); List missingNames = standardRouteNames .Where(name => !existingRoutes.Any(route => string.Equals(route.Name, name, StringComparison.Ordinal))) .ToList(); if (missingNames.Count == 0) { return; } string pluginDirectory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); string resourcePath = Path.Combine(pluginDirectory ?? string.Empty, "resources", AutoTestRoutesResourceFileName); if (!File.Exists(resourcePath)) { throw new InvalidOperationException( $"缺少标准测试路径资源文件: {resourcePath}。请确认插件已完整部署(resources\\{AutoTestRoutesResourceFileName})。缺失路径: {string.Join(", ", missingNames)}"); } List importedRoutes = new PathDataManager().ImportFromXml(resourcePath); if (importedRoutes == null || importedRoutes.Count == 0) { throw new InvalidOperationException($"标准测试路径资源文件为空: {resourcePath}"); } int importedCount = 0; foreach (PathRoute importedRoute in importedRoutes) { bool exists = existingRoutes.Any(route => string.Equals(route.Name, importedRoute.Name, StringComparison.Ordinal)); if (exists) { continue; } if (importedRoute.PathType != PathType.Ground && importedRoute.PathType != PathType.Hoisting && importedRoute.PathType != PathType.Rail && importedRoute.PathType != PathType.Free) { continue; } if (pathManager.AddRoute(importedRoute)) { importedCount++; LogManager.Info($"[测试HTTP] 已导入标准测试路径: {importedRoute.Name} ({importedRoute.PathType})"); } } if (importedCount == 0) { throw new InvalidOperationException( $"标准测试路径导入失败(0 条成功)。缺失路径: {string.Join(", ", missingNames)}"); } LogManager.Info($"[测试HTTP] 标准测试路径检查完成: 缺失 {missingNames.Count} 条,已导入 {importedCount} 条"); } private static ModelItem ResolveAnimatedObjectFromQuery(Dictionary query) { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; if (activeDocument == null) { throw new InvalidOperationException("当前没有活动文档,无法选择真实物体"); } string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName"); string animatedObjectPath = GetOptionalQueryValue(query, "animatedObjectPath"); if (string.IsNullOrWhiteSpace(animatedObjectName) && string.IsNullOrWhiteSpace(animatedObjectPath)) { return ResolveSingleSelectedItem(activeDocument); } var matches = new List(); foreach (Model model in activeDocument.Models) { if (model?.RootItem == null) { continue; } foreach (ModelItem item in model.RootItem.DescendantsAndSelf) { if (item == null) { 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); } } } if (matches.Count == 0) { string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath; throw new InvalidOperationException($"找不到指定真实物体: {target}"); } if (matches.Count > 1) { string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath; throw new InvalidOperationException($"找到多个匹配真实物体,请改用更唯一的名字或 PathId: {target}"); } return matches[0]; } private static ModelItem ResolveSingleSelectedItem(Document activeDocument) { var selectedItems = activeDocument.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() ?? new List(); if (selectedItems.Count != 1) { throw new InvalidOperationException($"当前选择集必须且只能包含 1 个真实物体,当前数量: {selectedItems.Count}"); } return selectedItems[0]; } private static void SelectDocumentItem(ModelItem item) { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; if (activeDocument?.CurrentSelection == null) { throw new InvalidOperationException("当前文档选择集不可用"); } activeDocument.CurrentSelection.Clear(); activeDocument.CurrentSelection.Add(item); } private static PathRouteViewModel CreatePathRouteViewModel(PathRoute route) { var routeViewModel = new PathRouteViewModel(isFromDatabase: true) { Route = route, IsActive = true }; foreach (var point in route.Points.OrderBy(p => p.Index)) { routeViewModel.Points.Add(new PathPointViewModel { Id = point.Id, Name = point.Name, Type = point.Type, Index = point.Index, X = point.X, Y = point.Y, Z = point.Z }); } routeViewModel.SetTimeInfo(route.CreatedTime, route.LastModified); return routeViewModel; } private static int ParseTimeoutSeconds(Dictionary query, int defaultSeconds) { string raw = GetOptionalQueryValue(query, "timeoutSeconds"); if (string.IsNullOrWhiteSpace(raw)) { return defaultSeconds; } if (!int.TryParse(raw, out int parsedSeconds) || parsedSeconds <= 0) { throw new InvalidOperationException($"无法解析 timeoutSeconds: {raw}"); } return parsedSeconds; } private static bool ParseBooleanQuery(Dictionary query, string key, bool defaultValue) { string raw = GetOptionalQueryValue(query, key); if (string.IsNullOrWhiteSpace(raw)) { return defaultValue; } if (bool.TryParse(raw, out bool parsed)) { return parsed; } if (string.Equals(raw, "1", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "yes", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "y", StringComparison.OrdinalIgnoreCase)) { return true; } if (string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "no", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "n", StringComparison.OrdinalIgnoreCase)) { return false; } throw new InvalidOperationException($"无法解析布尔参数 {key}: {raw}"); } private static IDisposable EnableAutoConfirmCollisionAnalysisDialogs() { Interlocked.Increment(ref _autoConfirmCollisionAnalysisDialogRequestCount); return new ActionOnDispose(() => { if (Interlocked.Decrement(ref _autoConfirmCollisionAnalysisDialogRequestCount) < 0) { Interlocked.Exchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0); } }); } private static IDisposable EnableAutoChooseCreateNewDetectionRecord() { Interlocked.Increment(ref _autoChooseCreateNewDetectionRecordRequestCount); return new ActionOnDispose(() => { if (Interlocked.Decrement(ref _autoChooseCreateNewDetectionRecordRequestCount) < 0) { Interlocked.Exchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0); } }); } private static async Task WaitForConditionAsync(DateTime deadlineUtc, Func condition, string timeoutMessage) { while (DateTime.UtcNow < deadlineUtc) { if (condition()) { return; } await Task.Delay(300).ConfigureAwait(false); } throw new TimeoutException(timeoutMessage); } private sealed class PreparedVirtualCollisionTest { public PathType pathType { get; set; } public object route { get; set; } public object animatedObject { get; set; } } private sealed class ActionOnDispose : IDisposable { private Action _disposeAction; public ActionOnDispose(Action disposeAction) { _disposeAction = disposeAction; } public void Dispose() { Action disposeAction = Interlocked.Exchange(ref _disposeAction, null); disposeAction?.Invoke(); } } private static async Task WriteJsonResponseAsync(StreamWriter writer, int statusCode, object payload) { string statusText = ResolveStatusText(statusCode); string json = JsonConvert.SerializeObject(payload, Formatting.Indented); byte[] bodyBytes = Encoding.UTF8.GetBytes(json); await writer.WriteLineAsync($"HTTP/1.1 {statusCode} {statusText}").ConfigureAwait(false); await writer.WriteLineAsync("Content-Type: application/json; charset=utf-8").ConfigureAwait(false); await writer.WriteLineAsync($"Content-Length: {bodyBytes.Length}").ConfigureAwait(false); await writer.WriteLineAsync("Connection: close").ConfigureAwait(false); await writer.WriteLineAsync().ConfigureAwait(false); await writer.FlushAsync().ConfigureAwait(false); Stream baseStream = writer.BaseStream; await baseStream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false); await baseStream.FlushAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); } private static string ResolveStatusText(int statusCode) { switch (statusCode) { case 200: return "OK"; case 400: return "Bad Request"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; case 500: return "Internal Server Error"; default: return "OK"; } } public void Dispose() { Stop(); } } }