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