SectionBoxExporter: - TraverseAndCollect 对非几何节点(组节点)先查聚合包围盒, 与剖面盒不相交 → 整棵子树跳过(NW BoundingBox 为文档级缓存 O(1), 已用 Architecture.nwd 2 万节点 + 全厂模型 403MB 验证) - GetObjectsAndHiddenItems 增加 prune 参数(默认 true,false 供性能对比) 验证(全厂设备模型 403MB,100x100x40 测试盒): - 剪枝 72.9s vs 无剪枝 129.9s(~1.8 倍) - 盒内对象/隐藏节点完全一致(3521/80043,正确性无变化) - Architecture.nwd(2 万节点):273ms vs 580ms(~2.1 倍) - 集成测试 25/25 测试基础设施: - export-section-box 端点加 prune 参数 + traversalMs 计时 - bbox-profiling 端点加 enum=false 模式(根盒查询,避免大模型全枚举 10+ 分钟) - BatchQueueF1ProcessAutomationTests 末尾等待后台批处理完全退出 (修复 F1 后台任务残留导致后续 BatchQueue_AddItems isExecuting 断言失败) - batch-queue-status 端点加 isExecuting 字段
3744 lines
158 KiB
C#
3744 lines
158 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// 本地测试自动化 HTTP 控制面。
|
||
/// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。
|
||
/// </summary>
|
||
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<TestAutomationHttpService> _instance =
|
||
new Lazy<TestAutomationHttpService>(() => 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
|
||
{
|
||
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/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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 【实验端点】NW 组节点/几何节点 BoundingBox 缓存行为分析。
|
||
/// 验证:组节点聚合盒是否为 O(1) 缓存读取(用于剖面盒遍历子树剪枝)。
|
||
/// </summary>
|
||
private object BuildBBoxProfilingPayload(Dictionary<string, string> 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<ModelItem>()).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 BuildStatusPayload()
|
||
{
|
||
Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument;
|
||
PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager();
|
||
PathAnimationManager animationManager = PathAnimationManager.GetInstance();
|
||
|
||
PathRoute currentRoute = pathManager?.CurrentRoute;
|
||
List<PathRoute> 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<PathRoute> routes = pathManager?.GetAllRoutes() ?? new List<PathRoute>();
|
||
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
|
||
}
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 枚举模型叶子物体:DisplayName + ModelPath(PathId) + 祖先名链。
|
||
/// 供集成测试动态定位真实物体(ModelPath 跨会话不稳定,测试不应硬编码 PathId)。
|
||
/// </summary>
|
||
private object BuildModelItemsPayload(Dictionary<string, string> 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<object>();
|
||
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<string>();
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询碰撞检测记录(集成测试验证检测记录持久化:动画参数/关联路径正确落库)。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 路径分析(效率/安全评分),集成测试验证分析链路与评分结构。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 剖面盒导出(T16):mode=bbox 用指定包围盒导出 NWD(集成测试可控,不依赖 UI 剖面盒状态);
|
||
/// mode=activeView 用当前视图剖面盒(需 UI 已激活 Box 模式)。
|
||
/// </summary>
|
||
private static object ExportSectionBoxPayload(Dictionary<string, string> 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 路径编辑集成测试端点(T11):action=remove-point/update-point/orthogonalize/remove-loops。
|
||
/// 直接操作临时测试路径,验证 PathPlanningManager 编辑链路。
|
||
/// </summary>
|
||
private static object EditRoutePayload(Dictionary<string, string> 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<PathPoint> orderedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List<PathPoint>();
|
||
|
||
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<PathPoint> updatedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List<PathPoint>();
|
||
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<string, string> 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<string, string> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 批量任务队列添加(集成测试验证队列管理,不执行 ProcessQueueAsync 以免长时间批量动画)。
|
||
/// query:routeName(必填)、frameRate、durationSeconds。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 触发批处理队列执行(F1):后台执行,立即返回(ProcessQueueAsync 含模态进度条,
|
||
/// 同步等待会阻塞 HTTP 响应;调用方轮询数据库 BatchQueueItems 状态跟踪)。
|
||
/// </summary>
|
||
private static object BatchQueueProcessPayload(Dictionary<string, string> 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<object> BatchQueueStatusPayload(Dictionary<string, string> 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<object> BatchQueueAddAsync(Dictionary<string, string> 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);
|
||
|
||
// 真实物体:主实例解析名字并写入 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<string, string> 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<string, string> 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")
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析实例端口:优先环境变量 TRANSPORTPLUGIN_TEST_PORT,默认 18777。
|
||
/// 支持多 Navisworks 实例并行(E4 副实例检测 worker)。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// F2:序列化碰撞对象详情(name + 副实例 PathId + 层级链),
|
||
/// 供主 NW 按层级链(导出保留树结构,去根一致)映射回主模型。
|
||
/// </summary>
|
||
private static List<object> SerializeCollidedObjects(
|
||
List<CollisionResult> allCollisions, List<string> nameList)
|
||
{
|
||
var result = new List<object>();
|
||
var seen = new HashSet<string>(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<ModelItem>().Where(item => item != null).ToList()
|
||
?? new List<ModelItem>();
|
||
|
||
return new
|
||
{
|
||
hasActiveDocument = activeDocument != null,
|
||
selectionCount = selectedItems.Count,
|
||
selectedItems = selectedItems.Select(SerializeModelItem).ToList()
|
||
};
|
||
}
|
||
|
||
private object SelectRoutePayload(Dictionary<string, string> query)
|
||
{
|
||
PathPlanningManager pathManager = RequirePathManager();
|
||
string routeName = GetRequiredQueryValue(query, "name");
|
||
List<PathRoute> routes = pathManager.GetAllRoutes() ?? new List<PathRoute>();
|
||
|
||
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<string, string> 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<PathRoute> routes = pathManager.GetAllRoutes() ?? new List<PathRoute>();
|
||
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<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<string, string> 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<object> RunVirtualCollisionTestAsync(Dictionary<string, string> query, PathType? fixedPathType)
|
||
{
|
||
await _testExecutionLock.WaitAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
return await RunVirtualCollisionTestCoreAsync(query, fixedPathType).ConfigureAwait(false);
|
||
}
|
||
finally
|
||
{
|
||
_testExecutionLock.Release();
|
||
}
|
||
}
|
||
|
||
private async Task<object> RunVirtualCollisionTestCoreAsync(Dictionary<string, string> 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,
|
||
// F2:碰撞对象详情(name + 副实例 PathId + 层级链),用于跨实例报告映射回主模型
|
||
collidedObjects = SerializeCollidedObjects(report.AllCollisions, report.CollidedObjectsList)
|
||
}
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
private static object BuildEnvelope(bool ok, object data = null, string error = null)
|
||
{
|
||
return new Dictionary<string, object>
|
||
{
|
||
["ok"] = ok,
|
||
["data"] = data,
|
||
["error"] = error
|
||
};
|
||
}
|
||
|
||
private static T InvokeOnUiThread<T>(Func<T> 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<PathRoute> 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<string, string> query)
|
||
{
|
||
PathPlanningManager pathManager = RequirePathManager();
|
||
PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null);
|
||
if (route.Points == null || route.Points.Count < 2)
|
||
{
|
||
throw new InvalidOperationException($"路径点不足,无法分析自动路径网格: {route.Name}");
|
||
}
|
||
|
||
List<PathPoint> orderedPoints = route.Points.OrderBy(point => point.Index).ToList();
|
||
PathPoint startPoint = orderedPoints.First();
|
||
PathPoint endPoint = orderedPoints.Last();
|
||
|
||
BoundingBox3D bounds = InvokePathManagerPrivate<BoundingBox3D>(pathManager, "GetModelBounds");
|
||
double gridSize = InvokePathManagerPrivate<double>(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<string, string> query)
|
||
{
|
||
PathPlanningManager pathManager = RequirePathManager();
|
||
PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, null);
|
||
if (route.Points == null || route.Points.Count < 2)
|
||
{
|
||
throw new InvalidOperationException($"路径点不足,无法执行自动路径规划: {route.Name}");
|
||
}
|
||
|
||
List<PathPoint> 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<BoundingBox3D>(pathManager, "GetModelBounds");
|
||
double gridSize = ParsePositiveDoubleQuery(
|
||
query,
|
||
"gridSizeInMeters",
|
||
InvokePathManagerPrivate<double>(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)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导入路径文件(JSON/XML)并注册到路径管理器,返回导入路径的完整信息。
|
||
/// 供集成测试验证“导入 → 属性映射 → 长度计算 → 路径可用性”完整流程。
|
||
/// overwrite=true 时同名路径先删除后导入(保证测试可重复执行且不累积数据)。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 导出路径到文件(XML/JSON),供集成测试验证导出→导入回环一致性。
|
||
/// query:routeName(必填)、format(xml/json,默认 xml)、path(可选导出路径)。
|
||
/// </summary>
|
||
private static object ExportRouteFilePayload(Dictionary<string, string> 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<PathRoute> { route }, exportPath)
|
||
: dataManager.ExportToXml(new List<PathRoute> { 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<string, string> 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<PathRoute> importedRoutes = format == ExportFormat.Json
|
||
? dataManager.ImportFromJson(filePath)
|
||
: dataManager.ImportFromXml(filePath);
|
||
|
||
if (importedRoutes == null || importedRoutes.Count == 0)
|
||
{
|
||
throw new InvalidOperationException($"导入文件中没有有效路径: {filePath}");
|
||
}
|
||
|
||
var serializedRoutes = new List<object>();
|
||
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<object>();
|
||
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<string, string> query)
|
||
{
|
||
PathPlanningManager pathManager = RequirePathManager();
|
||
string routeName = GetOptionalQueryValue(query, "routeName");
|
||
List<PathRoute> routes = pathManager.GetAllRoutes() ?? new List<PathRoute>();
|
||
|
||
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<PathPoint>();
|
||
var segmentDiagnostics = new List<object>();
|
||
var blockedIntersections = new List<object>();
|
||
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<object> 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<dynamic> 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<dynamic>();
|
||
|
||
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<object> 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<string, string> query, string key)
|
||
{
|
||
string value = GetOptionalQueryValue(query, key);
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
throw new InvalidOperationException($"缺少必填参数: {key}");
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
private static string GetOptionalQueryValue(Dictionary<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string>(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<string, string> 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<ModelItem>)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { traversableItems });
|
||
var irrelevantRelatedItems = (HashSet<ModelItem>)collectRelatedItemsMethod.Invoke(gridMapGenerator, new object[] { irrelevantItems });
|
||
var geometryItems = ModelItemAnalysisHelper.GetAllVisibleGeometryItems();
|
||
var itemCache = (Dictionary<ModelItem, ItemProperties>)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<object>();
|
||
var filteredSampleItems = new List<object>();
|
||
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<Triangle3D> 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<T>(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<string, string> 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 动画逐帧状态探针:生成动画后 seek 到指定进度点,返回各点物体跟踪位置/yaw。
|
||
/// 供集成测试验证物体沿路径移动(起点→中间→终点)与平面姿态链。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 动画播放控制集成测试序列:prepare → start → pause(验证帧停止)→ resume(验证帧前进)→ finished。
|
||
/// 验证 PathAnimationManager 的暂停/恢复/播放完整链路。
|
||
/// </summary>
|
||
private static async Task<object> AnimationPlaybackControlAsync(Dictionary<string, string> query)
|
||
{
|
||
await _testExecutionLock.WaitAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
return await AnimationPlaybackControlCoreAsync(query).ConfigureAwait(false);
|
||
}
|
||
finally
|
||
{
|
||
_testExecutionLock.Release();
|
||
}
|
||
}
|
||
|
||
private static async Task<object> AnimationPlaybackControlCoreAsync(Dictionary<string, string> 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<object>();
|
||
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<object> ProbeAnimationFramesAsync(Dictionary<string, string> query)
|
||
{
|
||
await _testExecutionLock.WaitAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
return await ProbeAnimationFramesCoreAsync(query).ConfigureAwait(false);
|
||
}
|
||
finally
|
||
{
|
||
_testExecutionLock.Release();
|
||
}
|
||
}
|
||
|
||
private static async Task<object> ProbeAnimationFramesCoreAsync(Dictionary<string, string> 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<object>();
|
||
|
||
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<PathPoint> orderedPoints = route.Points?.OrderBy(p => p.Index).ToList() ?? new List<PathPoint>();
|
||
return new
|
||
{
|
||
route = SerializeRoute(route, route),
|
||
totalFrames,
|
||
pathStart = SerializePoint3D(orderedPoints.FirstOrDefault()?.Position),
|
||
pathEnd = SerializePoint3D(orderedPoints.LastOrDefault()?.Position),
|
||
probes
|
||
};
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成虚拟物体动画(帧探针准备阶段),返回当前路径。
|
||
/// </summary>
|
||
private static PathRoute PrepareProbeAnimation(Dictionary<string, string> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 应用测试动画参数(帧率/时长),加速集成测试:
|
||
/// query 支持 frameRate(FPS)与 durationSeconds,缺省保持配置默认值。
|
||
/// </summary>
|
||
private static void ApplyTestAnimationParameters(AnimationControlViewModel animationViewModel, Dictionary<string, string> 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<string, string> query, PathType? fixedPathType)
|
||
{
|
||
string routeName = GetOptionalQueryValue(query, "routeName");
|
||
string pathTypeText = fixedPathType.HasValue
|
||
? fixedPathType.Value.ToString()
|
||
: GetOptionalQueryValue(query, "pathType");
|
||
List<PathRoute> routes = pathManager.GetAllRoutes() ?? new List<PathRoute>();
|
||
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<PathRoute>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断两个 ModelItem 是否指向同一模型对象。
|
||
/// NW API 中同一逻辑对象通过不同途径(Selection/Descendants)获取的实例不保证同一引用,
|
||
/// 因此用 InstanceGuid 比较,禁止用 ReferenceEquals。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查并导入集成测试标准路径(自动测试_ 前缀 + 测试基准)。
|
||
/// 仅当对应名称的路径缺失时,从插件部署目录 resources\auto-test-routes.xml 导入。
|
||
/// 已存在或重名的路径不会被覆盖/重命名,确保不破坏用户数据。
|
||
/// </summary>
|
||
private static void EnsureAutoTestRoutesImported(PathPlanningManager pathManager)
|
||
{
|
||
string[] standardRouteNames = new[]
|
||
{
|
||
"自动测试_Ground",
|
||
"自动测试_Hoisting",
|
||
"自动测试_Rail",
|
||
"自动测试_Free",
|
||
"测试基准"
|
||
};
|
||
|
||
List<PathRoute> existingRoutes = pathManager.GetAllRoutes() ?? new List<PathRoute>();
|
||
List<string> 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<PathRoute> 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<string, string> 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<ModelItem>();
|
||
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<ModelItem>().Where(item => item != null).ToList()
|
||
?? new List<ModelItem>();
|
||
|
||
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<string, string> 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<string, string> 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<bool> 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();
|
||
}
|
||
}
|
||
}
|