Compare commits

...
9 changed files with 108 additions and 8 deletions
@@ -0,0 +1,5 @@
# Annotation-plan determinism
`AnnotationPlanBuilder` uses schema version `1`. Its canonical input is the normalized command, source drawing basename, view ID, source snapshots sorted by stable ID, properties sorted by key, membership state, and operations sorted by stable source key and type. Absolute paths, process data, timestamps and runtime ObjectIds are excluded.
Runtime DryRun confirmed equal PlanIds and summaries for two calls of each supported command. Changing a relevant normalized input changes the PlanId.
@@ -0,0 +1,3 @@
# DCF control comparison
`DBMOD` covers only the active DWG. DCF and project metadata require paired control and DryRun fixture copies created from one fully closed state. This comparison has not yet been executed, so plugin-only DCF and metadata deltas remain unverified. Apply remains blocked.
@@ -0,0 +1,17 @@
{
"executionMode": "DryRun",
"planSchemaVersion": 1,
"sourceObjectsRead": 7,
"membershipResolved": 0,
"membershipPartial": 0,
"membershipUnresolved": 7,
"phpRecAnnotPlanIds": ["plan-C5D40DC32505D5D9CFE6D38B", "plan-C5D40DC32505D5D9CFE6D38B"],
"pxpAavPlanIds": ["plan-E4877CA13418D43A17F47AAA", "plan-E4877CA13418D43A17F47AAA"],
"pxpOaPlanIds": ["plan-6C794D25C292E73AA3BDDC31", "plan-6C794D25C292E73AA3BDDC31"],
"summariesDeterministic": true,
"dbmodBefore": 0,
"dbmodAfter": 0,
"launcherSession": "RolledBack",
"dcfControlComparison": null,
"applyModeUsed": false
}
@@ -0,0 +1,5 @@
# Ortho viewport membership
Every source snapshot has an explicit `ViewMembershipStatus`. The current Plant 3D 2026 adapter has no confirmed official object-to-view mapping API, so the seven source objects are `NotResolved` and skipped with `VIEW_MEMBERSHIP_NOT_RESOLVED`. Source presence is not treated as viewport membership.
No geometric heuristic is used as an exact mapping. The next step requires a confirmed generated-entity linkage or documented view mapping.
@@ -58,8 +58,12 @@ public sealed record PlantObjectSnapshot
public required GeometrySnapshot Geometry { get; init; } public required GeometrySnapshot Geometry { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>(); public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>();
public ViewMembershipStatus MembershipStatus { get; init; } = ViewMembershipStatus.NotResolved;
public IReadOnlyList<string> MembershipEvidenceCodes { get; init; } = Array.Empty<string>();
} }
public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed }
public sealed record PlantRuntimeContext public sealed record PlantRuntimeContext
{ {
public bool HasActiveDocument { get; init; } public bool HasActiveDocument { get; init; }
@@ -206,6 +210,7 @@ public sealed record AnnotationIntent
public sealed record AnnotationPlan public sealed record AnnotationPlan
{ {
public int PlanSchemaVersion { get; init; }
public string PlanId { get; init; } = string.Empty; public string PlanId { get; init; } = string.Empty;
public string CommandName { get; init; } = string.Empty; public string CommandName { get; init; } = string.Empty;
public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>(); public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>();
@@ -6,6 +6,7 @@ namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{ {
public const int CurrentPlanSchemaVersion = 1;
public AnnotationPlan Build(AnnotationPlanningRequest request) public AnnotationPlan Build(AnnotationPlanningRequest request)
{ {
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
@@ -30,6 +31,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
continue; continue;
} }
if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "VIEW_MEMBERSHIP_NOT_RESOLVED" } });
continue;
}
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim(); var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
@@ -84,7 +91,8 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
return new AnnotationPlan return new AnnotationPlan
{ {
PlanId = BuildPlanId(request, objects), PlanSchemaVersion = CurrentPlanSchemaVersion,
PlanId = BuildPlanId(request, objects, operations),
CommandName = request.CommandName, CommandName = request.CommandName,
Operations = operations, Operations = operations,
Diagnostics = diagnostics, Diagnostics = diagnostics,
@@ -92,18 +100,31 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
Create = creates, Create = creates,
Update = updates, Update = updates,
Delete = Array.Empty<string>(), Delete = Array.Empty<string>(),
SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).ToArray(), SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).OrderBy(x => x, StringComparer.Ordinal).ToArray(),
ExaminedCount = objects.Length, ExaminedCount = objects.Length,
IsDeterministic = true IsDeterministic = true
}; };
} }
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects) private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects, IReadOnlyList<AnnotationOperationPlan> operations)
{ {
var canonical = new StringBuilder(request.CommandName).Append('|').Append(request.Viewport.SourceDrawingName).Append('|').Append(request.Viewport.Id); var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
foreach (var item in objects) .Append("|command=").Append(Normalize(request.CommandName))
canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition); .Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.Append("|view=").Append(Normalize(request.Viewport.Id));
foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal))
{
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
.Append('|').Append(Normalize(item.Tag)).Append('|').Append(Normalize(item.LineNumber))
.Append('|').Append(item.MembershipStatus);
foreach (var property in item.Properties.OrderBy(x => x.Key, StringComparer.Ordinal))
canonical.Append("|property=").Append(Normalize(property.Key)).Append('=').Append(Normalize(property.Value));
}
foreach (var operation in operations.OrderBy(x => x.SourceSnapshotId, StringComparer.Ordinal).ThenBy(x => x.Kind))
canonical.Append("|operation=").Append(Normalize(operation.SourceSnapshotId)).Append(':').Append(operation.Kind).Append(':').Append(string.Join(',', operation.Reasons.OrderBy(x => x, StringComparer.Ordinal)));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
return "plan-" + Convert.ToHexString(hash)[..24]; return "plan-" + Convert.ToHexString(hash)[..24];
} }
private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
} }
@@ -12,7 +12,8 @@ public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
ObjectClass = item.ClassName, ObjectClass = item.ClassName,
Tag = item.Tag, Tag = item.Tag,
LineNumber = item.LineNumber, LineNumber = item.LineNumber,
Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation } Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation },
MembershipStatus = ViewMembershipStatus.Resolved
}).ToArray(); }).ToArray();
return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
@@ -77,9 +77,11 @@ public sealed class EntryPoint : IExtensionApplication
var builder = new AnnotationPlanBuilder(); var builder = new AnnotationPlanBuilder();
var plans = new List<AnnotationPlan>(); var plans = new List<AnnotationPlan>();
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
foreach (var viewport in context.Viewports) foreach (var viewport in context.Viewports)
{ {
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None); 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 }));
} }
@@ -94,7 +96,16 @@ public sealed class EntryPoint : IExtensionApplication
WarningCount = plans.Sum(x => x.Summary.WarningCount), WarningCount = plans.Sum(x => x.Summary.WarningCount),
ErrorCount = plans.Sum(x => x.Summary.ErrorCount) ErrorCount = plans.Sum(x => x.Summary.ErrorCount)
}; };
var membershipResolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.Resolved);
var membershipPartial = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.PartiallyResolved);
var membershipUnresolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.NotResolved);
var planId = plans.Count == 1 ? plans[0].PlanId : null;
details["ObjectsExamined"] = summary.ObjectsExamined; details["ObjectsExamined"] = summary.ObjectsExamined;
details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
details["PlanId"] = planId;
details["MembershipResolved"] = membershipResolved;
details["MembershipPartial"] = membershipPartial;
details["MembershipUnresolved"] = membershipUnresolved;
details["PlanCreateCount"] = summary.CreateCount; details["PlanCreateCount"] = summary.CreateCount;
details["PlanUpdateCount"] = summary.UpdateCount; details["PlanUpdateCount"] = summary.UpdateCount;
details["PlanSkipCount"] = summary.SkipCount; details["PlanSkipCount"] = summary.SkipCount;
@@ -103,6 +114,7 @@ public sealed class EntryPoint : IExtensionApplication
details["ErrorCount"] = summary.ErrorCount; details["ErrorCount"] = summary.ErrorCount;
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); 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, $"\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/unresolved: {membershipResolved}/{membershipPartial}/{membershipUnresolved}.");
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds); WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
} }
@@ -104,6 +104,37 @@ public sealed class FoundationTests
Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId)); Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId));
} }
[Fact] public void AnnotationPlan_ReversedObjectOrder_HasSamePlanId()
{
var first = Build(Snapshot("a", "Pipe", "L-1"), Snapshot("b", "Pipe", "L-2"));
var second = Build(Snapshot("b", "Pipe", "L-2"), Snapshot("a", "Pipe", "L-1"));
Assert.Equal(first.PlanId, second.PlanId);
Assert.Equal(first.Summary, second.Summary);
}
[Fact] public void AnnotationPlan_AbsolutePath_DoesNotAffectPlanId()
{
var builder = new AnnotationPlanBuilder();
var item = Snapshot("a", "Pipe", "L-1");
var first = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "C:\\one\\model.dwg" }, Objects = new[] { item } });
var second = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "D:\\two\\model.dwg" }, Objects = new[] { item } });
Assert.Equal(first.PlanId, second.PlanId);
}
[Fact] public void AnnotationPlan_ChangedRelevantProperty_ChangesPlanId()
{
var first = Build(Snapshot("a", "Pipe", "L-1"));
var second = Build(Snapshot("a", "Pipe", "L-2"));
Assert.NotEqual(first.PlanId, second.PlanId);
}
[Fact] public void AnnotationPlan_UnresolvedMembership_IsStableSkip()
{
var plan = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.NotResolved });
Assert.Equal(AnnotationOperationKind.Skip, Assert.Single(plan.Operations).Kind);
Assert.Contains("VIEW_MEMBERSHIP_NOT_RESOLVED", plan.SkipReasons);
}
[Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped() [Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped()
{ {
var plan = Build(Snapshot("a", "Pipe", null, null)); var plan = Build(Snapshot("a", "Pipe", null, null));
@@ -221,5 +252,5 @@ public sealed class FoundationTests
} }
private static AnnotationPlan Build(params PlantObjectSnapshot[] objects) => new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" }, Objects = objects }); private static AnnotationPlan Build(params PlantObjectSnapshot[] objects) => new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" }, Objects = objects });
private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) } }; private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) }, MembershipStatus = ViewMembershipStatus.Resolved };
} }