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 Newtonsoft.Json; namespace NavisworksTransport.Core.Services { /// /// 本地测试自动化 HTTP 控制面。 /// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。 /// public sealed class TestAutomationHttpService : IDisposable { private const int DefaultPort = 18777; private const string DefaultHost = "127.0.0.1"; private const string DefaultAutoTestRoutePrefix = "自动测试_"; private static readonly Lazy _instance = new Lazy(() => new TestAutomationHttpService()); private readonly object _syncRoot = new object(); private static int _autoConfirmCollisionAnalysisDialogRequestCount; private static int _autoChooseCreateNewDetectionRecordRequestCount; private TcpListener _listener; private CancellationTokenSource _cts; private Task _acceptLoopTask; private DateTime _startedAtUtc; private string _lastStartError; private TestAutomationHttpService() { } public static TestAutomationHttpService Instance => _instance.Value; public bool IsRunning { get { lock (_syncRoot) { return _listener != null; } } } public int Port => DefaultPort; public string BaseUrl => $"http://{DefaultHost}:{DefaultPort}"; public static bool ShouldAutoConfirmCollisionAnalysisDialogs => Interlocked.CompareExchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0, 0) > 0; public static bool ShouldAutoChooseCreateNewDetectionRecord => Interlocked.CompareExchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0, 0) > 0; public void Start() { lock (_syncRoot) { if (_listener != null) { return; } try { _cts = new CancellationTokenSource(); _listener = new TcpListener(IPAddress.Loopback, DefaultPort); _listener.Start(); _startedAtUtc = DateTime.UtcNow; _lastStartError = null; _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); LogManager.Info($"[测试HTTP] 服务已启动: {BaseUrl}"); } catch (Exception ex) { _lastStartError = ex.Message; _listener = null; _cts?.Dispose(); _cts = null; _acceptLoopTask = null; LogManager.Error($"[测试HTTP] 服务启动失败: {ex.Message}", ex); } } } public void Stop() { Task acceptLoopTask = null; lock (_syncRoot) { if (_listener == null) { return; } try { _cts?.Cancel(); _listener.Stop(); acceptLoopTask = _acceptLoopTask; } catch (Exception ex) { LogManager.Warning($"[测试HTTP] 服务停止时出现警告: {ex.Message}"); } finally { _listener = null; _acceptLoopTask = null; _cts?.Dispose(); _cts = null; } } try { acceptLoopTask?.Wait(1000); } catch { // 忽略退出等待异常 } LogManager.Info("[测试HTTP] 服务已停止"); } private async Task AcceptLoopAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { TcpClient client = null; try { client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); } catch (ObjectDisposedException) { break; } catch (SocketException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception ex) { LogManager.Error($"[测试HTTP] 接收请求失败: {ex.Message}", ex); client?.Dispose(); } } } private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) { using (client) using (var stream = client.GetStream()) using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true)) using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, true) { NewLine = "\r\n", AutoFlush = true }) { try { string requestLine = await reader.ReadLineAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(requestLine)) { return; } string[] requestLineParts = requestLine.Split(' '); if (requestLineParts.Length < 2) { await WriteJsonResponseAsync(writer, 400, BuildEnvelope(false, error: "Invalid request line")).ConfigureAwait(false); return; } string method = requestLineParts[0].Trim().ToUpperInvariant(); string requestTarget = requestLineParts[1].Trim(); var request = ParseRequestTarget(requestTarget); string headerLine; while (!string.IsNullOrEmpty(headerLine = await reader.ReadLineAsync().ConfigureAwait(false))) { if (cancellationToken.IsCancellationRequested) { return; } } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/ping", StringComparison.OrdinalIgnoreCase)) { var payload = new { ok = true, service = "NavisworksTransport.TestAutomation", protocol = "http", version = 1, baseUrl = BaseUrl, serverTimeUtc = DateTime.UtcNow.ToString("o") }; await WriteJsonResponseAsync(writer, 200, payload).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildStatusPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/routes", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildRoutesPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/selection", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(BuildSelectionPayload); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/export-debug-snapshot", StringComparison.OrdinalIgnoreCase)) { object snapshotPayload = InvokeOnUiThread(BuildDebugSnapshotPayload); string snapshotFilePath = WriteSnapshotToDisk(snapshotPayload); var payload = new { snapshotFilePath, snapshot = snapshotPayload }; await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-route", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectRoutePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-default-route", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectDefaultRoutePayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/select-animated-object", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => SelectAnimatedObjectPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-ground-collision-test", StringComparison.OrdinalIgnoreCase)) { object payload = await RunVirtualCollisionTestAsync(request.Query, PathType.Ground).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-virtual-collision-test", StringComparison.OrdinalIgnoreCase)) { object payload = await RunVirtualCollisionTestAsync(request.Query, null).ConfigureAwait(false); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/analyze-auto-path-grid", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => AnalyzeAutoPathGridPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/run-auto-path", StringComparison.OrdinalIgnoreCase)) { object payload = InvokeOnUiThread(() => RunAutoPathPayload(request.Query)); await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); return; } if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase)) { await WriteJsonResponseAsync(writer, 405, BuildEnvelope(false, error: "Only GET and POST are supported in the current test API")).ConfigureAwait(false); return; } await WriteJsonResponseAsync(writer, 404, BuildEnvelope(false, error: $"Unknown endpoint: {request.Path}")).ConfigureAwait(false); } catch (Exception ex) { LogManager.Error($"[测试HTTP] 处理请求失败: {ex.Message}", ex); await WriteJsonResponseAsync(writer, 500, BuildEnvelope(false, error: ex.Message)).ConfigureAwait(false); } } } private object BuildStatusPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); PathRoute currentRoute = pathManager?.CurrentRoute; List allRoutes = pathManager?.GetAllRoutes(); return new { service = new { name = "NavisworksTransport.TestAutomation", protocol = "http", version = 1, isRunning = IsRunning, baseUrl = BaseUrl, port = Port, startedAtUtc = _startedAtUtc == default(DateTime) ? null : _startedAtUtc.ToString("o"), lastStartError = _lastStartError }, environment = new { processId = System.Diagnostics.Process.GetCurrentProcess().Id, machineName = Environment.MachineName, logFilePath = LogManager.LogFilePath }, document = new { hasActiveDocument = activeDocument != null, fileName = activeDocument?.FileName, title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), modelCount = activeDocument?.Models?.Count ?? 0 }, pathManager = new { isAvailable = pathManager != null, routeCount = allRoutes?.Count ?? 0, currentRouteId = currentRoute?.Id, currentRouteName = currentRoute?.Name, currentRoutePathType = currentRoute?.PathType.ToString(), currentRoutePointCount = currentRoute?.Points?.Count ?? 0 }, animation = new { isAvailable = animationManager != null, currentState = animationManager?.CurrentState.ToString(), isAnimating = animationManager?.IsAnimating ?? false, currentFrame = animationManager?.CurrentFrame ?? 0, totalFrames = animationManager?.TotalFrames ?? 0, hasTrackedRotation = animationManager?.HasTrackedRotation ?? false, currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI } }; } private object BuildDebugSnapshotPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); PathRoute currentRoute = pathManager?.CurrentRoute; ModelItem controlledObject = ResolveControlledObject(animationManager); var trackedState = ResolveTrackedState(animationManager, controlledObject); var geometryState = ResolveGeometryState(controlledObject); var boundingBoxState = ResolveBoundingBoxState(controlledObject); return new { snapshotVersion = 1, capturedAtUtc = DateTime.UtcNow.ToString("o"), document = new { hasActiveDocument = activeDocument != null, fileName = activeDocument?.FileName, title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), modelCount = activeDocument?.Models?.Count ?? 0 }, route = new { isAvailable = currentRoute != null, id = currentRoute?.Id, name = currentRoute?.Name, pathType = currentRoute?.PathType.ToString(), pointCount = currentRoute?.Points?.Count ?? 0 }, animation = new { isAvailable = animationManager != null, currentState = animationManager?.CurrentState.ToString(), isAnimating = animationManager?.IsAnimating ?? false, currentFrame = animationManager?.CurrentFrame ?? 0, totalFrames = animationManager?.TotalFrames ?? 0, currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI, hasTrackedRotation = animationManager?.HasTrackedRotation ?? false }, controlledObject = new { exists = controlledObject != null, displayName = controlledObject?.DisplayName, instanceGuid = controlledObject == null ? null : controlledObject.InstanceGuid.ToString(), isVirtualObject = controlledObject != null && VirtualObjectManager.Instance.IsVirtualObjectActive && ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, controlledObject), trackedState, geometryState, boundingBox = boundingBoxState }, environment = new { processId = System.Diagnostics.Process.GetCurrentProcess().Id, machineName = Environment.MachineName, logFilePath = LogManager.LogFilePath } }; } private object BuildRoutesPayload() { PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); List routes = pathManager?.GetAllRoutes() ?? new List(); PathRoute currentRoute = pathManager?.CurrentRoute; return new { isAvailable = pathManager != null, currentRouteId = currentRoute?.Id, currentRouteName = currentRoute?.Name, currentRoutePathType = currentRoute?.PathType.ToString(), totalRouteCount = routes.Count, routes = routes.Select(route => SerializeRoute(route, currentRoute)).ToList(), suggestedAutoTestRoutes = new { ground = TryFindAutoTestRoute(routes, PathType.Ground)?.Name, hoisting = TryFindAutoTestRoute(routes, PathType.Hoisting)?.Name, rail = TryFindAutoTestRoute(routes, PathType.Rail)?.Name } }; } private object BuildSelectionPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; var selectedItems = activeDocument?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() ?? new List(); return new { hasActiveDocument = activeDocument != null, selectionCount = selectedItems.Count, selectedItems = selectedItems.Select(SerializeModelItem).ToList() }; } private object SelectRoutePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string routeName = GetRequiredQueryValue(query, "name"); List routes = pathManager.GetAllRoutes() ?? new List(); PathRoute selectedRoute = routes.FirstOrDefault(route => string.Equals(route.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (selectedRoute == null) { throw new InvalidOperationException($"找不到指定路径: {routeName}"); } pathManager.SetCurrentRoute(selectedRoute); LogManager.Info($"[测试HTTP] 已切换当前路径: {selectedRoute.Name} ({selectedRoute.PathType})"); return new { selected = SerializeRoute(selectedRoute, selectedRoute), totalRouteCount = routes.Count }; } private object SelectDefaultRoutePayload(Dictionary query) { PathPlanningManager pathManager = RequirePathManager(); string pathTypeValue = GetRequiredQueryValue(query, "pathType"); string prefix = GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix; if (!Enum.TryParse(pathTypeValue, true, out PathType pathType)) { throw new InvalidOperationException($"无法解析路径类型: {pathTypeValue}"); } List routes = pathManager.GetAllRoutes() ?? new List(); PathRoute selectedRoute = routes .FirstOrDefault(route => route.PathType == pathType && !string.IsNullOrWhiteSpace(route.Name) && route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); if (selectedRoute == null) { throw new InvalidOperationException( $"找不到默认测试路径: prefix={prefix}, pathType={pathType}"); } pathManager.SetCurrentRoute(selectedRoute); LogManager.Info($"[测试HTTP] 已切换默认测试路径: {selectedRoute.Name} ({selectedRoute.PathType})"); return new { selectionRule = new { prefix, pathType = pathType.ToString() }, selected = SerializeRoute(selectedRoute, selectedRoute) }; } private object SelectAnimatedObjectPayload(Dictionary query) { AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); PathPlanningManager pathManager = RequirePathManager(); animationViewModel.SetPathPlanningManager(pathManager); ModelItem animatedObject = ResolveAnimatedObjectFromQuery(query); SelectDocumentItem(animatedObject); if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null)) { throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令"); } animationViewModel.UseVirtualObject = false; animationViewModel.SelectAnimatedObjectCommand.Execute(null); if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject)) { throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); } return new { selectedAnimatedObject = SerializeModelItem(animatedObject), canGenerateAnimation = animationViewModel.CanGenerateAnimation, isManualCollisionTargetEnabled = animationViewModel.IsManualCollisionTargetEnabled }; } private async Task RunVirtualCollisionTestAsync(Dictionary query, PathType? fixedPathType) { using (EnableAutoConfirmCollisionAnalysisDialogs()) using (EnableAutoChooseCreateNewDetectionRecord()) { int timeoutSeconds = ParseTimeoutSeconds(query, 180); DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); var setupResult = InvokeOnUiThread(() => PrepareVirtualCollisionTest(query, fixedPathType)); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationManager = PathAnimationManager.GetInstance(); return animationManager != null && animationManager.TotalFrames > 0 && (animationManager.CurrentState == AnimationState.Ready || animationManager.CurrentState == AnimationState.Finished); }), "等待动画生成完成超时").ConfigureAwait(false); InvokeOnUiThread(() => { StartPreparedAnimation(); return true; }); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentState == AnimationState.Finished), "等待动画播放和碰撞检测完成超时").ConfigureAwait(false); await WaitForConditionAsync( deadlineUtc, () => InvokeOnUiThread(() => { var animationVm = RequireAnimationControlViewModel(); return animationVm.HasGeneratedCollisionReport; }), "等待碰撞报告生成超时").ConfigureAwait(false); return InvokeOnUiThread(() => { AnimationControlViewModel animationVm = RequireAnimationControlViewModel(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); CollisionReportResult report = animationVm.LastGeneratedReport; return new { route = setupResult.route, animatedObject = setupResult.animatedObject, requestedPathType = setupResult.pathType.ToString(), timeoutSeconds, automation = new { autoConfirmedCollisionAnalysisDialog = true, autoChooseCreateNewDetectionRecord = true }, animation = new { currentState = animationManager?.CurrentState.ToString(), totalFrames = animationManager?.TotalFrames ?? 0, currentFrame = animationManager?.CurrentFrame ?? 0, detectionRecordId = animationManager?.CurrentDetectionRecordId }, report = report == null ? null : new { totalCollisions = report.TotalCollisions, uniqueCollidedObjectsCount = report.UniqueCollidedObjectsCount, pathName = report.PathName, movingObjectInfo = report.MovingObjectInfo, hasScreenshots = report.Screenshots != null && report.Screenshots.Count > 0, screenshotCount = report.Screenshots?.Count ?? 0, resultId = report.ResultId, routeId = report.RouteId } }; }); } } private static object BuildEnvelope(bool ok, object data = null, string error = null) { return new Dictionary { ["ok"] = ok, ["data"] = data, ["error"] = error }; } private static T InvokeOnUiThread(Func func) { var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher == null || dispatcher.CheckAccess()) { return func(); } return dispatcher.Invoke(func); } private static ModelItem ResolveControlledObject(PathAnimationManager animationManager) { if (VirtualObjectManager.Instance.IsVirtualObjectActive && VirtualObjectManager.Instance.CurrentVirtualObject != null) { return VirtualObjectManager.Instance.CurrentVirtualObject; } return animationManager?.AnimatedObject; } private static object SerializeModelItem(ModelItem item) { if (item == null) { return null; } return new { displayName = item.DisplayName, instanceGuid = item.InstanceGuid.ToString() }; } private static object SerializeRoute(PathRoute route, PathRoute currentRoute) { return new { id = route?.Id, name = route?.Name, pathType = route?.PathType.ToString(), pointCount = route?.Points?.Count ?? 0, isCurrent = currentRoute != null && ReferenceEquals(route, currentRoute), matchesAutoTestPrefix = !string.IsNullOrWhiteSpace(route?.Name) && route.Name.StartsWith(DefaultAutoTestRoutePrefix, StringComparison.OrdinalIgnoreCase) }; } private static PathRoute TryFindAutoTestRoute(IEnumerable routes, PathType pathType) { return routes?.FirstOrDefault(route => route != null && route.PathType == pathType && !string.IsNullOrWhiteSpace(route.Name) && route.Name.StartsWith(DefaultAutoTestRoutePrefix, 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 = InvokePathManagerPrivate(pathManager, "CalculateOptimalGridSize", bounds); double objectHeightInMeters = config.ObjectHeightMeters; double objectLengthInMeters = config.ObjectLengthMeters; double objectWidthInMeters = config.ObjectWidthMeters; double 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() } }; } 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 RequestTarget ParseRequestTarget(string requestTarget) { string path = requestTarget ?? "/"; var query = new Dictionary(StringComparer.OrdinalIgnoreCase); int queryStartIndex = path.IndexOf('?'); if (queryStartIndex >= 0) { string queryString = path.Substring(queryStartIndex + 1); path = path.Substring(0, queryStartIndex); foreach (string pair in queryString.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) { string[] kv = pair.Split(new[] { '=' }, 2); string key = Uri.UnescapeDataString(kv[0] ?? string.Empty); string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1] ?? string.Empty) : string.Empty; if (!string.IsNullOrWhiteSpace(key)) { query[key] = value; } } } return new RequestTarget { Path = string.IsNullOrWhiteSpace(path) ? "/" : path, Query = query }; } private sealed class RequestTarget { public string Path { get; set; } public Dictionary Query { get; set; } } private static object ResolveTrackedState(PathAnimationManager animationManager, ModelItem controlledObject) { if (animationManager == null || controlledObject == null || !animationManager.ControlsAnimatedObject(controlledObject)) { return new { isAvailable = false, reason = animationManager == null ? "PathAnimationManagerUnavailable" : "ControlledObjectUnavailable" }; } var currentState = animationManager.GetObjectCurrentPosition(controlledObject); return new { isAvailable = true, position = SerializePoint3D(currentState.Position), yawRadians = currentState.Yaw, yawDegrees = currentState.Yaw * 180.0 / Math.PI, hasTrackedRotation = animationManager.HasTrackedRotation, trackedRotation = animationManager.HasTrackedRotation ? SerializeRotation3D(animationManager.TrackedRotation) : null }; } private static object ResolveGeometryState(ModelItem controlledObject) { if (controlledObject == null) { return new { isAvailable = false, reason = "ControlledObjectUnavailable" }; } bool hasGeometryRotation = ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out Rotation3D geometryRotation); bool hasOverrideRotation = ModelItemTransformHelper.TryGetCurrentOverrideRotation(controlledObject, out Rotation3D overrideRotation); return new { isAvailable = true, geometryRotation = hasGeometryRotation ? SerializeRotation3D(geometryRotation) : null, overrideRotation = hasOverrideRotation ? SerializeRotation3D(overrideRotation) : null }; } private static object ResolveBoundingBoxState(ModelItem controlledObject) { if (controlledObject == null) { return new { isAvailable = false, reason = "ControlledObjectUnavailable" }; } BoundingBox3D boundingBox = controlledObject.BoundingBox(); return new { isAvailable = true, min = SerializePoint3D(boundingBox.Min), max = SerializePoint3D(boundingBox.Max), center = SerializePoint3D(boundingBox.Center) }; } private static object SerializePoint3D(Point3D point) { if (point == null) { return null; } return new { x = point.X, y = point.Y, z = point.Z }; } private static object SerializePathPoint(PathPoint point) { if (point == null) { return null; } return new { id = point.Id, name = point.Name, type = point.Type.ToString(), index = point.Index, position = SerializePoint3D(point.Position) }; } private static object SerializeGridStats(GridMap gridMap) { if (gridMap == null) { return null; } int walkableCellCount = 0; int nonWalkableCellCount = 0; int obstacleCellCount = 0; int holeCellCount = 0; int boundaryLayerCount = 0; for (int x = 0; x < gridMap.Width; x++) { for (int y = 0; y < gridMap.Height; y++) { GridCell cell = gridMap.Cells[x, y]; bool isWalkable = cell.HasAnyWalkableLayer(); if (isWalkable) { walkableCellCount++; } else { nonWalkableCellCount++; } if (string.Equals(cell.CellType, "障碍物", StringComparison.Ordinal)) { obstacleCellCount++; } if (string.Equals(cell.CellType, "空洞", StringComparison.Ordinal)) { holeCellCount++; } if (cell.HeightLayers != null) { boundaryLayerCount += cell.HeightLayers.Count(layer => layer.IsBoundary); } } } int totalCellCount = gridMap.Width * gridMap.Height; return new { width = gridMap.Width, height = gridMap.Height, totalCellCount, walkableCellCount, nonWalkableCellCount, obstacleCellCount, holeCellCount, boundaryLayerCount, obstacleRatio = totalCellCount == 0 ? 0.0 : (double)obstacleCellCount / totalCellCount, nonWalkableRatio = totalCellCount == 0 ? 0.0 : (double)nonWalkableCellCount / totalCellCount }; } private static object BuildObstacleDiagnostics(GridMapGenerator gridMapGenerator, GridMap gridMap, double objectHeightInMeters, double safetyMarginInMeters) { if (gridMapGenerator == null || gridMap == null) { return null; } double metersToModelUnits = UnitsConverter.GetMetersToUnitsConversionFactor(Application.ActiveDocument.Units); double scanHeightInModelUnits = (objectHeightInMeters + safetyMarginInMeters) * metersToModelUnits; var traversableItems = CategoryAttributeManager.GetAllTraversableLogisticsItems().ToList(); var irrelevantItems = CategoryAttributeManager.GetLogisticsItemsByType("无关项"); var collectRelatedItemsMethod = typeof(GridMapGenerator).GetMethod( "CollectRelatedItems", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var postProcessGeometryItemsMethod = typeof(GridMapGenerator).GetMethod( "PostProcessGeometryItems", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var getObstacleElevationRangeMethod = typeof(GridMapGenerator).GetMethod( "GetObstacleElevationRange", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, new[] { typeof(Point3D), typeof(Point3D), typeof(GridMap) }, null); var calculateBoundingBoxGridCoverageMethod = typeof(GridMapGenerator).GetMethod( "CalculateBoundingBoxGridCoverage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var traversableRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { traversableItems }); var irrelevantRelatedItems = (HashSet)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { irrelevantItems }); var geometryItems = ModelItemAnalysisHelper.GetAllVisibleGeometryItems(); var itemCache = (Dictionary)postProcessGeometryItemsMethod.Invoke( gridMapGenerator, new object[] { geometryItems, traversableRelatedItems, irrelevantRelatedItems }); var validItems = itemCache .Where(kvp => kvp.Value.HasGeometry && !kvp.Value.IsContainer && !kvp.Value.IsChannelItem && !kvp.Value.IsChildOfChannel && kvp.Value.BoundingBox != null && kvp.Value.IsInScanHeightRange) .ToList(); double walkableMinElevation = double.MaxValue; double walkableMaxElevation = double.MinValue; int walkableGridCount = 0; for (int x = 0; x < gridMap.Width; x++) { for (int y = 0; y < gridMap.Height; y++) { GridCell cell = gridMap.Cells[x, y]; if (!cell.HasAnyWalkableLayer() || cell.HeightLayers == null || cell.HeightLayers.Count == 0) { continue; } walkableGridCount++; double cellElevation = cell.HeightLayers[0].Z; walkableMinElevation = Math.Min(walkableMinElevation, cellElevation); walkableMaxElevation = Math.Max(walkableMaxElevation, cellElevation); } } if (walkableGridCount == 0) { walkableMinElevation = 0.0; walkableMaxElevation = 0.0; } double scanMin = walkableMinElevation; double scanMax = walkableMaxElevation + scanHeightInModelUnits; var keptItems = new List(); var filteredSampleItems = new List(); int filteredByHeightCount = 0; foreach (var kvp in validItems) { BoundingBox3D bbox = kvp.Value.BoundingBox; var elevationRange = ((double min, double max))getObstacleElevationRangeMethod.Invoke( gridMapGenerator, new object[] { bbox.Min, bbox.Max, gridMap }); bool isInRange = !(elevationRange.max <= scanMin || elevationRange.min > scanMax); if (!isInRange) { filteredByHeightCount++; if (filteredSampleItems.Count < 10) { filteredSampleItems.Add(new { displayName = kvp.Key.DisplayName, bboxMin = SerializePoint3D(bbox.Min), bboxMax = SerializePoint3D(bbox.Max), minElevation = elevationRange.min, maxElevation = elevationRange.max }); } continue; } if (keptItems.Count < 30) { var coveredCells = (List<(int x, int y)>)calculateBoundingBoxGridCoverageMethod.Invoke( gridMapGenerator, new object[] { bbox, gridMap }); object triangleBounds = keptItems.Count < 8 ? SerializeTriangleBounds(kvp.Key) : null; keptItems.Add(new { displayName = kvp.Key.DisplayName, bboxMin = SerializePoint3D(bbox.Min), bboxMax = SerializePoint3D(bbox.Max), triangleBounds, minElevation = elevationRange.min, maxElevation = elevationRange.max, coveredCellCount = coveredCells.Count }); } } return new { geometryItemCount = geometryItems.Count, traversableItemCount = traversableItems.Count, traversableRelatedItemCount = traversableRelatedItems.Count, irrelevantItemCount = irrelevantItems.Count, irrelevantRelatedItemCount = irrelevantRelatedItems.Count, postProcessedItemCount = itemCache.Count, validItemCount = validItems.Count, walkableGridCount, walkableMinElevation, walkableMaxElevation, scanHeightInModelUnits, scanMin, scanMax, filteredByHeightCount, keptItemSample = keptItems, filteredByHeightSample = filteredSampleItems }; } private static object SerializeTriangleBounds(ModelItem item) { if (item == null) { return null; } List triangles = GeometryHelper.ExtractTriangles(new[] { item }); if (triangles == null || triangles.Count == 0) { return null; } double minX = triangles.Min(t => Math.Min(t.Point1.X, Math.Min(t.Point2.X, t.Point3.X))); double minY = triangles.Min(t => Math.Min(t.Point1.Y, Math.Min(t.Point2.Y, t.Point3.Y))); double minZ = triangles.Min(t => Math.Min(t.Point1.Z, Math.Min(t.Point2.Z, t.Point3.Z))); double maxX = triangles.Max(t => Math.Max(t.Point1.X, Math.Max(t.Point2.X, t.Point3.X))); double maxY = triangles.Max(t => Math.Max(t.Point1.Y, Math.Max(t.Point2.Y, t.Point3.Y))); double maxZ = triangles.Max(t => Math.Max(t.Point1.Z, Math.Max(t.Point2.Z, t.Point3.Z))); return new { min = SerializePoint3D(new Point3D(minX, minY, minZ)), max = SerializePoint3D(new Point3D(maxX, maxY, maxZ)) }; } private static T InvokePathManagerPrivate(PathPlanningManager pathManager, string methodName, params object[] args) { var method = typeof(PathPlanningManager).GetMethod( methodName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (method == null) { throw new MissingMethodException(typeof(PathPlanningManager).FullName, methodName); } object result = method.Invoke(pathManager, args); if (result == null) { return default(T); } return (T)result; } private static object SerializeRotation3D(Rotation3D rotation) { var linear = new Transform3D(rotation).Linear; return new { quaternion = new { x = rotation.A, y = rotation.B, z = rotation.C, w = rotation.D }, axes = new { hostX = new { x = linear.Get(0, 0), y = linear.Get(1, 0), z = linear.Get(2, 0) }, hostY = new { x = linear.Get(0, 1), y = linear.Get(1, 1), z = linear.Get(2, 1) }, hostZ = new { x = linear.Get(0, 2), y = linear.Get(1, 2), z = linear.Get(2, 2) } } }; } private static string WriteSnapshotToDisk(object snapshotPayload) { string logDirectory = Path.GetDirectoryName(LogManager.LogFilePath); string snapshotDirectory = Path.Combine(logDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "test-automation"); Directory.CreateDirectory(snapshotDirectory); string fileName = string.Format( CultureInfo.InvariantCulture, "debug-snapshot-{0:yyyyMMdd-HHmmss-fff}.json", DateTime.Now); string fullPath = Path.Combine(snapshotDirectory, fileName); string json = JsonConvert.SerializeObject(snapshotPayload, Formatting.Indented); File.WriteAllText(fullPath, json, new UTF8Encoding(false)); LogManager.Info($"[测试HTTP] 已导出调试快照: {fullPath}"); return fullPath; } private static AnimationControlViewModel RequireAnimationControlViewModel() { var animationViewModel = AnimationControlViewModel.Instance; if (animationViewModel == null) { throw new InvalidOperationException("AnimationControlViewModel 当前不可用,请先打开插件动画页签"); } return animationViewModel; } private static PreparedVirtualCollisionTest PrepareVirtualCollisionTest(Dictionary query, PathType? fixedPathType) { PathPlanningManager pathManager = RequirePathManager(); AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); animationViewModel.SetPathPlanningManager(pathManager); PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, fixedPathType); pathManager.SetCurrentRoute(route); animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); animationViewModel.IsManualCollisionTargetEnabled = false; animationViewModel.UseVirtualObject = true; object animatedObjectPayload = new { mode = "VirtualObject", displayName = "虚拟物体" }; if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) { throw new InvalidOperationException($"{route.PathType} 虚拟物体碰撞测试准备失败:当前条件下不能生成动画"); } animationViewModel.GenerateAnimationCommand.Execute(null); LogManager.Info( $"[测试HTTP] 已开始准备虚拟物体碰撞测试: 路径={route.Name}, 类型={route.PathType}"); return new PreparedVirtualCollisionTest { pathType = route.PathType, route = SerializeRoute(route, route), animatedObject = animatedObjectPayload }; } private static void StartPreparedAnimation() { AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); PathAnimationManager animationManager = PathAnimationManager.GetInstance(); if (animationManager == null) { throw new InvalidOperationException("PathAnimationManager 当前不可用,无法开始播放"); } if (animationManager.CurrentState == AnimationState.Paused) { animationManager.ResumeAnimation(); LogManager.Info("[测试HTTP] 已从暂停状态恢复自动测试动画播放"); return; } if (animationManager.IsAnimating) { LogManager.Info("[测试HTTP] 动画已经在播放中,跳过重复开始"); return; } if (animationManager.CurrentState != AnimationState.Ready && animationManager.CurrentState != AnimationState.Finished) { throw new InvalidOperationException($"动画尚未就绪,当前状态={animationManager.CurrentState}"); } animationManager.ClearExclusionCache(); ModelHighlightHelper.ClearCollisionHighlights(); animationManager.StartAnimation(); LogManager.Info("[测试HTTP] 已启动自动测试动画播放"); } private static PathRoute ResolveVirtualCollisionRoute(PathPlanningManager pathManager, Dictionary query, PathType? fixedPathType) { string routeName = GetOptionalQueryValue(query, "routeName"); string pathTypeText = fixedPathType.HasValue ? fixedPathType.Value.ToString() : GetOptionalQueryValue(query, "pathType"); List routes = pathManager.GetAllRoutes() ?? new List(); PathType pathType = fixedPathType ?? ParsePathTypeOrThrow(pathTypeText, "pathType"); PathRoute route = string.IsNullOrWhiteSpace(routeName) ? TryFindAutoTestRoute(routes, pathType) : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); if (route == null) { throw new InvalidOperationException( string.IsNullOrWhiteSpace(routeName) ? $"找不到默认自动测试路径: {pathType}" : $"找不到指定路径: {routeName}"); } if (route.PathType != pathType) { throw new InvalidOperationException($"指定路径类型不匹配: 期望={pathType}, 实际={route.PathType}, 路径={route.Name}"); } return route; } private static PathType ParsePathTypeOrThrow(string value, string parameterName) { if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException($"缺少必填参数: {parameterName}"); } if (!Enum.TryParse(value, true, out PathType pathType)) { throw new InvalidOperationException($"无法解析路径类型: {value}"); } return pathType; } private static ModelItem ResolveAnimatedObjectFromQuery(Dictionary query) { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; if (activeDocument == null) { throw new InvalidOperationException("当前没有活动文档,无法选择真实物体"); } string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName"); if (string.IsNullOrWhiteSpace(animatedObjectName)) { return ResolveSingleSelectedItem(activeDocument); } var matches = new List(); foreach (Model model in activeDocument.Models) { if (model?.RootItem == null) { continue; } foreach (ModelItem item in model.RootItem.DescendantsAndSelf) { if (item != null && string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase)) { matches.Add(item); } } } if (matches.Count == 0) { throw new InvalidOperationException($"找不到指定真实物体: {animatedObjectName}"); } if (matches.Count > 1) { throw new InvalidOperationException($"找到多个同名真实物体,请改用更唯一的名字: {animatedObjectName}"); } return matches[0]; } private static ModelItem ResolveSingleSelectedItem(Document activeDocument) { var selectedItems = activeDocument.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() ?? new List(); if (selectedItems.Count != 1) { throw new InvalidOperationException($"当前选择集必须且只能包含 1 个真实物体,当前数量: {selectedItems.Count}"); } return selectedItems[0]; } private static void SelectDocumentItem(ModelItem item) { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; if (activeDocument?.CurrentSelection == null) { throw new InvalidOperationException("当前文档选择集不可用"); } activeDocument.CurrentSelection.Clear(); activeDocument.CurrentSelection.Add(item); } private static PathRouteViewModel CreatePathRouteViewModel(PathRoute route) { var routeViewModel = new PathRouteViewModel(isFromDatabase: true) { Route = route, IsActive = true }; foreach (var point in route.Points.OrderBy(p => p.Index)) { routeViewModel.Points.Add(new PathPointViewModel { Id = point.Id, Name = point.Name, Type = point.Type, Index = point.Index, X = point.X, Y = point.Y, Z = point.Z }); } routeViewModel.SetTimeInfo(route.CreatedTime, route.LastModified); return routeViewModel; } private static int ParseTimeoutSeconds(Dictionary query, int defaultSeconds) { string raw = GetOptionalQueryValue(query, "timeoutSeconds"); if (string.IsNullOrWhiteSpace(raw)) { return defaultSeconds; } if (!int.TryParse(raw, out int parsedSeconds) || parsedSeconds <= 0) { throw new InvalidOperationException($"无法解析 timeoutSeconds: {raw}"); } return parsedSeconds; } private static bool ParseBooleanQuery(Dictionary query, string key, bool defaultValue) { string raw = GetOptionalQueryValue(query, key); if (string.IsNullOrWhiteSpace(raw)) { return defaultValue; } if (bool.TryParse(raw, out bool parsed)) { return parsed; } if (string.Equals(raw, "1", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "yes", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "y", StringComparison.OrdinalIgnoreCase)) { return true; } if (string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "no", StringComparison.OrdinalIgnoreCase) || string.Equals(raw, "n", StringComparison.OrdinalIgnoreCase)) { return false; } throw new InvalidOperationException($"无法解析布尔参数 {key}: {raw}"); } private static IDisposable EnableAutoConfirmCollisionAnalysisDialogs() { Interlocked.Increment(ref _autoConfirmCollisionAnalysisDialogRequestCount); return new ActionOnDispose(() => { if (Interlocked.Decrement(ref _autoConfirmCollisionAnalysisDialogRequestCount) < 0) { Interlocked.Exchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0); } }); } private static IDisposable EnableAutoChooseCreateNewDetectionRecord() { Interlocked.Increment(ref _autoChooseCreateNewDetectionRecordRequestCount); return new ActionOnDispose(() => { if (Interlocked.Decrement(ref _autoChooseCreateNewDetectionRecordRequestCount) < 0) { Interlocked.Exchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0); } }); } private static async Task WaitForConditionAsync(DateTime deadlineUtc, Func condition, string timeoutMessage) { while (DateTime.UtcNow < deadlineUtc) { if (condition()) { return; } await Task.Delay(300).ConfigureAwait(false); } throw new TimeoutException(timeoutMessage); } private sealed class PreparedVirtualCollisionTest { public PathType pathType { get; set; } public object route { get; set; } public object animatedObject { get; set; } } private sealed class ActionOnDispose : IDisposable { private Action _disposeAction; public ActionOnDispose(Action disposeAction) { _disposeAction = disposeAction; } public void Dispose() { Action disposeAction = Interlocked.Exchange(ref _disposeAction, null); disposeAction?.Invoke(); } } private static async Task WriteJsonResponseAsync(StreamWriter writer, int statusCode, object payload) { string statusText = ResolveStatusText(statusCode); string json = JsonConvert.SerializeObject(payload, Formatting.Indented); byte[] bodyBytes = Encoding.UTF8.GetBytes(json); await writer.WriteLineAsync($"HTTP/1.1 {statusCode} {statusText}").ConfigureAwait(false); await writer.WriteLineAsync("Content-Type: application/json; charset=utf-8").ConfigureAwait(false); await writer.WriteLineAsync($"Content-Length: {bodyBytes.Length}").ConfigureAwait(false); await writer.WriteLineAsync("Connection: close").ConfigureAwait(false); await writer.WriteLineAsync().ConfigureAwait(false); await writer.FlushAsync().ConfigureAwait(false); Stream baseStream = writer.BaseStream; await baseStream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false); await baseStream.FlushAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); } private static string ResolveStatusText(int statusCode) { switch (statusCode) { case 200: return "OK"; case 400: return "Bad Request"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; case 500: return "Internal Server Error"; default: return "OK"; } } public void Dispose() { Stop(); } } }