diff --git a/AGENTS.md b/AGENTS.md index 745e39b..b8719a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,30 @@ 不要并行执行编译和部署。否则很容易把旧 DLL 部署到插件目录。 +### 并行执行边界 + +后续会话中的 AI 助手必须把命令分成两类: + +- **允许并行的纯读取操作** + - `rg` + - `Get-Content` + - `ls / Get-ChildItem` + - 读取日志 + - 查询状态 +- **禁止并行的产出型/宿主相关操作** + - `run-unit-tests.bat` + - `compile.bat` + - `deploy-plugin.bat` + - 启动/关闭 Navisworks + - 会占用 DLL、修改 `bin/obj`、写插件部署目录、依赖上一步产物的任何命令 + +硬约束: + +- 只有“纯读取、无副作用、且互不依赖”的命令才允许并行 +- 只要命令会生成、覆盖、部署、锁定文件、启动宿主进程,必须串行执行 +- 对本仓库,默认把 `run-unit-tests -> compile -> deploy -> start Navisworks` 视为**单通道流水线** +- 不允许把这条流水线放进任何并行工具里,即使只是为了节省时间 + ### 插件部署目录 - `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\` diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj index 959e016..113b76a 100644 --- a/NavisworksTransport.UnitTests.csproj +++ b/NavisworksTransport.UnitTests.csproj @@ -42,6 +42,9 @@ packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + @@ -57,6 +60,8 @@ + + diff --git a/TransportPlugin.csproj b/TransportPlugin.csproj index a41f577..6cf663a 100644 --- a/TransportPlugin.csproj +++ b/TransportPlugin.csproj @@ -150,6 +150,7 @@ + diff --git a/UnitTests/Integration/NavisworksTestAutomationClient.cs b/UnitTests/Integration/NavisworksTestAutomationClient.cs new file mode 100644 index 0000000..e5bfab5 --- /dev/null +++ b/UnitTests/Integration/NavisworksTestAutomationClient.cs @@ -0,0 +1,85 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + internal sealed class NavisworksTestAutomationClient : IDisposable + { + private readonly HttpClient _httpClient; + + public NavisworksTestAutomationClient() + { + _httpClient = new HttpClient + { + BaseAddress = new Uri("http://127.0.0.1:18777"), + Timeout = TimeSpan.FromSeconds(20) + }; + } + + public async Task EnsureServiceReadyAsync(TimeSpan timeout) + { + DateTime deadlineUtc = DateTime.UtcNow.Add(timeout); + Exception lastError = null; + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + JObject pingResponse = await GetJsonAsync("/api/test/ping").ConfigureAwait(false); + if (pingResponse.Value("ok") == true) + { + return; + } + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + + Assert.Fail( + "Navisworks 测试服务未就绪。请先用 start-navisworks.bat 启动 Navisworks,并确保插件面板已加载。最后错误: {0}", + lastError?.Message ?? "unknown"); + } + + public async Task RunVirtualCollisionTestAsync(string pathType, int timeoutSeconds) + { + string requestUri = string.Format( + "/api/test/run-virtual-collision-test?pathType={0}&timeoutSeconds={1}", + Uri.EscapeDataString(pathType), + timeoutSeconds); + + return await PostJsonAsync(requestUri).ConfigureAwait(false); + } + + private async Task GetJsonAsync(string requestUri) + { + using (HttpResponseMessage response = await _httpClient.GetAsync(requestUri).ConfigureAwait(false)) + { + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return JObject.Parse(content); + } + } + + private async Task PostJsonAsync(string requestUri) + { + using (HttpResponseMessage response = await _httpClient.PostAsync(requestUri, new StringContent(string.Empty)).ConfigureAwait(false)) + { + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return JObject.Parse(content); + } + } + + public void Dispose() + { + _httpClient.Dispose(); + } + } +} diff --git a/UnitTests/Integration/VirtualCollisionAutomationTests.cs b/UnitTests/Integration/VirtualCollisionAutomationTests.cs new file mode 100644 index 0000000..1791734 --- /dev/null +++ b/UnitTests/Integration/VirtualCollisionAutomationTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + [TestClass] + [TestCategory("NavisworksIntegration")] + public class VirtualCollisionAutomationTests + { + private const int DefaultCollisionTimeoutSeconds = 240; + + [TestMethod] + [Timeout(360000)] + public async Task GroundVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Ground").ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task HoistingVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Hoisting").ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task RailVirtualCollision_AutoTestRoute_Completes() + { + await AssertVirtualCollisionTestAsync("Rail").ConfigureAwait(false); + } + + private static async Task AssertVirtualCollisionTestAsync(string pathType) + { + using (var client = new NavisworksTestAutomationClient()) + { + await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false); + + JObject response = await client.RunVirtualCollisionTestAsync(pathType, DefaultCollisionTimeoutSeconds).ConfigureAwait(false); + Assert.IsTrue(response.Value("ok"), "测试 HTTP 接口返回失败: " + (string)response["error"]); + + JObject data = (JObject)response["data"]; + Assert.IsNotNull(data, "缺少测试结果 data"); + + Assert.AreEqual(pathType, (string)data["requestedPathType"], "请求的路径类型不匹配"); + + JObject route = (JObject)data["route"]; + Assert.IsNotNull(route, "缺少 route"); + Assert.AreEqual(pathType, (string)route["pathType"], "返回的路径类型不匹配"); + Assert.IsFalse(string.IsNullOrWhiteSpace((string)route["name"]), "路径名为空"); + + JObject animatedObject = (JObject)data["animatedObject"]; + Assert.IsNotNull(animatedObject, "缺少 animatedObject"); + Assert.AreEqual("VirtualObject", (string)animatedObject["mode"], "当前集成测试应使用虚拟物体"); + + JObject animation = (JObject)data["animation"]; + Assert.IsNotNull(animation, "缺少 animation"); + Assert.AreEqual("Finished", (string)animation["currentState"], "动画未完成"); + Assert.IsTrue((int)animation["totalFrames"] > 0, "动画总帧数应大于 0"); + Assert.IsTrue(animation["detectionRecordId"] != null && (int)animation["detectionRecordId"] > 0, "检测记录 ID 无效"); + + Assert.IsNotNull(data["report"], "缺少碰撞报告"); + } + } + } +} diff --git a/run-ground-virtual-collision-test.bat b/run-ground-virtual-collision-test.bat new file mode 100644 index 0000000..e38e421 --- /dev/null +++ b/run-ground-virtual-collision-test.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-ground-virtual-collision-test.ps1" %* + +if errorlevel 1 exit /b 1 +exit /b 0 diff --git a/run-unit-tests.bat b/run-unit-tests.bat index b5df301..64220ca 100644 --- a/run-unit-tests.bat +++ b/run-unit-tests.bat @@ -41,7 +41,7 @@ if errorlevel 1 ( exit /b 1 ) -%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% +%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% /TestCaseFilter:"TestCategory!=NavisworksIntegration" if errorlevel 1 ( echo Unit tests failed! exit /b 1 diff --git a/scripts/run-ground-virtual-collision-test.ps1 b/scripts/run-ground-virtual-collision-test.ps1 new file mode 100644 index 0000000..d4a48c9 --- /dev/null +++ b/scripts/run-ground-virtual-collision-test.ps1 @@ -0,0 +1,154 @@ +[CmdletBinding()] +param( + [string]$ModelPath, + [int]$ServiceStartupTimeoutSeconds = 90, + [int]$TestTimeoutSeconds = 240 +) + +$ErrorActionPreference = 'Stop' + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDirectory +$startScriptPath = Join-Path $scriptDirectory 'start-navisworks.ps1' +$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json' +$testApiBaseUrl = 'http://127.0.0.1:18777' + +function Resolve-DefaultModelPath { + if (-not (Test-Path $defaultsFilePath)) { + return $null + } + + $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json + $defaultModelPath = [string]$defaults.defaultModelPath + if ([string]::IsNullOrWhiteSpace($defaultModelPath)) { + return $null + } + + return [System.IO.Path]::GetFullPath($defaultModelPath) +} + +function Wait-TestApiReady { + param( + [string]$BaseUrl, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + try { + $pingResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/ping') -TimeoutSec 10 + if ($pingResponse.ok) { + return + } + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds 1 + } + + if ([string]::IsNullOrWhiteSpace($lastError)) { + throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。" + } + + throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError" +} + +function Wait-GroundRouteReady { + param( + [string]$BaseUrl, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + try { + $routesResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/routes') -TimeoutSec 10 + if ($routesResponse.ok -and $routesResponse.data -and $routesResponse.data.totalRouteCount -gt 0) { + $groundRoute = $routesResponse.data.suggestedAutoTestRoutes.ground + if (-not [string]::IsNullOrWhiteSpace([string]$groundRoute)) { + return [string]$groundRoute + } + } + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds 1 + } + + if ([string]::IsNullOrWhiteSpace($lastError)) { + throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。" + } + + throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError" +} + +function Save-JsonResult { + param( + [object]$Payload + ) + + $logRoot = Join-Path $env:ProgramData 'Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\test-automation' + New-Item -ItemType Directory -Path $logRoot -Force | Out-Null + + $fileName = 'ground-virtual-collision-test-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.json' + $outputPath = Join-Path $logRoot $fileName + $json = $Payload | ConvertTo-Json -Depth 16 + [System.IO.File]::WriteAllText($outputPath, $json, [System.Text.UTF8Encoding]::new($false)) + return $outputPath +} + +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = Resolve-DefaultModelPath +} + +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = [System.IO.Path]::GetFullPath($ModelPath) + if (-not (Test-Path $ModelPath)) { + throw "模型文件不存在: $ModelPath" + } +} + +Write-Host '启动 Navisworks...' +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + & $startScriptPath +} +else { + & $startScriptPath $ModelPath +} + +Write-Host '等待测试 HTTP 服务就绪...' +Wait-TestApiReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds + +Write-Host '等待默认地面测试路径就绪...' +$groundRouteName = Wait-GroundRouteReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds + +Write-Host ('切换到默认地面测试路径: {0}' -f $groundRouteName) +$selectRouteResponse = Invoke-RestMethod -Method Post -Uri ($testApiBaseUrl + '/api/test/select-default-route?pathType=Ground') -TimeoutSec 15 +if (-not $selectRouteResponse.ok) { + throw '切换默认地面测试路径失败。' +} + +Write-Host '执行地面虚拟物体碰撞测试...' +$requestUrl = $testApiBaseUrl + "/api/test/run-ground-collision-test?useVirtualObject=true&timeoutSeconds=$TestTimeoutSeconds" +$testResult = Invoke-RestMethod -Method Post -Uri $requestUrl -TimeoutSec ($TestTimeoutSeconds + 30) + +$outputFilePath = Save-JsonResult -Payload $testResult + +Write-Host ('结果已保存: {0}' -f $outputFilePath) +if ($testResult.ok -and $testResult.data) { + Write-Host ('路径: {0}' -f $testResult.data.route.name) + Write-Host ('动画状态: {0}' -f $testResult.data.animation.currentState) + Write-Host ('检测记录ID: {0}' -f $testResult.data.animation.detectionRecordId) + if ($testResult.data.report) { + Write-Host ('碰撞数: {0}' -f $testResult.data.report.totalCollisions) + Write-Host ('截图数: {0}' -f $testResult.data.report.screenshotCount) + } +} diff --git a/scripts/start-navisworks.ps1 b/scripts/start-navisworks.ps1 new file mode 100644 index 0000000..6a150b5 --- /dev/null +++ b/scripts/start-navisworks.ps1 @@ -0,0 +1,167 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$ModelPath +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDirectory +$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json' + +function Resolve-RoamerPath { + $candidates = New-Object System.Collections.Generic.List[string] + $appPathKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe' + ) + + foreach ($key in $appPathKeys) { + try { + $value = (Get-ItemProperty -Path $key -ErrorAction Stop).'(default)' + if (-not [string]::IsNullOrWhiteSpace($value)) { + $candidates.Add($value) + } + } + catch { + } + } + + $wellKnownPaths = @( + (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Manage 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Manage 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Simulate 2026\Roamer.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Simulate 2026\Roamer.exe') + ) | Where-Object { $_ } + + foreach ($path in $wellKnownPaths) { + $candidates.Add($path) + } + + foreach ($candidate in $candidates | Select-Object -Unique) { + if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + + throw '找不到 Roamer.exe。请确认 Navisworks Manage 2026 已安装。' +} + +function Resolve-DefaultModelPath { + if (-not (Test-Path $defaultsFilePath)) { + return $null + } + + try { + $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json + $defaultModelPath = [string]$defaults.defaultModelPath + if ([string]::IsNullOrWhiteSpace($defaultModelPath)) { + return $null + } + + $resolvedPath = [System.IO.Path]::GetFullPath($defaultModelPath) + if (-not (Test-Path $resolvedPath)) { + throw "默认模型文件不存在: $resolvedPath" + } + + return $resolvedPath + } + catch { + throw "读取默认测试配置失败: $($_.Exception.Message)" + } +} + +function Dismiss-RecoveryPromptIfPresent { + param( + [int]$TimeoutSeconds = 20 + ) + + Add-Type -AssemblyName System.Windows.Forms | Out-Null + $shell = New-Object -ComObject WScript.Shell + $windowTitles = @( + '是否从自动保存重新载入?', + '是否从自动保存重新载入?' + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + foreach ($windowTitle in $windowTitles) { + try { + if ($shell.AppActivate($windowTitle)) { + Start-Sleep -Milliseconds 300 + [System.Windows.Forms.SendKeys]::SendWait('%N') + Write-Host '已自动选择“否”以跳过自动保存恢复窗口。' + return + } + } + catch { + } + } + + Start-Sleep -Milliseconds 500 + } +} + +if ([string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = Resolve-DefaultModelPath +} + +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $ModelPath = [System.IO.Path]::GetFullPath($ModelPath) + if (-not (Test-Path $ModelPath)) { + throw "模型文件不存在: $ModelPath" + } +} + +$roamerPath = Resolve-RoamerPath +$existingProcess = Get-Process Roamer -ErrorAction SilentlyContinue | Select-Object -First 1 +$env:TRANSPORTPLUGIN_TEST_AUTOMATION = '1' + +if ($existingProcess) { + Write-Host ("Navisworks 已在运行,PID={0}" -f $existingProcess.Id) + if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + Start-Process -FilePath $roamerPath -ArgumentList @($ModelPath) | Out-Null + Write-Host ("已请求 Navisworks 打开模型: {0}" -f $ModelPath) + } + + Dismiss-RecoveryPromptIfPresent + + Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。' + exit 0 +} + +$arguments = @() +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + $arguments += $ModelPath +} + +$startProcessParams = @{ + FilePath = $roamerPath + PassThru = $true +} + +if ($arguments.Count -gt 0) { + $startProcessParams.ArgumentList = $arguments +} + +$process = Start-Process @startProcessParams +Write-Host ("Navisworks 启动中,PID={0}" -f $process.Id) + +$deadline = (Get-Date).AddSeconds(30) +do { + Start-Sleep -Milliseconds 500 + $running = Get-Process -Id $process.Id -ErrorAction SilentlyContinue +} while (-not $running -and (Get-Date) -lt $deadline) + +if (-not $running) { + throw 'Navisworks 启动超时,30 秒内未检测到 Roamer.exe 进程。' +} + +Dismiss-RecoveryPromptIfPresent + +Write-Host ("Navisworks 已启动: {0}" -f $roamerPath) +if (-not [string]::IsNullOrWhiteSpace($ModelPath)) { + Write-Host ("已打开模型参数: {0}" -f $ModelPath) +} + +Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。' diff --git a/scripts/test-automation.defaults.json b/scripts/test-automation.defaults.json new file mode 100644 index 0000000..b5d5620 --- /dev/null +++ b/scripts/test-automation.defaults.json @@ -0,0 +1,4 @@ +{ + "defaultModelPath": "C:\\Users\\Tellme\\Documents\\NavisworksTransport\\模型\\Floor2_mobile_yup.nwd", + "defaultRouteNames": [] +} diff --git a/src/Core/MainPlugin.cs b/src/Core/MainPlugin.cs index 32a3607..eb5e92d 100644 --- a/src/Core/MainPlugin.cs +++ b/src/Core/MainPlugin.cs @@ -7,6 +7,7 @@ using System.Windows.Forms.Integration; using NavisworksTransport.Core; using NavisworksTransport.Core.Animation; using NavisworksTransport.Core.Config; +using NavisworksTransport.Core.Services; using NavisworksTransport.Utils; using NavisworksTransport.Utils.CoordinateSystem; using NavisApplication = Autodesk.Navisworks.Api.Application; @@ -381,6 +382,9 @@ namespace NavisworksTransport // 订阅文档事件 SubscribeToDocumentEvents(); + + // 启动本地测试自动化 HTTP 控制面 + TestAutomationHttpService.Instance.Start(); // 检查是否已有活动文档且包含模型 var activeDoc = NavisApplication.ActiveDocument; @@ -412,6 +416,9 @@ namespace NavisworksTransport // 清理管理器 CleanupManagers(); + + // 停止本地测试自动化 HTTP 控制面 + TestAutomationHttpService.Instance.Stop(); base.OnUnloading(); } diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs new file mode 100644 index 0000000..ece2ce5 --- /dev/null +++ b/src/Core/Services/TestAutomationHttpService.cs @@ -0,0 +1,1296 @@ +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.UI.WPF.ViewModels; +using NavisworksTransport.Utils; +using Newtonsoft.Json; + +namespace NavisworksTransport.Core.Services +{ + /// + /// 本地测试自动化 HTTP 控制面。 + /// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。 + /// + public sealed class TestAutomationHttpService : IDisposable + { + private const int DefaultPort = 18777; + private const string DefaultHost = "127.0.0.1"; + private const string DefaultAutoTestRoutePrefix = "自动测试_"; + + private static readonly Lazy _instance = + new Lazy(() => new TestAutomationHttpService()); + + private readonly object _syncRoot = new object(); + private static int _autoConfirmCollisionAnalysisDialogRequestCount; + private static int _autoChooseCreateNewDetectionRecordRequestCount; + + private TcpListener _listener; + private CancellationTokenSource _cts; + private Task _acceptLoopTask; + private DateTime _startedAtUtc; + private string _lastStartError; + + private TestAutomationHttpService() + { + } + + public static TestAutomationHttpService Instance => _instance.Value; + + public bool IsRunning + { + get + { + lock (_syncRoot) + { + return _listener != null; + } + } + } + + public int Port => DefaultPort; + + public string BaseUrl => $"http://{DefaultHost}:{DefaultPort}"; + + public static bool ShouldAutoConfirmCollisionAnalysisDialogs => + Interlocked.CompareExchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0, 0) > 0; + + public static bool ShouldAutoChooseCreateNewDetectionRecord => + Interlocked.CompareExchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0, 0) > 0; + + public void Start() + { + lock (_syncRoot) + { + if (_listener != null) + { + return; + } + + try + { + _cts = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Loopback, DefaultPort); + _listener.Start(); + _startedAtUtc = DateTime.UtcNow; + _lastStartError = null; + _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); + + LogManager.Info($"[测试HTTP] 服务已启动: {BaseUrl}"); + } + catch (Exception ex) + { + _lastStartError = ex.Message; + _listener = null; + _cts?.Dispose(); + _cts = null; + _acceptLoopTask = null; + LogManager.Error($"[测试HTTP] 服务启动失败: {ex.Message}", ex); + } + } + } + + public void Stop() + { + Task acceptLoopTask = null; + + lock (_syncRoot) + { + if (_listener == null) + { + return; + } + + try + { + _cts?.Cancel(); + _listener.Stop(); + acceptLoopTask = _acceptLoopTask; + } + catch (Exception ex) + { + LogManager.Warning($"[测试HTTP] 服务停止时出现警告: {ex.Message}"); + } + finally + { + _listener = null; + _acceptLoopTask = null; + _cts?.Dispose(); + _cts = null; + } + } + + try + { + acceptLoopTask?.Wait(1000); + } + catch + { + // 忽略退出等待异常 + } + + LogManager.Info("[测试HTTP] 服务已停止"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client = null; + try + { + client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + LogManager.Error($"[测试HTTP] 接收请求失败: {ex.Message}", ex); + client?.Dispose(); + } + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + using (client) + using (var stream = client.GetStream()) + using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, true) { NewLine = "\r\n", AutoFlush = true }) + { + try + { + string requestLine = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(requestLine)) + { + return; + } + + string[] requestLineParts = requestLine.Split(' '); + if (requestLineParts.Length < 2) + { + await WriteJsonResponseAsync(writer, 400, BuildEnvelope(false, error: "Invalid request line")).ConfigureAwait(false); + return; + } + + string method = requestLineParts[0].Trim().ToUpperInvariant(); + string requestTarget = requestLineParts[1].Trim(); + var request = ParseRequestTarget(requestTarget); + + string headerLine; + while (!string.IsNullOrEmpty(headerLine = await reader.ReadLineAsync().ConfigureAwait(false))) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/ping", StringComparison.OrdinalIgnoreCase)) + { + var payload = new + { + ok = true, + service = "NavisworksTransport.TestAutomation", + protocol = "http", + version = 1, + baseUrl = BaseUrl, + serverTimeUtc = DateTime.UtcNow.ToString("o") + }; + await WriteJsonResponseAsync(writer, 200, payload).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildStatusPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/routes", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildRoutesPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/selection", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(BuildSelectionPayload); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/export-debug-snapshot", StringComparison.OrdinalIgnoreCase)) + { + object snapshotPayload = InvokeOnUiThread(BuildDebugSnapshotPayload); + string snapshotFilePath = WriteSnapshotToDisk(snapshotPayload); + var payload = new + { + snapshotFilePath, + snapshot = snapshotPayload + }; + + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-route", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectRoutePayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-default-route", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectDefaultRoutePayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/select-animated-object", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => SelectAnimatedObjectPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/run-ground-collision-test", StringComparison.OrdinalIgnoreCase)) + { + object payload = await RunVirtualCollisionTestAsync(request.Query, PathType.Ground).ConfigureAwait(false); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/run-virtual-collision-test", StringComparison.OrdinalIgnoreCase)) + { + object payload = await RunVirtualCollisionTestAsync(request.Query, null).ConfigureAwait(false); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + + if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + !string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase)) + { + await WriteJsonResponseAsync(writer, 405, BuildEnvelope(false, error: "Only GET and POST are supported in the current test API")).ConfigureAwait(false); + return; + } + + await WriteJsonResponseAsync(writer, 404, BuildEnvelope(false, error: $"Unknown endpoint: {request.Path}")).ConfigureAwait(false); + } + catch (Exception ex) + { + LogManager.Error($"[测试HTTP] 处理请求失败: {ex.Message}", ex); + await WriteJsonResponseAsync(writer, 500, BuildEnvelope(false, error: ex.Message)).ConfigureAwait(false); + } + } + } + + private object BuildStatusPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + + PathRoute currentRoute = pathManager?.CurrentRoute; + List allRoutes = pathManager?.GetAllRoutes(); + + return new + { + service = new + { + name = "NavisworksTransport.TestAutomation", + protocol = "http", + version = 1, + isRunning = IsRunning, + baseUrl = BaseUrl, + port = Port, + startedAtUtc = _startedAtUtc == default(DateTime) ? null : _startedAtUtc.ToString("o"), + lastStartError = _lastStartError + }, + environment = new + { + processId = System.Diagnostics.Process.GetCurrentProcess().Id, + machineName = Environment.MachineName, + logFilePath = LogManager.LogFilePath + }, + document = new + { + hasActiveDocument = activeDocument != null, + fileName = activeDocument?.FileName, + title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), + modelCount = activeDocument?.Models?.Count ?? 0 + }, + pathManager = new + { + isAvailable = pathManager != null, + routeCount = allRoutes?.Count ?? 0, + currentRouteId = currentRoute?.Id, + currentRouteName = currentRoute?.Name, + currentRoutePathType = currentRoute?.PathType.ToString(), + currentRoutePointCount = currentRoute?.Points?.Count ?? 0 + }, + animation = new + { + isAvailable = animationManager != null, + currentState = animationManager?.CurrentState.ToString(), + isAnimating = animationManager?.IsAnimating ?? false, + currentFrame = animationManager?.CurrentFrame ?? 0, + totalFrames = animationManager?.TotalFrames ?? 0, + hasTrackedRotation = animationManager?.HasTrackedRotation ?? false, + currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI + } + }; + } + + private object BuildDebugSnapshotPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + + PathRoute currentRoute = pathManager?.CurrentRoute; + ModelItem controlledObject = ResolveControlledObject(animationManager); + var trackedState = ResolveTrackedState(animationManager, controlledObject); + var geometryState = ResolveGeometryState(controlledObject); + var boundingBoxState = ResolveBoundingBoxState(controlledObject); + + return new + { + snapshotVersion = 1, + capturedAtUtc = DateTime.UtcNow.ToString("o"), + document = new + { + hasActiveDocument = activeDocument != null, + fileName = activeDocument?.FileName, + title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName), + modelCount = activeDocument?.Models?.Count ?? 0 + }, + route = new + { + isAvailable = currentRoute != null, + id = currentRoute?.Id, + name = currentRoute?.Name, + pathType = currentRoute?.PathType.ToString(), + pointCount = currentRoute?.Points?.Count ?? 0 + }, + animation = new + { + isAvailable = animationManager != null, + currentState = animationManager?.CurrentState.ToString(), + isAnimating = animationManager?.IsAnimating ?? false, + currentFrame = animationManager?.CurrentFrame ?? 0, + totalFrames = animationManager?.TotalFrames ?? 0, + currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI, + hasTrackedRotation = animationManager?.HasTrackedRotation ?? false + }, + controlledObject = new + { + exists = controlledObject != null, + displayName = controlledObject?.DisplayName, + instanceGuid = controlledObject == null ? null : controlledObject.InstanceGuid.ToString(), + isVirtualObject = controlledObject != null && + VirtualObjectManager.Instance.IsVirtualObjectActive && + ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, controlledObject), + trackedState, + geometryState, + boundingBox = boundingBoxState + }, + environment = new + { + processId = System.Diagnostics.Process.GetCurrentProcess().Id, + machineName = Environment.MachineName, + logFilePath = LogManager.LogFilePath + } + }; + } + + private object BuildRoutesPayload() + { + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + List routes = pathManager?.GetAllRoutes() ?? new List(); + PathRoute currentRoute = pathManager?.CurrentRoute; + + return new + { + isAvailable = pathManager != null, + currentRouteId = currentRoute?.Id, + currentRouteName = currentRoute?.Name, + currentRoutePathType = currentRoute?.PathType.ToString(), + totalRouteCount = routes.Count, + routes = routes.Select(route => SerializeRoute(route, currentRoute)).ToList(), + suggestedAutoTestRoutes = new + { + ground = TryFindAutoTestRoute(routes, PathType.Ground)?.Name, + hoisting = TryFindAutoTestRoute(routes, PathType.Hoisting)?.Name, + rail = TryFindAutoTestRoute(routes, PathType.Rail)?.Name + } + }; + } + + private object BuildSelectionPayload() + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + var selectedItems = activeDocument?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() + ?? new List(); + + return new + { + hasActiveDocument = activeDocument != null, + selectionCount = selectedItems.Count, + selectedItems = selectedItems.Select(SerializeModelItem).ToList() + }; + } + + private object SelectRoutePayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + string routeName = GetRequiredQueryValue(query, "name"); + List routes = pathManager.GetAllRoutes() ?? new List(); + + PathRoute selectedRoute = routes.FirstOrDefault(route => + string.Equals(route.Name, routeName, StringComparison.OrdinalIgnoreCase)); + if (selectedRoute == null) + { + throw new InvalidOperationException($"找不到指定路径: {routeName}"); + } + + pathManager.SetCurrentRoute(selectedRoute); + LogManager.Info($"[测试HTTP] 已切换当前路径: {selectedRoute.Name} ({selectedRoute.PathType})"); + + return new + { + selected = SerializeRoute(selectedRoute, selectedRoute), + totalRouteCount = routes.Count + }; + } + + private object SelectDefaultRoutePayload(Dictionary query) + { + PathPlanningManager pathManager = RequirePathManager(); + string pathTypeValue = GetRequiredQueryValue(query, "pathType"); + string prefix = GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix; + + if (!Enum.TryParse(pathTypeValue, true, out PathType pathType)) + { + throw new InvalidOperationException($"无法解析路径类型: {pathTypeValue}"); + } + + List routes = pathManager.GetAllRoutes() ?? new List(); + PathRoute selectedRoute = routes + .FirstOrDefault(route => + route.PathType == pathType && + !string.IsNullOrWhiteSpace(route.Name) && + route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + if (selectedRoute == null) + { + throw new InvalidOperationException( + $"找不到默认测试路径: prefix={prefix}, pathType={pathType}"); + } + + pathManager.SetCurrentRoute(selectedRoute); + LogManager.Info($"[测试HTTP] 已切换默认测试路径: {selectedRoute.Name} ({selectedRoute.PathType})"); + + return new + { + selectionRule = new + { + prefix, + pathType = pathType.ToString() + }, + selected = SerializeRoute(selectedRoute, selectedRoute) + }; + } + + private object SelectAnimatedObjectPayload(Dictionary query) + { + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + PathPlanningManager pathManager = RequirePathManager(); + animationViewModel.SetPathPlanningManager(pathManager); + + ModelItem animatedObject = ResolveAnimatedObjectFromQuery(query); + SelectDocumentItem(animatedObject); + + if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null)) + { + throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令"); + } + + animationViewModel.UseVirtualObject = false; + animationViewModel.SelectAnimatedObjectCommand.Execute(null); + + if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject)) + { + throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); + } + + return new + { + selectedAnimatedObject = SerializeModelItem(animatedObject), + canGenerateAnimation = animationViewModel.CanGenerateAnimation, + isManualCollisionTargetEnabled = animationViewModel.IsManualCollisionTargetEnabled + }; + } + + private async Task RunVirtualCollisionTestAsync(Dictionary query, PathType? fixedPathType) + { + using (EnableAutoConfirmCollisionAnalysisDialogs()) + using (EnableAutoChooseCreateNewDetectionRecord()) + { + int timeoutSeconds = ParseTimeoutSeconds(query, 180); + DateTime deadlineUtc = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + var setupResult = InvokeOnUiThread(() => PrepareVirtualCollisionTest(query, fixedPathType)); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => + { + var animationManager = PathAnimationManager.GetInstance(); + return animationManager != null && + animationManager.TotalFrames > 0 && + (animationManager.CurrentState == AnimationState.Ready || + animationManager.CurrentState == AnimationState.Finished); + }), + "等待动画生成完成超时").ConfigureAwait(false); + + InvokeOnUiThread(() => + { + StartPreparedAnimation(); + return true; + }); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => PathAnimationManager.GetInstance()?.CurrentState == AnimationState.Finished), + "等待动画播放和碰撞检测完成超时").ConfigureAwait(false); + + await WaitForConditionAsync( + deadlineUtc, + () => InvokeOnUiThread(() => + { + var animationVm = RequireAnimationControlViewModel(); + return animationVm.HasGeneratedCollisionReport; + }), + "等待碰撞报告生成超时").ConfigureAwait(false); + + return InvokeOnUiThread(() => + { + AnimationControlViewModel animationVm = RequireAnimationControlViewModel(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + CollisionReportResult report = animationVm.LastGeneratedReport; + + return new + { + route = setupResult.route, + animatedObject = setupResult.animatedObject, + requestedPathType = setupResult.pathType.ToString(), + timeoutSeconds, + automation = new + { + autoConfirmedCollisionAnalysisDialog = true, + autoChooseCreateNewDetectionRecord = true + }, + animation = new + { + currentState = animationManager?.CurrentState.ToString(), + totalFrames = animationManager?.TotalFrames ?? 0, + currentFrame = animationManager?.CurrentFrame ?? 0, + detectionRecordId = animationManager?.CurrentDetectionRecordId + }, + report = report == null + ? null + : new + { + totalCollisions = report.TotalCollisions, + uniqueCollidedObjectsCount = report.UniqueCollidedObjectsCount, + pathName = report.PathName, + movingObjectInfo = report.MovingObjectInfo, + hasScreenshots = report.Screenshots != null && report.Screenshots.Count > 0, + screenshotCount = report.Screenshots?.Count ?? 0, + resultId = report.ResultId, + routeId = report.RouteId + } + }; + }); + } + } + + private static object BuildEnvelope(bool ok, object data = null, string error = null) + { + return new Dictionary + { + ["ok"] = ok, + ["data"] = data, + ["error"] = error + }; + } + + private static T InvokeOnUiThread(Func func) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + return func(); + } + + return dispatcher.Invoke(func); + } + + private static ModelItem ResolveControlledObject(PathAnimationManager animationManager) + { + if (VirtualObjectManager.Instance.IsVirtualObjectActive && + VirtualObjectManager.Instance.CurrentVirtualObject != null) + { + return VirtualObjectManager.Instance.CurrentVirtualObject; + } + + return animationManager?.AnimatedObject; + } + + private static object SerializeModelItem(ModelItem item) + { + if (item == null) + { + return null; + } + + return new + { + displayName = item.DisplayName, + instanceGuid = item.InstanceGuid.ToString() + }; + } + + private static object SerializeRoute(PathRoute route, PathRoute currentRoute) + { + return new + { + id = route?.Id, + name = route?.Name, + pathType = route?.PathType.ToString(), + pointCount = route?.Points?.Count ?? 0, + isCurrent = currentRoute != null && ReferenceEquals(route, currentRoute), + matchesAutoTestPrefix = !string.IsNullOrWhiteSpace(route?.Name) && + route.Name.StartsWith(DefaultAutoTestRoutePrefix, StringComparison.OrdinalIgnoreCase) + }; + } + + private static PathRoute TryFindAutoTestRoute(IEnumerable routes, PathType pathType) + { + return routes?.FirstOrDefault(route => + route != null && + route.PathType == pathType && + !string.IsNullOrWhiteSpace(route.Name) && + route.Name.StartsWith(DefaultAutoTestRoutePrefix, StringComparison.OrdinalIgnoreCase)); + } + + private static PathPlanningManager RequirePathManager() + { + PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager(); + if (pathManager == null) + { + throw new InvalidOperationException("PathPlanningManager 当前不可用"); + } + + return pathManager; + } + + private static string GetRequiredQueryValue(Dictionary query, string key) + { + string value = GetOptionalQueryValue(query, key); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"缺少必填参数: {key}"); + } + + return value; + } + + private static string GetOptionalQueryValue(Dictionary query, string key) + { + if (query == null || string.IsNullOrWhiteSpace(key)) + { + return null; + } + + query.TryGetValue(key, out string value); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static RequestTarget ParseRequestTarget(string requestTarget) + { + string path = requestTarget ?? "/"; + var query = new Dictionary(StringComparer.OrdinalIgnoreCase); + + int queryStartIndex = path.IndexOf('?'); + if (queryStartIndex >= 0) + { + string queryString = path.Substring(queryStartIndex + 1); + path = path.Substring(0, queryStartIndex); + + foreach (string pair in queryString.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + string[] kv = pair.Split(new[] { '=' }, 2); + string key = Uri.UnescapeDataString(kv[0] ?? string.Empty); + string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1] ?? string.Empty) : string.Empty; + if (!string.IsNullOrWhiteSpace(key)) + { + query[key] = value; + } + } + } + + return new RequestTarget + { + Path = string.IsNullOrWhiteSpace(path) ? "/" : path, + Query = query + }; + } + + private sealed class RequestTarget + { + public string Path { get; set; } + + public Dictionary Query { get; set; } + } + + private static object ResolveTrackedState(PathAnimationManager animationManager, ModelItem controlledObject) + { + if (animationManager == null || controlledObject == null || !animationManager.ControlsAnimatedObject(controlledObject)) + { + return new + { + isAvailable = false, + reason = animationManager == null ? "PathAnimationManagerUnavailable" : "ControlledObjectUnavailable" + }; + } + + var currentState = animationManager.GetObjectCurrentPosition(controlledObject); + return new + { + isAvailable = true, + position = SerializePoint3D(currentState.Position), + yawRadians = currentState.Yaw, + yawDegrees = currentState.Yaw * 180.0 / Math.PI, + hasTrackedRotation = animationManager.HasTrackedRotation, + trackedRotation = animationManager.HasTrackedRotation + ? SerializeRotation3D(animationManager.TrackedRotation) + : null + }; + } + + private static object ResolveGeometryState(ModelItem controlledObject) + { + if (controlledObject == null) + { + return new + { + isAvailable = false, + reason = "ControlledObjectUnavailable" + }; + } + + bool hasGeometryRotation = ModelItemTransformHelper.TryGetCurrentGeometryRotation(controlledObject, out Rotation3D geometryRotation); + bool hasOverrideRotation = ModelItemTransformHelper.TryGetCurrentOverrideRotation(controlledObject, out Rotation3D overrideRotation); + + return new + { + isAvailable = true, + geometryRotation = hasGeometryRotation ? SerializeRotation3D(geometryRotation) : null, + overrideRotation = hasOverrideRotation ? SerializeRotation3D(overrideRotation) : null + }; + } + + private static object ResolveBoundingBoxState(ModelItem controlledObject) + { + if (controlledObject == null) + { + return new + { + isAvailable = false, + reason = "ControlledObjectUnavailable" + }; + } + + BoundingBox3D boundingBox = controlledObject.BoundingBox(); + return new + { + isAvailable = true, + min = SerializePoint3D(boundingBox.Min), + max = SerializePoint3D(boundingBox.Max), + center = SerializePoint3D(boundingBox.Center) + }; + } + + private static object SerializePoint3D(Point3D point) + { + if (point == null) + { + return null; + } + + return new + { + x = point.X, + y = point.Y, + z = point.Z + }; + } + + private static object SerializeRotation3D(Rotation3D rotation) + { + var linear = new Transform3D(rotation).Linear; + return new + { + quaternion = new + { + x = rotation.A, + y = rotation.B, + z = rotation.C, + w = rotation.D + }, + axes = new + { + hostX = new + { + x = linear.Get(0, 0), + y = linear.Get(1, 0), + z = linear.Get(2, 0) + }, + hostY = new + { + x = linear.Get(0, 1), + y = linear.Get(1, 1), + z = linear.Get(2, 1) + }, + hostZ = new + { + x = linear.Get(0, 2), + y = linear.Get(1, 2), + z = linear.Get(2, 2) + } + } + }; + } + + private static string WriteSnapshotToDisk(object snapshotPayload) + { + string logDirectory = Path.GetDirectoryName(LogManager.LogFilePath); + string snapshotDirectory = Path.Combine(logDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "test-automation"); + Directory.CreateDirectory(snapshotDirectory); + + string fileName = string.Format( + CultureInfo.InvariantCulture, + "debug-snapshot-{0:yyyyMMdd-HHmmss-fff}.json", + DateTime.Now); + string fullPath = Path.Combine(snapshotDirectory, fileName); + string json = JsonConvert.SerializeObject(snapshotPayload, Formatting.Indented); + File.WriteAllText(fullPath, json, new UTF8Encoding(false)); + + LogManager.Info($"[测试HTTP] 已导出调试快照: {fullPath}"); + return fullPath; + } + + private static AnimationControlViewModel RequireAnimationControlViewModel() + { + var animationViewModel = AnimationControlViewModel.Instance; + if (animationViewModel == null) + { + throw new InvalidOperationException("AnimationControlViewModel 当前不可用,请先打开插件动画页签"); + } + + return animationViewModel; + } + + private static PreparedVirtualCollisionTest PrepareVirtualCollisionTest(Dictionary query, PathType? fixedPathType) + { + PathPlanningManager pathManager = RequirePathManager(); + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + animationViewModel.SetPathPlanningManager(pathManager); + + PathRoute route = ResolveVirtualCollisionRoute(pathManager, query, fixedPathType); + pathManager.SetCurrentRoute(route); + animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); + + animationViewModel.IsManualCollisionTargetEnabled = false; + animationViewModel.UseVirtualObject = true; + object animatedObjectPayload = new + { + mode = "VirtualObject", + displayName = "虚拟物体" + }; + + if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) + { + throw new InvalidOperationException($"{route.PathType} 虚拟物体碰撞测试准备失败:当前条件下不能生成动画"); + } + + animationViewModel.GenerateAnimationCommand.Execute(null); + + LogManager.Info( + $"[测试HTTP] 已开始准备虚拟物体碰撞测试: 路径={route.Name}, 类型={route.PathType}"); + + return new PreparedVirtualCollisionTest + { + pathType = route.PathType, + route = SerializeRoute(route, route), + animatedObject = animatedObjectPayload + }; + } + + private static void StartPreparedAnimation() + { + AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel(); + PathAnimationManager animationManager = PathAnimationManager.GetInstance(); + if (animationManager == null) + { + throw new InvalidOperationException("PathAnimationManager 当前不可用,无法开始播放"); + } + + if (animationManager.CurrentState == AnimationState.Paused) + { + animationManager.ResumeAnimation(); + LogManager.Info("[测试HTTP] 已从暂停状态恢复自动测试动画播放"); + return; + } + + if (animationManager.IsAnimating) + { + LogManager.Info("[测试HTTP] 动画已经在播放中,跳过重复开始"); + return; + } + + if (animationManager.CurrentState != AnimationState.Ready && + animationManager.CurrentState != AnimationState.Finished) + { + throw new InvalidOperationException($"动画尚未就绪,当前状态={animationManager.CurrentState}"); + } + + animationManager.ClearExclusionCache(); + ModelHighlightHelper.ClearCollisionHighlights(); + animationManager.StartAnimation(); + LogManager.Info("[测试HTTP] 已启动自动测试动画播放"); + } + + private static PathRoute ResolveVirtualCollisionRoute(PathPlanningManager pathManager, Dictionary query, PathType? fixedPathType) + { + string routeName = GetOptionalQueryValue(query, "routeName"); + string pathTypeText = fixedPathType.HasValue + ? fixedPathType.Value.ToString() + : GetOptionalQueryValue(query, "pathType"); + List routes = pathManager.GetAllRoutes() ?? new List(); + PathType pathType = fixedPathType ?? ParsePathTypeOrThrow(pathTypeText, "pathType"); + + PathRoute route = string.IsNullOrWhiteSpace(routeName) + ? TryFindAutoTestRoute(routes, pathType) + : routes.FirstOrDefault(r => string.Equals(r.Name, routeName, StringComparison.OrdinalIgnoreCase)); + + if (route == null) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(routeName) + ? $"找不到默认自动测试路径: {pathType}" + : $"找不到指定路径: {routeName}"); + } + + if (route.PathType != pathType) + { + throw new InvalidOperationException($"指定路径类型不匹配: 期望={pathType}, 实际={route.PathType}, 路径={route.Name}"); + } + + return route; + } + + private static PathType ParsePathTypeOrThrow(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"缺少必填参数: {parameterName}"); + } + + if (!Enum.TryParse(value, true, out PathType pathType)) + { + throw new InvalidOperationException($"无法解析路径类型: {value}"); + } + + return pathType; + } + + private static ModelItem ResolveAnimatedObjectFromQuery(Dictionary query) + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (activeDocument == null) + { + throw new InvalidOperationException("当前没有活动文档,无法选择真实物体"); + } + + string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName"); + if (string.IsNullOrWhiteSpace(animatedObjectName)) + { + return ResolveSingleSelectedItem(activeDocument); + } + + var matches = new List(); + foreach (Model model in activeDocument.Models) + { + if (model?.RootItem == null) + { + continue; + } + + foreach (ModelItem item in model.RootItem.DescendantsAndSelf) + { + if (item != null && string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + } + } + } + + if (matches.Count == 0) + { + throw new InvalidOperationException($"找不到指定真实物体: {animatedObjectName}"); + } + + if (matches.Count > 1) + { + throw new InvalidOperationException($"找到多个同名真实物体,请改用更唯一的名字: {animatedObjectName}"); + } + + return matches[0]; + } + + private static ModelItem ResolveSingleSelectedItem(Document activeDocument) + { + var selectedItems = activeDocument.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList() + ?? new List(); + + if (selectedItems.Count != 1) + { + throw new InvalidOperationException($"当前选择集必须且只能包含 1 个真实物体,当前数量: {selectedItems.Count}"); + } + + return selectedItems[0]; + } + + private static void SelectDocumentItem(ModelItem item) + { + Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (activeDocument?.CurrentSelection == null) + { + throw new InvalidOperationException("当前文档选择集不可用"); + } + + activeDocument.CurrentSelection.Clear(); + activeDocument.CurrentSelection.Add(item); + } + + private static PathRouteViewModel CreatePathRouteViewModel(PathRoute route) + { + var routeViewModel = new PathRouteViewModel(isFromDatabase: true) + { + Route = route, + IsActive = true + }; + + foreach (var point in route.Points.OrderBy(p => p.Index)) + { + routeViewModel.Points.Add(new PathPointViewModel + { + Id = point.Id, + Name = point.Name, + Type = point.Type, + Index = point.Index, + X = point.X, + Y = point.Y, + Z = point.Z + }); + } + + routeViewModel.SetTimeInfo(route.CreatedTime, route.LastModified); + return routeViewModel; + } + + private static int ParseTimeoutSeconds(Dictionary query, int defaultSeconds) + { + string raw = GetOptionalQueryValue(query, "timeoutSeconds"); + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultSeconds; + } + + if (!int.TryParse(raw, out int parsedSeconds) || parsedSeconds <= 0) + { + throw new InvalidOperationException($"无法解析 timeoutSeconds: {raw}"); + } + + return parsedSeconds; + } + + private static bool ParseBooleanQuery(Dictionary query, string key, bool defaultValue) + { + string raw = GetOptionalQueryValue(query, key); + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + if (bool.TryParse(raw, out bool parsed)) + { + return parsed; + } + + if (string.Equals(raw, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "y", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "n", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + throw new InvalidOperationException($"无法解析布尔参数 {key}: {raw}"); + } + + private static IDisposable EnableAutoConfirmCollisionAnalysisDialogs() + { + Interlocked.Increment(ref _autoConfirmCollisionAnalysisDialogRequestCount); + return new ActionOnDispose(() => + { + if (Interlocked.Decrement(ref _autoConfirmCollisionAnalysisDialogRequestCount) < 0) + { + Interlocked.Exchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0); + } + }); + } + + private static IDisposable EnableAutoChooseCreateNewDetectionRecord() + { + Interlocked.Increment(ref _autoChooseCreateNewDetectionRecordRequestCount); + return new ActionOnDispose(() => + { + if (Interlocked.Decrement(ref _autoChooseCreateNewDetectionRecordRequestCount) < 0) + { + Interlocked.Exchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0); + } + }); + } + + private static async Task WaitForConditionAsync(DateTime deadlineUtc, Func condition, string timeoutMessage) + { + while (DateTime.UtcNow < deadlineUtc) + { + if (condition()) + { + return; + } + + await Task.Delay(300).ConfigureAwait(false); + } + + throw new TimeoutException(timeoutMessage); + } + + private sealed class PreparedVirtualCollisionTest + { + public PathType pathType { get; set; } + + public object route { get; set; } + + public object animatedObject { get; set; } + } + + private sealed class ActionOnDispose : IDisposable + { + private Action _disposeAction; + + public ActionOnDispose(Action disposeAction) + { + _disposeAction = disposeAction; + } + + public void Dispose() + { + Action disposeAction = Interlocked.Exchange(ref _disposeAction, null); + disposeAction?.Invoke(); + } + } + + private static async Task WriteJsonResponseAsync(StreamWriter writer, int statusCode, object payload) + { + string statusText = ResolveStatusText(statusCode); + string json = JsonConvert.SerializeObject(payload, Formatting.Indented); + byte[] bodyBytes = Encoding.UTF8.GetBytes(json); + + await writer.WriteLineAsync($"HTTP/1.1 {statusCode} {statusText}").ConfigureAwait(false); + await writer.WriteLineAsync("Content-Type: application/json; charset=utf-8").ConfigureAwait(false); + await writer.WriteLineAsync($"Content-Length: {bodyBytes.Length}").ConfigureAwait(false); + await writer.WriteLineAsync("Connection: close").ConfigureAwait(false); + await writer.WriteLineAsync().ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + + Stream baseStream = writer.BaseStream; + await baseStream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false); + await baseStream.FlushAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + private static string ResolveStatusText(int statusCode) + { + switch (statusCode) + { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 404: + return "Not Found"; + case 405: + return "Method Not Allowed"; + case 500: + return "Internal Server Error"; + default: + return "OK"; + } + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/UI/WPF/ViewModels/AnimationControlViewModel.cs b/src/UI/WPF/ViewModels/AnimationControlViewModel.cs index 54b2e65..9b9ca94 100644 --- a/src/UI/WPF/ViewModels/AnimationControlViewModel.cs +++ b/src/UI/WPF/ViewModels/AnimationControlViewModel.cs @@ -15,6 +15,7 @@ using Autodesk.Navisworks.Api.Clash; using NavisworksTransport.Core; using NavisworksTransport.Core.Models; using NavisworksTransport.Core.Config; +using NavisworksTransport.Core.Services; using NavisworksTransport.Commands; using NavisworksTransport.UI.WPF.Collections; using NavisworksTransport.UI.WPF.Views; @@ -488,6 +489,14 @@ namespace NavisworksTransport.UI.WPF.ViewModels } } + /// + /// 最近一次自动生成的碰撞报告。 + /// 仅用于测试自动化读取结果,不参与业务流程分支。 + /// + public CollisionReportResult LastGeneratedReport => _lastGeneratedReport; + + public bool HasGeneratedCollisionReport => _lastGeneratedReport != null; + /// @@ -1994,11 +2003,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels if (clashResults == null || clashResults.Count == 0) { LogManager.Info("🎉 恭喜!本次动画未检测到任何碰撞,路径规划非常安全!"); - System.Windows.MessageBox.Show( + DialogHelper.ShowAutoClosingMessageBox( "🎉 恭喜!本次动画仿真过程中未发现任何碰撞!\n\n您的物流路径规划非常成功,物体可以安全通行。", "仿真完成 - 无碰撞", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Information); + System.Windows.MessageBoxImage.Information, + autoCloseMilliseconds: 4000); } // 统一流程:刷新历史列表并显示报告(无论有无碰撞) @@ -2928,6 +2937,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels { try { + if (DialogHelper.IsTestAutomationModeEnabled) + { + LogManager.Info("[碰撞分析] 自动测试模式下跳过预计算碰撞分析"); + SaveCollisionDetectionRecord(); + return; + } + // 1. 获取预计算碰撞结果(从PathAnimationManager,现在缓存帧中已包含碰撞结果) var allResults = _pathAnimationManager?.AllCollisionResults; if (allResults == null || allResults.Count == 0) @@ -3158,28 +3174,35 @@ namespace NavisworksTransport.UI.WPF.ViewModels } string routeId = CurrentPathRoute?.Id ?? ""; - - // 🔥 检查是否已有相同配置的检测记录 - var existingRecordResult = CheckExistingDetectionRecord(); - if (existingRecordResult.HasValue) + + if (!DialogHelper.IsTestAutomationModeEnabled) { - var existingId = existingRecordResult.Value.id; - var userChoice = existingRecordResult.Value.userChoice; - - if (userChoice == UserChoice.UseExisting) + // 🔥 检查是否已有相同配置的检测记录 + var existingRecordResult = CheckExistingDetectionRecord(); + if (existingRecordResult.HasValue) { - // 用户选择使用历史记录 - LogManager.Info($"[检测记录] 用户选择使用历史记录 (Id={existingId})"); - _pathAnimationManager.CurrentDetectionRecordId = existingId; - _pathAnimationManager.IsUsingHistoryRecord = true; // 标记使用历史记录,动画完成后跳过ClashDetective - - // 在历史列表中选中该记录 - SelectHistoryRecordById(existingId); - - return existingId; + var existingId = existingRecordResult.Value.id; + var userChoice = existingRecordResult.Value.userChoice; + + if (userChoice == UserChoice.UseExisting) + { + // 用户选择使用历史记录 + LogManager.Info($"[检测记录] 用户选择使用历史记录 (Id={existingId})"); + _pathAnimationManager.CurrentDetectionRecordId = existingId; + _pathAnimationManager.IsUsingHistoryRecord = true; // 标记使用历史记录,动画完成后跳过ClashDetective + + // 在历史列表中选中该记录 + SelectHistoryRecordById(existingId); + + return existingId; + } + // 用户选择重新生成,继续创建新记录 + LogManager.Info($"[检测记录] 用户选择重新生成,创建新记录"); } - // 用户选择重新生成,继续创建新记录 - LogManager.Info($"[检测记录] 用户选择重新生成,创建新记录"); + } + else + { + LogManager.Info("[检测记录] 自动测试模式下跳过重复检测记录检查,直接创建新记录"); } int recordId = CreateCollisionDetectionRecordSnapshot("动画生成", bindToAnimationManager: true); @@ -3445,6 +3468,13 @@ namespace NavisworksTransport.UI.WPF.ViewModels /// private (int id, UserChoice userChoice)? ShowDuplicateConfigDialog(CollisionDetectionRecord record) { + if (TestAutomationHttpService.ShouldAutoChooseCreateNewDetectionRecord) + { + _lastReusedRecordId = null; + LogManager.Info($"[检测记录] 测试自动化已接管重复配置对话框,自动选择重新生成新记录 (ExistingId={record.Id})"); + return (record.Id, UserChoice.CreateNew); + } + var message = $"检测配置已存在!\n\n" + $"找到一条相同配置的历史检测记录:\n" + $"• 测试名称:{record.TestName ?? "未命名"}\n" + diff --git a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs index 2c61b0d..e873b06 100644 --- a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs +++ b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs @@ -551,11 +551,11 @@ namespace NavisworksTransport.UI.WPF.ViewModels _documentRotationHintShown.Add(documentKey); _documentRotationHintPending.Remove(documentKey); LogManager.Info($"[模型整体旋转提示] {hintMessage}"); - System.Windows.MessageBox.Show( + DialogHelper.ShowAutoClosingMessageBox( hintMessage, "坐标系提示", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Information); + System.Windows.MessageBoxImage.Information, + autoCloseMilliseconds: 4000); } catch (Exception ex) { diff --git a/src/UI/WPF/Views/CollisionAnalysisDialog.xaml.cs b/src/UI/WPF/Views/CollisionAnalysisDialog.xaml.cs index a76f603..2096cba 100644 --- a/src/UI/WPF/Views/CollisionAnalysisDialog.xaml.cs +++ b/src/UI/WPF/Views/CollisionAnalysisDialog.xaml.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; +using System.Windows.Threading; using System.Windows.Media; using Autodesk.Navisworks.Api; +using NavisworksTransport.Core.Services; using NavisworksTransport.UI.WPF.ViewModels; using NavisworksTransport.Utils; @@ -34,6 +36,7 @@ namespace NavisworksTransport.UI.WPF.Views { InitializeComponent(); ExcludedObjects = new List(); + Loaded += CollisionAnalysisDialog_Loaded; // 更新统计信息 UpdateStatsText(hotspots.Count, totalCollisions); @@ -62,6 +65,27 @@ namespace NavisworksTransport.UI.WPF.Views LogManager.Info($"[碰撞分析] 自动选中 {autoSelectedCount}/{viewModels.Count} 个高频碰撞物体(>100次或>50%)"); } + private void CollisionAnalysisDialog_Loaded(object sender, RoutedEventArgs e) + { + if (!TestAutomationHttpService.ShouldAutoConfirmCollisionAnalysisDialogs) + { + return; + } + + Dispatcher.BeginInvoke(new Action(() => + { + if (!IsLoaded || !IsVisible) + { + return; + } + + LogManager.Info("[碰撞分析] 测试自动化已接管分析窗口,自动选择继续生成"); + ModelHighlightHelper.ClearCategory(PrecomputedHighlightCategory); + SelectedAction = CollisionAnalysisAction.Continue; + Close(); + }), DispatcherPriority.Background); + } + /// /// 更新统计文本 /// diff --git a/src/Utils/DialogHelper.cs b/src/Utils/DialogHelper.cs index 9f9f762..39f8ec8 100644 --- a/src/Utils/DialogHelper.cs +++ b/src/Utils/DialogHelper.cs @@ -2,6 +2,7 @@ using System; using System.Windows; using System.Windows.Controls; using System.Windows.Interop; +using System.Windows.Threading; using Autodesk.Navisworks.Api; namespace NavisworksTransport.Utils @@ -11,6 +12,14 @@ namespace NavisworksTransport.Utils /// public static class DialogHelper { + private const string TestAutomationEnvironmentVariable = "TRANSPORTPLUGIN_TEST_AUTOMATION"; + + public static bool IsTestAutomationModeEnabled => + string.Equals( + Environment.GetEnvironmentVariable(TestAutomationEnvironmentVariable), + "1", + StringComparison.OrdinalIgnoreCase); + /// /// 从 UserControl 获取父窗口并设置为对话框 Owner /// @@ -162,6 +171,159 @@ namespace NavisworksTransport.Utils return MessageBox.Show(messageBoxText, caption, button, icon); } + /// + /// 显示自动关闭的提示窗口,避免在自动化场景中阻塞流程。 + /// + public static void ShowAutoClosingMessageBox( + string messageBoxText, + string caption, + MessageBoxImage icon = MessageBoxImage.Information, + int autoCloseMilliseconds = 4000) + { + if (IsTestAutomationModeEnabled) + { + LogManager.Info($"[DialogHelper] 自动测试模式下跳过自动关闭提示框: {caption}"); + return; + } + + var owner = FindOwnerWindow(); + + var window = new Window + { + Title = caption, + Width = 460, + SizeToContent = SizeToContent.Height, + ResizeMode = ResizeMode.NoResize, + WindowStartupLocation = owner != null ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen, + ShowInTaskbar = false, + Topmost = true, + Content = BuildAutoClosingDialogContent(messageBoxText, icon, autoCloseMilliseconds) + }; + + if (owner != null) + { + try + { + window.Owner = owner; + } + catch (InvalidOperationException ex) + { + LogManager.Warning($"[DialogHelper] 设置自动关闭提示框 Owner 失败: {ex.Message}"); + } + } + else + { + SetWin32Owner(window); + } + + var closeTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(Math.Max(500, autoCloseMilliseconds)) + }; + closeTimer.Tick += (_, __) => + { + closeTimer.Stop(); + if (window.IsVisible) + { + window.Close(); + } + }; + + window.Closed += (_, __) => closeTimer.Stop(); + closeTimer.Start(); + window.Show(); + } + + private static FrameworkElement BuildAutoClosingDialogContent( + string messageBoxText, + MessageBoxImage icon, + int autoCloseMilliseconds) + { + string iconGlyph; + switch (icon) + { + case MessageBoxImage.Warning: + iconGlyph = "!"; + break; + case MessageBoxImage.Error: + iconGlyph = "X"; + break; + case MessageBoxImage.Question: + iconGlyph = "?"; + break; + default: + iconGlyph = "i"; + break; + } + + var root = new Grid + { + Margin = new Thickness(18) + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var iconText = new TextBlock + { + Text = iconGlyph, + FontSize = 22, + FontWeight = FontWeights.Bold, + Margin = new Thickness(0, 0, 14, 0), + VerticalAlignment = VerticalAlignment.Top + }; + Grid.SetColumn(iconText, 0); + Grid.SetRow(iconText, 0); + root.Children.Add(iconText); + + var messageText = new TextBlock + { + Text = messageBoxText, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0), + MinWidth = 320 + }; + Grid.SetColumn(messageText, 1); + Grid.SetRow(messageText, 0); + root.Children.Add(messageText); + + var footerPanel = new DockPanel + { + Margin = new Thickness(0, 16, 0, 0), + LastChildFill = false + }; + Grid.SetColumn(footerPanel, 0); + Grid.SetColumnSpan(footerPanel, 2); + Grid.SetRow(footerPanel, 1); + + var autoCloseHint = new TextBlock + { + Text = $"此提示将在 {Math.Max(1, autoCloseMilliseconds / 1000)} 秒后自动关闭。", + VerticalAlignment = VerticalAlignment.Center + }; + DockPanel.SetDock(autoCloseHint, Dock.Left); + footerPanel.Children.Add(autoCloseHint); + + var closeButton = new Button + { + Content = "确定", + MinWidth = 72, + Padding = new Thickness(14, 4, 14, 4), + Margin = new Thickness(16, 0, 0, 0) + }; + closeButton.Click += (_, __) => + { + Window parentWindow = Window.GetWindow(closeButton); + parentWindow?.Close(); + }; + DockPanel.SetDock(closeButton, Dock.Right); + footerPanel.Children.Add(closeButton); + + root.Children.Add(footerPanel); + return root; + } + /// /// 获取 Navisworks 主窗口句柄(Win32) /// diff --git a/start-navisworks.bat b/start-navisworks.bat new file mode 100644 index 0000000..fcffa3c --- /dev/null +++ b/start-navisworks.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start-navisworks.ps1" %* + +if errorlevel 1 exit /b 1 +exit /b 0