feat: resolve Ortho viewport membership from confirmed evidence

This commit is contained in:
codex
2026-08-03 10:14:11 +03:00
parent 37d3cacc13
commit 76d362d2c3
5 changed files with 150 additions and 3 deletions
@@ -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<string> EvidenceCodes { get; init; } = Array.Empty<string>();
public string? ReasonCode { get; init; }
}
public sealed record PlantRuntimeContext
{
public bool HasActiveDocument { get; init; }
@@ -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"
};
}
@@ -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);
}
@@ -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<PlantObjectSnapshot>();
}
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<int> SelectedRows,
ViewMembershipStatus DefaultStatus,
string DefaultCode)
{
public ViewMembershipStatus StatusFor(int rowId) =>
DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId)
? ViewMembershipStatus.ObjectOutsideView
: DefaultStatus;
public IReadOnlyList<string> 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<int>();
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<string, object?>
{
["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<int>(), 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)
@@ -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"));