diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 231d1dd..b48a270 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -10,10 +10,23 @@ namespace PRecAnnot.Plant3D2026; public sealed class EntryPoint : IExtensionApplication { + private static bool automationQueued; public void Initialize() { RuntimeDiagnostics.Write("AssemblyInitializeStarted"); RibbonIntegration.Register(); + if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) && !automationQueued) + { + try + { + automationQueued = true; + FirstLimitedApplyAutomationBootstrap.Prepare(); + } + catch (System.Exception exception) + { + RuntimeDiagnostics.Write("AutomationBootstrapPreparationFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception); + } + } RuntimeDiagnostics.Write("AssemblyInitializeCompleted"); } @@ -33,6 +46,9 @@ public sealed class EntryPoint : IExtensionApplication [CommandMethod("PXP-OA")] public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA"); + [CommandMethod("PHP_REC_AUTOMATION_START", CommandFlags.Session)] + public void AutomationStartCommand() => FirstLimitedApplyAutomationBootstrap.Arm(); + private static void RunDiagnostic(string commandName) { var operationId = Guid.NewGuid().ToString("N"); @@ -80,6 +96,8 @@ public sealed class EntryPoint : IExtensionApplication var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null ? FirstLimitedApplyPermitReader.ReadFor(document) : null; + var automationTarget = FirstLimitedApplyAutomationCoordinator.SelectedTargetSnapshotId; + var limitedTarget = applyPermit?.TargetSnapshotId ?? automationTarget; if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1) throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT"); @@ -95,8 +113,8 @@ public sealed class EntryPoint : IExtensionApplication CommandName = commandName, Viewport = viewport, Objects = objects, - LimitedApplyTargetSnapshotId = applyPermit?.TargetSnapshotId, - LimitedApplyRuleId = applyPermit is null ? null : FirstLimitedApplyPlanner.RuleId + LimitedApplyTargetSnapshotId = limitedTarget, + LimitedApplyRuleId = limitedTarget is null ? null : FirstLimitedApplyPlanner.RuleId })); } @@ -141,6 +159,13 @@ public sealed class EntryPoint : IExtensionApplication details["PlanConflictCount"] = summary.ConflictCount; details["PlanUnsupportedCount"] = summary.UnsupportedCount; details["ErrorCount"] = summary.ErrorCount; + if (string.Equals(commandName, "PXP-OA", StringComparison.OrdinalIgnoreCase)) + { + var candidates = objectsByViewport.SelectMany(x => x) + .Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null) + .Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null); + } if (mode == PRecAnnotExecutionMode.Apply) { var corePermit = new FirstLimitedApplyPermit diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs new file mode 100644 index 0000000..49a65a4 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using Autodesk.AutoCAD.ApplicationServices; +using Autodesk.AutoCAD.DatabaseServices; +using Autodesk.AutoCAD.Runtime; +using Autodesk.ProcessPower.PlantInstance; +using Autodesk.ProcessPower.ProjectManager; +using PRecAnnot.Abstractions; +using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application; + +namespace PRecAnnot.Plant3D2026; + +// Test-only coordinator. It is not registered in Ribbon and is inert unless +// the explicit NextPilot automation environment and local marker are present. +internal sealed record FirstApplyAutomationConfiguration +{ + public required string Phase { get; init; } + public string? OrthoDrawingPath { get; init; } + public string? ModelDrawingPath { get; init; } + public required string EvidencePath { get; init; } + public required string FixtureMarkerPath { get; init; } +} + +internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned); + +internal static class FirstLimitedApplyAutomationBootstrap +{ + private enum BootstrapState { Created, Prepared, Armed, Starting, Completed } + private static BootstrapState state; + private static bool observedIdle; + private static int idleCallbackCount; + private static int coordinatorStartCount; + + internal static int IdleCallbackCount => idleCallbackCount; + internal static int CoordinatorStartCount => coordinatorStartCount; + + // Called from Initialize: BCL gates and one lightweight subscription only. + internal static void Prepare() + { + if (state != BootstrapState.Created) return; + state = BootstrapState.Prepared; + AcadApplication.Idle += OnIdle; + } + + // Public test command is intentionally side-effect free with respect to Plant APIs. + internal static void Arm() + { + if (state is BootstrapState.Prepared) state = BootstrapState.Armed; + } + + private static void OnIdle(object? sender, EventArgs args) + { + try + { + idleCallbackCount++; + // The script engine can consume the custom command after NETLOAD. The + // first idle is therefore the explicit post-NETLOAD arming point. + if (state == BootstrapState.Prepared) { observedIdle = true; state = BootstrapState.Armed; } + if (!observedIdle || state != BootstrapState.Armed) return; + state = BootstrapState.Starting; + AcadApplication.Idle -= OnIdle; + coordinatorStartCount++; + new FirstLimitedApplyAutomationCoordinator().Start(); + } + catch (System.Exception exception) + { + state = BootstrapState.Completed; + AcadApplication.Idle -= OnIdle; + FirstLimitedApplyAutomationCoordinator.WriteBootstrapFailure(exception); + } + } +} +internal sealed record FirstApplyAutomationEvidence +{ + public int SchemaVersion { get; init; } = 1; + public required string Phase { get; init; } + public bool Completed { get; init; } + public string? ErrorCode { get; init; } + public int? DbmodBefore { get; init; } + public int? DbmodAfterCommand1 { get; init; } + public int? DbmodAfterCommand2 { get; init; } + public int? DbmodAfterUndo { get; init; } + public AutomationEntityCounts? Baseline { get; init; } + public AutomationEntityCounts? AfterCommand1 { get; init; } + public AutomationEntityCounts? AfterCommand2 { get; init; } + public AutomationEntityCounts? AfterUndo { get; init; } + public IReadOnlyList CompletedCommands { get; init; } = Array.Empty(); + public string? PlanIdRun1 { get; init; } + public string? PlanIdRun2 { get; init; } + public bool? PlanIdsEqual { get; init; } + public int? EligibleCandidateCount { get; init; } + public string? SelectedCandidateHash { get; init; } + public string? ViewHash { get; init; } + public int? CreateRun1 { get; init; } + public int? CreateRun2 { get; init; } + public int? SkipRun2 { get; init; } + public bool? PermitAccepted { get; init; } + public int? IdleCallbackCount { get; init; } + public int? CoordinatorStartCount { get; init; } + public IReadOnlyList StateTransitions { get; init; } = Array.Empty(); +} + +internal sealed class FirstLimitedApplyAutomationCoordinator +{ + private sealed record PxpResult(string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, bool? PermitAccepted); + private static string? selectedTargetSnapshotId; + private static readonly List pxpResults = []; + internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId; + internal static void WriteBootstrapFailure(System.Exception exception) => + RuntimeDiagnostics.Write("AutomationBootstrapFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception); + internal static void RecordPxpOa(string? planId, IReadOnlyList candidates, string? view, int create, int skip, bool? permitAccepted) + { + if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) + pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted)); + } + private FirstApplyAutomationConfiguration config = null!; + private Document document = null!; + private readonly List completed = []; + private int step; + private int? before, after1, after2, afterUndo; + private AutomationEntityCounts? baseline, counts1, counts2, countsUndo; + private readonly List transitions = ["Created"]; + + public void Start() + { + try + { + config = ReadConfiguration(); + transitions.Add("PostStartupReady"); + if (string.Equals(config.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase)) + { + CompleteLifecycleProbe(); + return; + } + if (string.IsNullOrWhiteSpace(config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"); + transitions.Add("ProjectActivationStarted"); + var projectXml = Path.Combine(Path.GetDirectoryName(config.FixtureMarkerPath)!, "Project.xml"); + var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty); + PlantApplication.SetCurrentProject(project, true); + transitions.Add("ProjectActivated"); + if (!string.IsNullOrWhiteSpace(config.ModelDrawingPath)) AcadApplication.DocumentManager.Open(config.ModelDrawingPath, false); + transitions.Add("DocumentOpenStarted"); + document = AcadApplication.DocumentManager.Open(config.OrthoDrawingPath, false); + AcadApplication.DocumentManager.MdiActiveDocument = document; + transitions.Add("DocumentOpened"); + document.CommandEnded += OnCommandEnded; + document.CommandFailed += OnCommandFailed; + document.CommandCancelled += OnCommandCancelled; + AcadApplication.DocumentManager.DocumentActivated += OnDocumentActivated; + AcadApplication.Idle += OnIdle; + } + catch (System.Exception ex) { Complete(false, "AUTOMATION_START_FAILED:" + ex.GetType().Name); } + } + + private void CompleteLifecycleProbe() + { + transitions.Add("LifecycleProbeCompleted"); + WriteEvidence(true, null); + transitions.Add("ExitRequested"); + AcadApplication.Quit(); + } + + private void OnIdle(object? sender, EventArgs e) + { + BeginContextVerification(); + } + private void OnDocumentActivated(object sender, DocumentCollectionEventArgs e) + { + if (string.Equals(e.Document.Name, document.Name, StringComparison.OrdinalIgnoreCase)) BeginContextVerification(); + } + private void BeginContextVerification() + { + AcadApplication.Idle -= OnIdle; + AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated; + try + { + transitions.Add("ContextVerificationStarted"); + var context = new PlantRuntimeContextReader().ReadCurrentContext(document); + if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) { Complete(false, $"AUTOMATION_ORTHO_CONTEXT_INVALID:P={context.HasPlantProject};O={context.IsOrthoDrawing};V={context.Viewports.Count}"); return; } + var candidates = new PlantObjectReader().ReadObjects(context.Viewports.Single(), CancellationToken.None) + .Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null) + .Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + if (candidates.Length == 0) { Complete(false, "AUTOMATION_NO_ELIGIBLE_CANDIDATE"); return; } + selectedTargetSnapshotId = candidates[0]; + transitions.Add("ContextVerified"); + before = Dbmod(); baseline = Counts(); + Queue("PXP-OA"); + } + catch (System.Exception ex) { Complete(false, "AUTOMATION_CONTEXT_FAILED:" + ex.GetType().Name); } + } + + private void Queue(string command) + { + transitions.Add("CommandQueued:" + command); + document.SendStringToExecute(command + " ", true, false, false); + } + private void OnCommandEnded(object sender, CommandEventArgs e) + { + var name = e.GlobalCommandName?.Trim().TrimStart('.', '_').ToUpperInvariant(); + if (name is not ("PXP-OA" or "UNDO" or "U")) return; + completed.Add(name); + transitions.Add("CommandCompleted:" + name); + if (step++ == 0) { after1 = Dbmod(); counts1 = Counts(); Queue("PXP-OA"); return; } + if (step == 2 && string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { after2 = Dbmod(); counts2 = Counts(); Queue("_.UNDO _1"); return; } + if (string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { afterUndo = Dbmod(); countsUndo = Counts(); Complete(true, null); } + else { after2 = Dbmod(); counts2 = Counts(); Complete(true, null); } + } + private void OnCommandFailed(object sender, CommandEventArgs e) => Complete(false, "AUTOMATION_COMMAND_FAILED:" + e.GlobalCommandName); + private void OnCommandCancelled(object sender, CommandEventArgs e) => Complete(false, "AUTOMATION_COMMAND_CANCELLED:" + e.GlobalCommandName); + + private int Dbmod() => Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD")); + private AutomationEntityCounts Counts() + { + using var tx = document.Database.TransactionManager.StartOpenCloseTransaction(); + var table = (BlockTable)tx.GetObject(document.Database.BlockTableId, OpenMode.ForRead); + var paper = (BlockTableRecord)tx.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForRead); + var total = 0; var pipes = 0; var xdata = 0; var owned = 0; + foreach (ObjectId id in paper) + { + total++; + if (tx.GetObject(id, OpenMode.ForRead) is BlockReference block && string.Equals(block.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++; + if (tx.GetObject(id, OpenMode.ForRead) is Entity entity) + { + using var data = entity.GetXDataForApplication("PXP-OA"); + if (data is not null) { xdata++; if (data.AsArray().Any(x => x.TypeCode == 1000)) owned++; } + } + } + return new(total, pipes, xdata, owned); + } + private FirstApplyAutomationConfiguration ReadConfiguration() + { + if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_DISABLED"); + if (!string.Equals(RuntimeDiagnostics.RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) && !string.Equals(RuntimeDiagnostics.RuntimeMode, "Apply", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_MODE_BLOCKED"); + var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG"); + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_CONFIGURATION_REQUIRED"); + var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("AUTOMATION_CONFIGURATION_INVALID"); + if (!File.Exists(loaded.FixtureMarkerPath) || !string.Equals(File.ReadAllText(loaded.FixtureMarkerPath).Trim(), "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_FIXTURE_MARKER_INVALID"); + if (!string.Equals(loaded.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase) && + (string.IsNullOrWhiteSpace(loaded.OrthoDrawingPath) || !Path.GetFullPath(loaded.OrthoDrawingPath).StartsWith(Path.GetDirectoryName(Path.GetFullPath(loaded.FixtureMarkerPath))! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException("AUTOMATION_FIXTURE_OUTSIDE_MARKER_ROOT"); + return loaded; + } + private void Complete(bool success, string? error) + { + try + { + if (document is not null) { document.CommandEnded -= OnCommandEnded; document.CommandFailed -= OnCommandFailed; document.CommandCancelled -= OnCommandCancelled; } + AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated; + WriteEvidence(success, error); + if (document is not null) document.CloseAndDiscard(); + AcadApplication.Quit(); + } + catch { } + finally { selectedTargetSnapshotId = null; pxpResults.Clear(); } + } + + private void WriteEvidence(bool success, string? error) + { + if (config is null) return; + var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1); + var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, PermitAccepted = first?.PermitAccepted, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, StateTransitions = transitions }; + Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!); + File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence)); + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs index 71c7b0a..100a644 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs @@ -16,9 +16,11 @@ namespace PRecAnnot.Plant3D2026; internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader { - public PlantRuntimeContext ReadCurrentContext() + public PlantRuntimeContext ReadCurrentContext() => ReadCurrentContext(null); + + public PlantRuntimeContext ReadCurrentContext(Document? explicitDocument = null) { - var document = AcadApplication.DocumentManager.MdiActiveDocument; + var document = explicitDocument ?? AcadApplication.DocumentManager.MdiActiveDocument; if (document is null) { return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует."); diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs index 3fc93f1..b358f5a 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -63,6 +63,24 @@ public sealed class FoundationTests Assert.Contains("[CommandMethod(\"PXP-OA\")]", source); } + [Fact] public void AutomationBootstrap_DefersPlantAndDocumentAccessUntilCoordinator() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + var bootstrap = source[..source.IndexOf("internal sealed record FirstApplyAutomationEvidence", StringComparison.Ordinal)]; + Assert.DoesNotContain("PlantProject.LoadProject", bootstrap); + Assert.DoesNotContain("MdiActiveDocument", bootstrap); + Assert.Contains("AcadApplication.Idle += OnIdle", bootstrap); + Assert.Contains("coordinatorStartCount++", bootstrap); + } + + [Fact] public void AutomationLifecycleProbe_CompletesBeforePlantActivation() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + Assert.Contains("LifecycleProbe", source); + Assert.True(source.IndexOf("CompleteLifecycleProbe", StringComparison.Ordinal) < source.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal)); + Assert.Contains("PostStartupReady", source); + } + [Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs"))); [Fact] public void LegacyAliases_AreRegistered() => Assert.True(File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-AAV") && File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-OA")); [Fact] public void RibbonCommand_ReferencesRegisteredCommand() => Assert.Contains("PHP_REC_ANNOT ", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));