feat: implement first limited Pipe annotation Apply

This commit is contained in:
codex
2026-08-03 10:50:51 +03:00
parent 1bfb91bc4b
commit bd5f47d811
6 changed files with 298 additions and 12 deletions
@@ -177,6 +177,10 @@ public sealed record AnnotationPlanningRequest
public required OrthoViewportInfo Viewport { get; init; }
public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>();
public IReadOnlyList<ExistingAnnotation> Existing { get; init; } = Array.Empty<ExistingAnnotation>();
// 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); }
@@ -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<string>()
});
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))
@@ -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<string, object?>
@@ -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<AnnotationPlan>();
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
@@ -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)
{
@@ -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<FirstLimitedApplyRuntimePermit>(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<DynamicBlockReferenceProperty>()
.SingleOrDefault(value => string.Equals(value.PropertyName, "V", StringComparison.Ordinal));
if (property is null || !property.GetAllowedValues().OfType<string>().Contains(visibilityState, StringComparer.Ordinal))
throw new InvalidOperationException("ANNOTATION_VISIBILITY_STATE_UNSUPPORTED");
property.Value = visibilityState;
}
}
@@ -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<int> SelectedRows,
ViewMembershipStatus DefaultStatus,
string DefaultCode)
string DefaultCode,
IReadOnlyDictionary<int, GeometrySnapshot>? 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<int>();
var geometry = new Dictionary<int, GeometrySnapshot>();
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<int>(), 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");
}
}
@@ -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 });
}