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