diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index b913e1c..c45a22d 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -64,6 +64,15 @@ public sealed record PlantObjectSnapshot public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed } +public sealed record SourceObjectViewportMembership +{ + public required string SourceObjectStableKey { get; init; } + public required string ViewStableKey { get; init; } + public required ViewMembershipStatus Status { get; init; } + public IReadOnlyList EvidenceCodes { get; init; } = Array.Empty(); + public string? ReasonCode { get; init; } +} + public sealed record PlantRuntimeContext { public bool HasActiveDocument { get; init; } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs index 4a77324..931c7e8 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/AnnotationPlanBuilder.cs @@ -33,7 +33,7 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder 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" } }); + operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { MembershipReason(item.MembershipStatus) } }); continue; } @@ -127,4 +127,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder } private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC); + + private static string MembershipReason(ViewMembershipStatus status) => status switch + { + ViewMembershipStatus.ObjectOutsideView => "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP", + ViewMembershipStatus.Unsupported => "VIEW_MEMBERSHIP_API_UNSUPPORTED", + ViewMembershipStatus.Failed => "VIEW_MEMBERSHIP_TRANSFORM_FAILED", + _ => "VIEW_MEMBERSHIP_NOT_RESOLVED" + }; } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 7b97b7c..7d3b723 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -99,6 +99,7 @@ public sealed class EntryPoint : IExtensionApplication 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 membershipOutside = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.ObjectOutsideView); var planId = plans.Count == 1 ? plans[0].PlanId : null; details["ObjectsExamined"] = summary.ObjectsExamined; details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion; @@ -106,6 +107,7 @@ public sealed class EntryPoint : IExtensionApplication details["MembershipResolved"] = membershipResolved; details["MembershipPartial"] = membershipPartial; details["MembershipUnresolved"] = membershipUnresolved; + details["MembershipOutsideView"] = membershipOutside; details["PlanCreateCount"] = summary.CreateCount; details["PlanUpdateCount"] = summary.UpdateCount; details["PlanSkipCount"] = summary.SkipCount; @@ -114,7 +116,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}."); + 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); } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs index cfb7bdd..70abfd8 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Collections.Specialized; using System.Text; using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.DatabaseServices; @@ -279,6 +280,8 @@ internal sealed class PlantObjectReader : IPlantObjectReader return Array.Empty(); } + var membership = PlantViewportMembershipReader.Read(viewport, dataLinks, rowIds); + foreach (var rowId in rowIds) { try @@ -299,7 +302,9 @@ internal sealed class PlantObjectReader : IPlantObjectReader EquipmentNumber = Value("Equipment Number", "EquipmentNumber"), Geometry = new GeometrySnapshot(), Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase), - Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" } + Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }, + MembershipStatus = membership.StatusFor(rowId), + MembershipEvidenceCodes = membership.EvidenceFor(rowId) }); } catch (Exception exception) @@ -381,6 +386,88 @@ internal sealed class PlantObjectReader : IPlantObjectReader } } +internal sealed class PlantViewportMembershipReader +{ + internal readonly record struct MembershipReadResult( + IReadOnlySet SelectedRows, + ViewMembershipStatus DefaultStatus, + string DefaultCode) + { + public ViewMembershipStatus StatusFor(int rowId) => + DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId) + ? ViewMembershipStatus.ObjectOutsideView + : DefaultStatus; + + public IReadOnlyList EvidenceFor(int rowId) => + DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId) + ? new[] { "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP" } + : new[] { DefaultCode }; + } + + internal static MembershipReadResult Read(OrthoViewportInfo viewport, DataLinksManager dataLinks, Autodesk.ProcessPower.DataObjects.PnPRowIdArray sourceRows) + { + try + { + var project = PlantApplication.ProjectPart("Ortho") as OrthoProject; + if (project is null) return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED"); + + var candidates = project.DwgSheetDefinitions.AsArray() + .SelectMany(sheet => sheet.DwgViewDefs.AsArray() + .Where(view => string.Equals(StableId(sheet.DwgGuid.ToString(), view.ViewName), viewport.Id, StringComparison.Ordinal)) + .Select(view => (sheet, view))) + .ToArray(); + if (candidates.Length != 1) return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED"); + + var view = candidates[0].view; + if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic)) + { + RuntimeDiagnostics.Write("ViewportMembershipContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details); + return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED"); + } + + var selectedRows = new HashSet(); + var selected = view.DwgQuery.Execute(new StringCollection(), new StringCollection()); + for (var index = 0; index < selected.Count; index++) + { + Autodesk.ProcessPower.Drawings2d.MdObjectId sourceId = default; + if (!selected.GetItem(index, ref sourceId) || sourceId.IsNull || sourceId.PrjObjectId.IsNull) continue; + try + { + var rowId = dataLinks.FindAcPpRowId(sourceId.PrjObjectId); + if (sourceRows.Contains(rowId)) selectedRows.Add(rowId); + } + catch (DLException) + { + // A generated entity without a Piping DataLinks row is outside this reader's source set. + } + } + + RuntimeDiagnostics.Write("ViewportMembershipReadCompleted", "INFO", resultStatus: "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", details: new Dictionary + { + ["SelectedSourceRows"] = selectedRows.Count, + ["SourceRows"] = sourceRows.Count + }); + return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID"); + } + catch (Exception exception) + { + RuntimeDiagnostics.Write("ViewportMembershipReadCompleted", "WARNING", resultStatus: "VIEW_MEMBERSHIP_API_UNSUPPORTED", exception: exception); + return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED"); + } + } + + private static MembershipReadResult Unsupported(string code) => new(new HashSet(), ViewMembershipStatus.NotResolved, code); + + // 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) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + (viewName ?? string.Empty))); + return "vp-" + Convert.ToHexString(hash)[..20]; + } + +} + internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard { public void EnsureWriteAllowed(string operation) 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 ce20a5d..670c353 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -135,6 +135,31 @@ public sealed class FoundationTests Assert.Contains("VIEW_MEMBERSHIP_NOT_RESOLVED", plan.SkipReasons); } + [Fact] public void ViewportMembership_OutsideClip_IsSkippedWithStableReason() + { + var plan = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.ObjectOutsideView }); + Assert.Equal(AnnotationOperationKind.Skip, Assert.Single(plan.Operations).Kind); + Assert.Contains("VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP", plan.SkipReasons); + } + + [Fact] public void AnnotationPlan_ResolvedMembership_AffectsPlanId() + { + var resolved = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.Resolved }); + var outside = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.ObjectOutsideView }); + Assert.NotEqual(resolved.PlanId, outside.PlanId); + } + + [Fact] public void AnnotationPlan_ReorderedMembership_HasSamePlanId() + { + var first = Build( + new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.Resolved }, + new PlantObjectSnapshot { SnapshotId = "b", ObjectClass = "Pipe", LineNumber = "L-2", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.ObjectOutsideView }); + var second = Build( + new PlantObjectSnapshot { SnapshotId = "b", ObjectClass = "Pipe", LineNumber = "L-2", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.ObjectOutsideView }, + new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.Resolved }); + Assert.Equal(first.PlanId, second.PlanId); + } + [Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped() { var plan = Build(Snapshot("a", "Pipe", null, null)); @@ -233,6 +258,22 @@ public sealed class FoundationTests Assert.Contains("SOURCE_DATALINKS_NO_ROWS", source); } + [Fact] public void ViewportMembership_UsesConfirmedPnpIdentityContract() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.Contains("view.DwgQuery.Execute", source); + Assert.Contains("sourceId.PrjObjectId", source); + Assert.Contains("dataLinks.FindAcPpRowId", source); + Assert.Contains("VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", source); + } + + [Fact] public void ViewportMembership_DoesNotSelectFirstViewport() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.DoesNotContain("DwgViewDefs.AsArray().First", source); + Assert.Contains("candidates.Length != 1", source); + } + [Fact] public void RuntimeInspector_IsDisabledByDefault() { var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs"));