diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 39813a6..b913e1c 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -58,8 +58,12 @@ public sealed record PlantObjectSnapshot public required GeometrySnapshot Geometry { get; init; } public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); + public ViewMembershipStatus MembershipStatus { get; init; } = ViewMembershipStatus.NotResolved; + public IReadOnlyList MembershipEvidenceCodes { get; init; } = Array.Empty(); } +public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed } + public sealed record PlantRuntimeContext { public bool HasActiveDocument { get; init; } @@ -206,6 +210,7 @@ public sealed record AnnotationIntent public sealed record AnnotationPlan { + public int PlanSchemaVersion { get; init; } public string PlanId { get; init; } = string.Empty; public string CommandName { get; init; } = string.Empty; public IReadOnlyList Operations { get; init; } = Array.Empty(); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs index 71be31c..4a77324 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs @@ -6,6 +6,7 @@ namespace PRecAnnot.Core; public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder { + public const int CurrentPlanSchemaVersion = 1; public AnnotationPlan Build(AnnotationPlanningRequest request) { var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); @@ -30,6 +31,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder 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(); if (string.IsNullOrWhiteSpace(text)) { @@ -84,7 +91,8 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder return new AnnotationPlan { - PlanId = BuildPlanId(request, objects), + PlanSchemaVersion = CurrentPlanSchemaVersion, + PlanId = BuildPlanId(request, objects, operations), CommandName = request.CommandName, Operations = operations, Diagnostics = diagnostics, @@ -92,18 +100,31 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder Create = creates, Update = updates, Delete = Array.Empty(), - 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, IsDeterministic = true }; } - private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList objects) + private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList objects, IReadOnlyList operations) { - var canonical = new StringBuilder(request.CommandName).Append('|').Append(request.Viewport.SourceDrawingName).Append('|').Append(request.Viewport.Id); - foreach (var item in objects) - canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition); + 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)); + 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())); return "plan-" + Convert.ToHexString(hash)[..24]; } + + private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC); } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationRuleEngine.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationRuleEngine.cs index 88ea0ac..a708b23 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationRuleEngine.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationRuleEngine.cs @@ -12,7 +12,8 @@ public sealed class AnnotationRuleEngine : IAnnotationRuleEngine ObjectClass = item.ClassName, Tag = item.Tag, 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(); return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 5e21a1e..7b97b7c 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -77,9 +77,11 @@ public sealed class EntryPoint : IExtensionApplication var builder = new AnnotationPlanBuilder(); var plans = new List(); + var objectsByViewport = new List>(); foreach (var viewport in context.Viewports) { var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None); + objectsByViewport.Add(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), 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["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion; + details["PlanId"] = planId; + details["MembershipResolved"] = membershipResolved; + details["MembershipPartial"] = membershipPartial; + details["MembershipUnresolved"] = membershipUnresolved; details["PlanCreateCount"] = summary.CreateCount; details["PlanUpdateCount"] = summary.UpdateCount; details["PlanSkipCount"] = summary.SkipCount; @@ -103,6 +114,7 @@ public sealed class EntryPoint : IExtensionApplication details["ErrorCount"] = summary.ErrorCount; 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/unresolved: {membershipResolved}/{membershipPartial}/{membershipUnresolved}."); WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds); RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); } 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 aa91e57..ce20a5d 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -104,6 +104,37 @@ public sealed class FoundationTests 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() { 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 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 }; }