From c5cf14215995d5d30ee92848f1ec3837dcf178c2 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 4 Aug 2026 10:06:57 +0300 Subject: [PATCH] refactor: observe commands driven by AutoCAD automation script --- .../src/PRecAnnot.Plant3D2026/EntryPoint.cs | 23 ++- .../FirstLimitedApplyAutomation.cs | 2 + .../ScriptDrivenFirstApplyAutomation.cs | 135 ++++++++++++++++++ 3 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index f5bda5f..db7dee4 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -10,24 +10,12 @@ 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(); - RuntimeDiagnostics.Write("AutomationBootstrapPrepared", resultStatus: "Gated"); - } - catch (System.Exception exception) - { - RuntimeDiagnostics.Write("AutomationBootstrapPreparationFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception); - } - } + // Test automation is driven only by the explicitly generated /b script. + // Initialize remains production-equivalent and must not orchestrate commands. RuntimeDiagnostics.Write("AssemblyInitializeCompleted"); } @@ -56,6 +44,12 @@ public sealed class EntryPoint : IExtensionApplication [CommandMethod("PHP_REC_AUTOMATION_READY")] public void AutomationReadyCommand() { } + [CommandMethod("PHP_REC_AUTOMATION_PREPARE", CommandFlags.Session)] + public void AutomationPrepareCommand() => ScriptDrivenFirstApplyAutomation.Prepare(); + + [CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)] + public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview(); + private static void RunDiagnostic(string commandName) { var operationId = Guid.NewGuid().ToString("N"); @@ -171,6 +165,7 @@ public sealed class EntryPoint : IExtensionApplication 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(); + ScriptDrivenFirstApplyAutomation.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null, summary.ObjectsExamined, membershipResolved); FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null); } if (mode == PRecAnnotExecutionMode.Apply) diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs index c3be6fc..436c6a4 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs @@ -83,6 +83,8 @@ internal sealed record FirstApplyAutomationEvidence public int SchemaVersion { get; init; } = 1; public required string Phase { get; init; } public bool Completed { get; init; } + public bool? Prepared { get; init; } + public bool? FunctionalEvidenceCompleted { get; init; } public string? ErrorCode { get; init; } public int? DbmodBefore { get; init; } public int? DbmodAfterCommand1 { get; init; } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs new file mode 100644 index 0000000..b2afcb5 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using Autodesk.AutoCAD.ApplicationServices; +using Autodesk.AutoCAD.DatabaseServices; +using Autodesk.AutoCAD.Runtime; +using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application; + +namespace PRecAnnot.Plant3D2026; + +// Test-only observer. The AutoCAD /b processor, never this type, drives PXP-OA. +internal static class ScriptDrivenFirstApplyAutomation +{ + private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath); + private sealed record Run(int Sequence, string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, int? DbmodBefore, int? DbmodAfter, AutomationEntityCounts? Before, AutomationEntityCounts? After, bool WillStart, bool Ended, bool Failed, bool Cancelled, int? SourceRows, int? MembershipResolved); + private static Config? config; + private static Document? fixture; + private static Database? database; + private static AutomationEntityCounts? baseline; + private static int? dbmodBaseline; + private static readonly List runs = []; + private static readonly List transitions = []; + private static bool prepared; + private static bool terminal; + + internal static void Prepare() + { + try + { + if (terminal) return; + config = ReadConfig(); + RequireGate(config); + var active = AcadApplication.DocumentManager.MdiActiveDocument ?? throw new InvalidOperationException("AUTOMATION_ACTIVE_DOCUMENT_MISSING"); + if (!SamePath(active.Name, config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_UNEXPECTED_ACTIVE_FIXTURE"); + fixture = active; + database = active.Database ?? throw new InvalidOperationException("AUTOMATION_FIXTURE_DATABASE_MISSING"); + fixture.CommandWillStart += OnWillStart; + fixture.CommandEnded += OnEnded; + fixture.CommandFailed += OnFailed; + fixture.CommandCancelled += OnCancelled; + var context = new PlantRuntimeContextReader().ReadCurrentContext(fixture); + if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) + throw new InvalidOperationException("AUTOMATION_ORTHO_CONTEXT_INVALID"); + dbmodBaseline = Dbmod(); + baseline = Counts(database); + prepared = true; + transitions.Add("Prepared"); + Write(false, null); + } + catch (System.Exception exception) { Fail("AUTOMATION_PREPARE_FAILED", exception); } + } + + internal static void RecordPxpOa(string? planId, IReadOnlyList candidates, string? view, int create, int skip, bool? permitAccepted, int sourceRows, int membershipResolved) + { + if (!prepared || terminal || runs.Count == 0) return; + var last = runs[^1]; + if (!last.WillStart || last.PlanId is not null) return; + runs[^1] = last with { PlanId = planId, Candidates = candidates, View = view, Create = create, Skip = skip, SourceRows = sourceRows, MembershipResolved = membershipResolved }; + } + + internal static void FinalizePreview() + { + try + { + if (!prepared) throw new InvalidOperationException("AUTOMATION_NOT_PREPARED"); + if (runs.Count != 2) throw new InvalidOperationException("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA"); + if (runs.Any(x => !x.WillStart || !x.Ended || x.Failed || x.Cancelled)) throw new InvalidOperationException("AUTOMATION_PXP_OA_NOT_ENDED"); + if (runs.Any(x => string.IsNullOrWhiteSpace(x.PlanId)) || !string.Equals(runs[0].PlanId, runs[1].PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_PLAN_IDS_DIFFER"); + if (runs.Any(x => x.Create != 1)) throw new InvalidOperationException("AUTOMATION_PREVIEW_CREATE_NOT_ONE"); + if (runs[0].Candidates.Count == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE"); + if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED"); + terminal = true; + transitions.Add("PreviewCompleted"); + Write(true, null); + Unsubscribe(); + } + catch (System.Exception exception) { Fail("PREVIEW_ACCEPTANCE_FAILED", exception); } + } + + private static void OnWillStart(object sender, CommandEventArgs args) + { + Guard(() => + { + if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return; + runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null)); + transitions.Add("PxpOaWillStart:" + runs.Count); + }); + } + private static void OnEnded(object sender, CommandEventArgs args) => Finish(sender, args, false, false); + private static void OnFailed(object sender, CommandEventArgs args) => Finish(sender, args, true, false); + private static void OnCancelled(object sender, CommandEventArgs args) => Finish(sender, args, false, true); + private static void Finish(object sender, CommandEventArgs args, bool failed, bool cancelled) + { + Guard(() => + { + if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return; + var last = runs[^1]; + if (last.Ended || last.Failed || last.Cancelled) return; + runs[^1] = last with { Ended = !failed && !cancelled, Failed = failed, Cancelled = cancelled, DbmodAfter = Dbmod(), After = Counts(database!) }; + transitions.Add(failed ? "PxpOaFailed" : cancelled ? "PxpOaCancelled" : "PxpOaEnded:" + last.Sequence); + Write(false, null); + }); + } + private static bool IsFixture(object sender) => !terminal && fixture is not null && ReferenceEquals(sender, fixture) && database is not null && ReferenceEquals(fixture.Database, database); + private static bool IsPxp(string name) => string.Equals(name.TrimStart('.', '_'), "PXP-OA", StringComparison.OrdinalIgnoreCase); + private static void Guard(Action action) { try { if (!terminal) action(); } catch (System.Exception ex) { Fail("AUTOMATION_EVENT_HANDLER_FAILED", ex); } } + private static void Fail(string code, System.Exception exception) { if (terminal) return; terminal = true; transitions.Add("Failed:" + code); try { Write(false, code + ":" + exception); } finally { Unsubscribe(); } } + private static void Unsubscribe() { if (fixture is null) return; try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } } + private static int? Dbmod() { try { return Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD")); } catch { return null; } } + private static AutomationEntityCounts Counts(Database db) + { + using var tx = db.TransactionManager.StartTransaction(); + var space = (BlockTableRecord)tx.GetObject(db.CurrentSpaceId, OpenMode.ForRead); + var total = 0; var pipes = 0; var xdata = 0; var owned = 0; + foreach (ObjectId id in space) { if (tx.GetObject(id, OpenMode.ForRead) is not Entity entity) continue; total++; if (entity is BlockReference br && string.Equals(br.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++; if (entity.XData is not null) { foreach (TypedValue value in entity.XData) if (value.Value is string text && string.Equals(text, "PXP-OA", StringComparison.OrdinalIgnoreCase)) { xdata++; owned++; break; } } } + return new(total, pipes, xdata, owned); + } + private static Config ReadConfig() + { + var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG") ?? throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING"); + using var json = JsonDocument.Parse(File.ReadAllText(path)); var root = json.RootElement; + return new(root.GetProperty("phase").GetString() ?? "Preview", root.GetProperty("orthoDrawingPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"), root.GetProperty("evidencePath").GetString() ?? throw new InvalidOperationException("AUTOMATION_EVIDENCE_PATH_REQUIRED"), root.GetProperty("fixtureMarkerPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_MARKER_PATH_REQUIRED")); + } + private static void RequireGate(Config value) + { + if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_LAUNCHER_MODE"), "NextPilot", StringComparison.Ordinal) || !File.Exists(value.FixtureMarkerPath)) throw new InvalidOperationException("AUTOMATION_GATE_REJECTED"); + } + private static bool SamePath(string left, string right) => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + private static void Write(bool completed, string? error) + { + if (config is null) return; + var first = runs.ElementAtOrDefault(0); var second = runs.ElementAtOrDefault(1); + var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").ToArray(), 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 = first?.Candidates.OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault(), ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null, EvidenceFlushed = true, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions }; + Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!); + var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true); + } +}