From bd5f47d8112c75d4d236d3682ff853962e4c3606 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 3 Aug 2026 10:50:51 +0300 Subject: [PATCH] feat: implement first limited Pipe annotation Apply --- .../src/PRecAnnot.Abstractions/Contracts.cs | 4 + .../PRecAnnot.Core/AnnotationPlanBuilder.cs | 59 ++++++++- .../src/PRecAnnot.Plant3D2026/EntryPoint.cs | 44 ++++++- .../FirstLimitedApplyRuntime.cs | 113 ++++++++++++++++++ .../PRecAnnot.Plant3D2026/PlantReadPath.cs | 57 ++++++++- .../PRecAnnot.Core.Tests/FoundationTests.cs | 33 ++++- 6 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyRuntime.cs diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 0d4e1aa..0ba60de 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -177,6 +177,10 @@ public sealed record AnnotationPlanningRequest public required OrthoViewportInfo Viewport { get; init; } public IReadOnlyList Objects { get; init; } = Array.Empty(); public IReadOnlyList Existing { get; init; } = Array.Empty(); + // Test-only limited Apply intent. It is part of the canonical plan and is + // deliberately explicit: the planner must never choose an arbitrary Pipe. + public string? LimitedApplyTargetSnapshotId { get; init; } + public string? LimitedApplyRuleId { get; init; } } public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs index 931c7e8..8b7233a 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs @@ -6,7 +6,7 @@ namespace PRecAnnot.Core; public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder { - public const int CurrentPlanSchemaVersion = 1; + public const int CurrentPlanSchemaVersion = 2; public AnnotationPlan Build(AnnotationPlanningRequest request) { var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); @@ -31,12 +31,65 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder continue; } + if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) && + !string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal)) + { + operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_NOT_SELECTED_TARGET" } }); + continue; + } + if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved) { operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { MembershipReason(item.MembershipStatus) } }); continue; } + // The first Apply slice is intentionally narrower than the + // historic bulk rules: exactly one explicitly selected, resolved + // Pipe can become a PIPE block operation. All other objects keep + // their normal diagnostic treatment. + if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) && + string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal)) + { + if (!string.Equals(item.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase)) + { + operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "APPLY_UNSUPPORTED_OBJECT_CLASS" } }); + continue; + } + if (item.MembershipStatus != ViewMembershipStatus.Resolved) + { + operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_REQUIRES_RESOLVED_MEMBERSHIP" } }); + continue; + } + if (item.Geometry.PaperPosition is not { } pipePosition) + { + operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "ANNOTATION_PLACEMENT_NOT_RESOLVED" } }); + continue; + } + + var pipeIntent = new AnnotationIntent + { + StableKey = $"{request.Viewport.Id}:{item.SnapshotId}:{FirstLimitedApplyPlanner.RuleId}", + Text = FirstLimitedApplyPlanner.RuleId, + Position = new AnnotationPoint(pipePosition.X, pipePosition.Y), + Rotation = item.Geometry.Rotation ?? 0, + ClassName = item.ObjectClass, + Layer = "PXP_ANNOTATION" + }; + operations.Add(new AnnotationOperationPlan + { + SourceSnapshotId = item.SnapshotId, + Kind = AnnotationOperationKind.Create, + AnnotationType = "PIPE", + Text = FirstLimitedApplyPlanner.RuleId, + Position = pipePosition, + Rotation = item.Geometry.Rotation, + Reasons = Array.Empty() + }); + creates.Add(pipeIntent); + continue; + } + var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim(); if (string.IsNullOrWhiteSpace(text)) { @@ -111,7 +164,9 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion) .Append("|command=").Append(Normalize(request.CommandName)) .Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName))) - .Append("|view=").Append(Normalize(request.Viewport.Id)); + .Append("|view=").Append(Normalize(request.Viewport.Id)) + .Append("|limited-target=").Append(Normalize(request.LimitedApplyTargetSnapshotId)) + .Append("|limited-rule=").Append(Normalize(request.LimitedApplyRuleId)); foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal)) { canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass)) diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 7d3b723..148376e 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -43,7 +43,6 @@ public sealed class EntryPoint : IExtensionApplication { document = AcadApplication.DocumentManager.MdiActiveDocument; var mode = ParseMode(RuntimeDiagnostics.RuntimeMode); - if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED"); var context = new PlantRuntimeContextReader().ReadCurrentContext(); var details = new Dictionary @@ -75,6 +74,15 @@ public sealed class EntryPoint : IExtensionApplication return; } + if (mode == PRecAnnotExecutionMode.Apply && !string.Equals(commandName, "PXP-OA", StringComparison.Ordinal)) + throw new InvalidOperationException("APPLY_RULE_NOT_SUPPORTED"); + var hasLimitedApplyPermit = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH")); + var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null + ? FirstLimitedApplyPermitReader.ReadFor(document) + : null; + if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1) + throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT"); + var builder = new AnnotationPlanBuilder(); var plans = new List(); var objectsByViewport = new List>(); @@ -82,7 +90,14 @@ public sealed class EntryPoint : IExtensionApplication { var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None); objectsByViewport.Add(objects); - plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects })); + plans.Add(builder.Build(new AnnotationPlanningRequest + { + CommandName = commandName, + Viewport = viewport, + Objects = objects, + LimitedApplyTargetSnapshotId = applyPermit?.TargetSnapshotId, + LimitedApplyRuleId = applyPermit is null ? null : FirstLimitedApplyPlanner.RuleId + })); } var summary = new @@ -114,11 +129,32 @@ public sealed class EntryPoint : IExtensionApplication details["PlanConflictCount"] = summary.ConflictCount; details["PlanUnsupportedCount"] = summary.UnsupportedCount; details["ErrorCount"] = summary.ErrorCount; + if (mode == PRecAnnotExecutionMode.Apply) + { + var corePermit = new FirstLimitedApplyPermit + { + FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, + AllowApply = applyPermit!.AllowApply, + TargetSnapshotId = applyPermit.TargetSnapshotId, + ExpectedPlanId = applyPermit.ExpectedPlanId + }; + var preflight = new FirstLimitedApplyPlanner().Prepare(mode, plans.Single(), context.Viewports.Single(), objectsByViewport.Single(), corePermit); + new RuntimeWriteGuard(mode).EnsureWriteAllowed(preflight.Operation.OperationId); + RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document); + RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document); + var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState); + details["ApplyOutcome"] = outcome.ToString(); + details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0; + details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0; + RuntimeDiagnostics.Write(outcome == PipeAnnotationWriteOutcome.Created ? "AnnotationCreated" : "AnnotationDuplicateDetected", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document); + RuntimeDiagnostics.Write("AnnotationTransactionCommitted", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document); + } RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); WriteEditor(document, $"\nP_Rec_Annot — диагностический план\nПроект: {context.ProjectDisplayName}\nТип чертежа: Ortho\nViewport: {context.Viewports.Count}\nОбъектов найдено: {summary.ObjectsExamined}\nБудет создано: {summary.CreateCount}\nБудет обновлено: {summary.UpdateCount}\nБудет пропущено: {summary.SkipCount}\nКонфликтов: {summary.ConflictCount}\nОшибок: {summary.ErrorCount}\nDWG не изменён."); WriteEditor(document, $"\nPlan schema: {AnnotationPlanBuilder.CurrentPlanSchemaVersion}; PlanId: {planId ?? "multiple"}; membership resolved/partial/outside/unresolved: {membershipResolved}/{membershipPartial}/{membershipOutside}/{membershipUnresolved}."); - WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds); - RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); + var completedStatus = mode == PRecAnnotExecutionMode.Apply ? "ApplyCompleted" : "DryRunCompleted"; + WriteUsage(operationId, commandName, completedStatus, document, details, stopwatch.ElapsedMilliseconds); + RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: completedStatus, document: document, details: details); } catch (System.Exception exception) { diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyRuntime.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyRuntime.cs new file mode 100644 index 0000000..855982a --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyRuntime.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Autodesk.AutoCAD.ApplicationServices; +using Autodesk.AutoCAD.DatabaseServices; +using Autodesk.AutoCAD.Geometry; +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Plant3D2026; + +// This file is deliberately runtime-only. Core receives only the anonymised +// permit and canonical operation; no AutoCAD handle, database or project path +// crosses the adapter boundary. +internal sealed record FirstLimitedApplyRuntimePermit +{ + public required bool AllowApply { get; init; } + public required string FixtureMarkerPath { get; init; } + public required string TargetSnapshotId { get; init; } + public required string ExpectedPlanId { get; init; } + public required string VisibilityState { get; init; } +} + +internal static class FirstLimitedApplyPermitReader +{ + private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY"; + + public static FirstLimitedApplyRuntimePermit ReadFor(Document document) + { + var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH"); + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) + throw new InvalidOperationException("APPLY_PERMIT_PATH_REQUIRED"); + if (!File.Exists(path)) throw new InvalidOperationException("APPLY_PERMIT_MISSING"); + + var permit = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (permit is null || !permit.AllowApply || string.IsNullOrWhiteSpace(permit.TargetSnapshotId) || string.IsNullOrWhiteSpace(permit.ExpectedPlanId) || string.IsNullOrWhiteSpace(permit.VisibilityState)) + throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED"); + if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath)) + throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED"); + if (!string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal)) + throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED"); + + var root = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))!; + var drawing = Path.GetFullPath(document.Name); + if (!drawing.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED"); + return permit; + } +} + +internal enum PipeAnnotationWriteOutcome { Created, AlreadyExists } + +internal sealed class FirstLimitedPipeAnnotationWriter +{ + private const string BlockName = "PIPE"; + private const string XdataApplication = "PXP-OA"; + + public PipeAnnotationWriteOutcome Write(Document document, CreatePipeAnnotationOperation operation, string visibilityState) + { + var database = document.Database; + using var transaction = database.TransactionManager.StartTransaction(); + var table = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead); + if (!table.Has(BlockName)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_BLOCK_MISSING"); + + var paper = (BlockTableRecord)transaction.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForWrite); + if (HasOwnedAnnotation(transaction, paper, operation.DuplicateKey)) return PipeAnnotationWriteOutcome.AlreadyExists; + EnsureRegApp(transaction, database); + + using var annotation = new BlockReference( + new Point3d(operation.Placement.X, operation.Placement.Y, operation.Placement.Z), + table[BlockName]); + paper.AppendEntity(annotation); + transaction.AddNewlyCreatedDBObject(annotation, true); + SetConfirmedVisibility(annotation, visibilityState); + annotation.XData = new ResultBuffer( + new TypedValue((int)DxfCode.ExtendedDataRegAppName, XdataApplication), + new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.DuplicateKey), + new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.RuleId)); + transaction.Commit(); + return PipeAnnotationWriteOutcome.Created; + } + + private static bool HasOwnedAnnotation(Transaction transaction, BlockTableRecord paper, string duplicateKey) + { + foreach (ObjectId id in paper) + { + if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue; + using var xdata = reference.GetXDataForApplication(XdataApplication); + if (xdata?.AsArray().Any(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(value.Value as string, duplicateKey, StringComparison.Ordinal)) == true) + return true; + } + return false; + } + + private static void EnsureRegApp(Transaction transaction, Database database) + { + var apps = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead); + if (apps.Has(XdataApplication)) return; + apps.UpgradeOpen(); + using var record = new RegAppTableRecord { Name = XdataApplication }; + apps.Add(record); + transaction.AddNewlyCreatedDBObject(record, true); + } + + // The "V" property and allowed values are the confirmed legacy PXP-OA + // dynamic-block contract. Failure is pre-commit and leaves no entity. + private static void SetConfirmedVisibility(BlockReference annotation, string visibilityState) + { + var property = annotation.DynamicBlockReferencePropertyCollection + .Cast() + .SingleOrDefault(value => string.Equals(value.PropertyName, "V", StringComparison.Ordinal)); + if (property is null || !property.GetAllowedValues().OfType().Contains(visibilityState, StringComparer.Ordinal)) + throw new InvalidOperationException("ANNOTATION_VISIBILITY_STATE_UNSUPPORTED"); + property.Value = visibilityState; + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs index 70abfd8..71c7b0a 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs @@ -5,6 +5,7 @@ using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.DatabaseServices; using Autodesk.ProcessPower.DataLinks; using Autodesk.ProcessPower.Drawings2d; +using Autodesk.ProcessPower.PnP3dObjects; using Autodesk.ProcessPower.P3dProjectParts; using Autodesk.ProcessPower.PlantInstance; using Autodesk.ProcessPower.ProjectManager; @@ -300,7 +301,7 @@ internal sealed class PlantObjectReader : IPlantObjectReader LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"), SupportNumber = Value("Support Number", "SupportNumber"), EquipmentNumber = Value("Equipment Number", "EquipmentNumber"), - Geometry = new GeometrySnapshot(), + Geometry = membership.GeometryFor(rowId), Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase), Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }, MembershipStatus = membership.StatusFor(rowId), @@ -391,7 +392,8 @@ internal sealed class PlantViewportMembershipReader internal readonly record struct MembershipReadResult( IReadOnlySet SelectedRows, ViewMembershipStatus DefaultStatus, - string DefaultCode) + string DefaultCode, + IReadOnlyDictionary? Geometry = null) { public ViewMembershipStatus StatusFor(int rowId) => DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId) @@ -402,6 +404,11 @@ internal sealed class PlantViewportMembershipReader DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId) ? new[] { "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP" } : new[] { DefaultCode }; + + public GeometrySnapshot GeometryFor(int rowId) => + Geometry is not null && Geometry.TryGetValue(rowId, out var geometry) + ? geometry + : new GeometrySnapshot(); } internal static MembershipReadResult Read(OrthoViewportInfo viewport, DataLinksManager dataLinks, Autodesk.ProcessPower.DataObjects.PnPRowIdArray sourceRows) @@ -426,6 +433,7 @@ internal sealed class PlantViewportMembershipReader } var selectedRows = new HashSet(); + var geometry = new Dictionary(); var selected = view.DwgQuery.Execute(new StringCollection(), new StringCollection()); for (var index = 0; index < selected.Count; index++) { @@ -434,7 +442,11 @@ internal sealed class PlantViewportMembershipReader try { var rowId = dataLinks.FindAcPpRowId(sourceId.PrjObjectId); - if (sourceRows.Contains(rowId)) selectedRows.Add(rowId); + if (sourceRows.Contains(rowId)) + { + selectedRows.Add(rowId); + if (TryReadPipePlacement(sourceId, dataLinks, out var placement)) geometry[rowId] = placement; + } } catch (DLException) { @@ -447,7 +459,7 @@ internal sealed class PlantViewportMembershipReader ["SelectedSourceRows"] = selectedRows.Count, ["SourceRows"] = sourceRows.Count }); - return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID"); + return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", geometry); } catch (Exception exception) { @@ -458,6 +470,41 @@ internal sealed class PlantViewportMembershipReader private static MembershipReadResult Unsupported(string code) => new(new HashSet(), ViewMembershipStatus.NotResolved, code); + // Confirmed Plant 3D 2026 linkage: DwgQuery.Execute returns MdObjectId, + // whose PrjObjectId identifies the source entity and whose Xform projects + // that source entity into the particular Ortho view. This is not a sheet + // coordinate heuristic. + private static bool TryReadPipePlacement(MdObjectId sourceId, DataLinksManager dataLinks, out GeometrySnapshot geometry) + { + geometry = new GeometrySnapshot(); + try + { + var openedByQuery = false; + var sourceDatabase = sourceId.GetDatabase(false, ref openedByQuery); + try + { + using var transaction = sourceDatabase.TransactionManager.StartOpenCloseTransaction(); + var objectId = dataLinks.MakeAcDbObjectId(sourceId.PrjObjectId, sourceDatabase); + if (transaction.GetObject(objectId, OpenMode.ForRead) is not Pipe pipe) return false; + var point = pipe.StartPoint.TransformBy(sourceId.Xform); + geometry = new GeometrySnapshot + { + PaperPosition = new Point2Dto(point.X, point.Y), + Rotation = 0 + }; + return true; + } + finally + { + if (openedByQuery) sourceDatabase.Dispose(); + } + } + catch + { + return false; + } + } + // Ortho viewport IDs are generated by PlantOrthoViewportReader from exactly this pair. // Only an exact regenerated ID is accepted; the reader never chooses an arbitrary view. private static string StableId(string sheetId, string? viewName) @@ -472,7 +519,7 @@ internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeW { public void EnsureWriteAllowed(string operation) { - if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED"); + if (mode == PRecAnnotExecutionMode.Apply) return; throw new InvalidOperationException("SMOKE_WRITE_BLOCKED"); } } 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 e479065..3fc93f1 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -321,6 +321,30 @@ public sealed class FoundationTests Assert.DoesNotContain("C:\\", first.Operation.DuplicateKey, StringComparison.OrdinalIgnoreCase); } + [Fact] public void FirstLimitedApplyPlan_OneResolvedPipeCreatesOnePipeOperation() + { + var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" }; + var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest + { + CommandName = "PXP-OA", Viewport = viewport, + Objects = new[] { Snapshot("pipe", "Pipe", null), Snapshot("other", "Valve", "TEST") }, + LimitedApplyTargetSnapshotId = "pipe", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId + }); + Assert.Equal(1, plan.Summary.CreateCount); + Assert.Equal("PIPE", Assert.Single(plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create)).AnnotationType); + Assert.Contains("APPLY_NOT_SELECTED_TARGET", plan.Operations.Single(x => x.SourceSnapshotId == "other").Reasons); + } + + [Fact] public void FirstLimitedApplyPlan_SelectionChangesCanonicalPlanId() + { + var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" }; + var objects = new[] { Snapshot("one", "Pipe", null), Snapshot("two", "Pipe", null) }; + var builder = new AnnotationPlanBuilder(); + var one = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "one", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId }); + var two = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "two", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId }); + Assert.NotEqual(one.PlanId, two.PlanId); + } + [Fact] public void DryRunPath_HasNoForWriteOperations() { var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); @@ -334,7 +358,14 @@ public sealed class FoundationTests { var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" }; var objects = new[] { Snapshot("pipe", "Pipe", "TEST") }; - var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects }); + var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest + { + CommandName = "PXP-OA", + Viewport = viewport, + Objects = objects, + LimitedApplyTargetSnapshotId = "pipe", + LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId + }); return (plan, viewport, objects, new FirstLimitedApplyPermit { FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, AllowApply = true, TargetSnapshotId = "pipe", ExpectedPlanId = plan.PlanId }); }