diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/OrthoRuntimeStructureInspector.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/OrthoRuntimeStructureInspector.cs new file mode 100644 index 0000000..098cc12 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/OrthoRuntimeStructureInspector.cs @@ -0,0 +1,81 @@ +using System.Collections; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Autodesk.ProcessPower.Drawings2d; + +namespace PRecAnnot.Plant3D2026; + +// This is a diagnostic-only probe. It is never part of source resolution and is +// called only by the explicit DryRun/OrthoStructure environment flag combination. +internal static class OrthoRuntimeStructureInspector +{ + public static void Inspect(IDwgViewDef view) + { + if (!RuntimeDiagnostics.IsOrthoStructureDiagnosticsEnabled) return; + + try + { + var query = view.DwgQuery; + var modelFiles = query?.ModelFiles; + var details = new Dictionary + { + ["ViewRuntimeType"] = TypeName(view.GetType()), + ["DwgQueryRuntimeType"] = query is null ? null : TypeName(query.GetType()), + ["ModelFilesDeclaredType"] = typeof(ModelFile[]).FullName, + ["ModelFilesRuntimeType"] = modelFiles?.GetType().AssemblyQualifiedName, + ["ModelFilesCount"] = modelFiles?.Length ?? 0, + ["ModelFileElements"] = modelFiles?.Select(DescribeModelFile).ToArray() ?? Array.Empty>() + }; + RuntimeDiagnostics.Write("OrthoRuntimeStructureInspected", resultStatus: "Captured", details: details); + } + catch (Exception exception) + { + RuntimeDiagnostics.Write("OrthoRuntimeStructureInspectionFailed", "WARNING", resultStatus: "Failed", exception: exception); + } + } + + private static IReadOnlyDictionary DescribeModelFile(ModelFile? modelFile) + { + if (modelFile is null) + { + return new Dictionary { ["IsNull"] = true }; + } + + var properties = modelFile.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.CanRead && property.GetIndexParameters().Length == 0) + .OrderBy(property => property.Name, StringComparer.Ordinal) + .Select(property => DescribeProperty(modelFile, property)) + .ToArray(); + return new Dictionary + { + ["RuntimeType"] = TypeName(modelFile.GetType()), + ["Properties"] = properties + }; + } + + private static IReadOnlyDictionary DescribeProperty(ModelFile modelFile, PropertyInfo property) + { + object? value; + try { value = property.GetValue(modelFile); } + catch { value = null; } + return new Dictionary + { + ["PropertyName"] = property.Name, + ["PropertyType"] = property.PropertyType.FullName, + ["ValueKind"] = value?.GetType().FullName ?? "null", + ["IsNull"] = value is null, + ["StringLength"] = value is string text ? text.Length : null, + ["PathKind"] = property.Name == "ModelFileName" && value is string path + ? (Path.IsPathRooted(path) ? "Absolute" : "ProjectRelative") + : null, + ["CollectionCount"] = value is ICollection collection ? collection.Count : null, + ["StableHash"] = value is null ? null : Hash(value.ToString() ?? string.Empty) + }; + } + + private static string TypeName(Type type) => $"{type.FullName}, {type.Assembly.GetName().Name}, {type.Assembly.GetName().Version}"; + + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16]; +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PRecAnnot.Plant3D2026.csproj b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PRecAnnot.Plant3D2026.csproj index adaef5e..052bce8 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PRecAnnot.Plant3D2026.csproj +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PRecAnnot.Plant3D2026.csproj @@ -14,7 +14,9 @@ $(AutoCAD2026ManagedDir)\PnPDataObjects.dllfalsefalse $(Plant3D2026ManagedDir)\PnPProjectManagerMgd.dllfalsefalse $(Plant3D2026ManagedDir)\PnP3dObjectsMgd.dllfalsefalse + $(Plant3D2026ManagedDir)\PnP3dProjectPartsMgd.dllfalsefalse $(Plant3D2026ManagedDir)\PnP3dOrthoProjectPart.dllfalsefalse $(Plant3D2026ManagedDir)\PnPDwg2dDef.dllfalsefalse + $(Plant3D2026ManagedDir)\PnPDwg2dUtil.dllfalsefalse diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs index 19e30b2..cfb7bdd 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/PlantReadPath.cs @@ -3,6 +3,7 @@ using System.Text; using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.DatabaseServices; using Autodesk.ProcessPower.DataLinks; +using Autodesk.ProcessPower.Drawings2d; using Autodesk.ProcessPower.P3dProjectParts; using Autodesk.ProcessPower.PlantInstance; using Autodesk.ProcessPower.ProjectManager; @@ -151,50 +152,134 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader return "vp-" + Convert.ToHexString(hash)[..20]; } - private static string? ReadSourceDrawingPath(object view) + private static string? ReadSourceDrawingPath(IDwgViewDef view) { - // The Plant 3D 2026 view definition owns a DwgQuery. Its ModelFiles are - // the authoritative source-model references; sheet.DwgName is the Ortho - // sheet and must not be used for Piping DataLinks. + // Confirmed Plant 3D 2026 contract (PnPDwg2dDef/PnPDwg2dUtil): + // IDwgViewDef.DwgQuery -> IDwgQuery.ModelFiles -> ModelFile.ModelFileName. + // The Ortho sheet is never used as a Piping DataLinks source. try { - var query = view.GetType().GetProperty("DwgQuery")?.GetValue(view); - var files = query?.GetType().GetProperty("ModelFiles")?.GetValue(query) as System.Collections.IEnumerable; - if (files is null) return null; - foreach (var file in files) + if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic)) { - var value = file as string - ?? file?.GetType().GetProperty("ModelFileName")?.GetValue(file)?.ToString() - ?? file?.GetType().GetProperty("Value")?.GetValue(file)?.ToString(); - if (!string.IsNullOrWhiteSpace(value)) return value; + RuntimeDiagnostics.Write("OrthoModelFilesContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details); + return null; } + + if (RuntimeDiagnostics.IsOrthoStructureDiagnosticsEnabled) + { + OrthoRuntimeStructureInspector.Inspect(view); + } + + return view.DwgQuery?.ModelFiles + .Select(file => file.ModelFileName) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + } + catch (Exception exception) + { + RuntimeDiagnostics.Write("OrthoModelFilesReadFailed", "WARNING", resultStatus: "ORTHO_MODEL_FILES_API_UNSUPPORTED", exception: exception); } - catch { } return null; } } +internal static class OrthoModelFilesContract +{ + private const string ExpectedViewType = "Autodesk.ProcessPower.Drawings2d.DwgViewDef"; + private const string ExpectedQueryType = "Autodesk.ProcessPower.Drawings2d.DwgQueryByBox"; + private const string ExpectedModelFileType = "Autodesk.ProcessPower.Drawings2d.ModelFile"; + private const string ExpectedDefAssembly = "PnPDwg2dDef"; + private const string ExpectedUtilAssembly = "PnPDwg2dUtil"; + private const string ExpectedVersion = "0.0.0.0"; + + public static bool IsSupported(IDwgViewDef view, out OrthoModelFilesContractDiagnostic diagnostic) + { + var query = view.DwgQuery; + var files = query?.ModelFiles; + var valid = HasExpectedType(view, ExpectedViewType, ExpectedDefAssembly) && + query is not null && HasExpectedType(query, ExpectedQueryType, ExpectedDefAssembly) && + files is not null && files.GetType() == typeof(ModelFile[]) && + files.All(file => file is not null && HasExpectedType(file, ExpectedModelFileType, ExpectedUtilAssembly)); + diagnostic = new OrthoModelFilesContractDiagnostic( + valid ? "ORTHO_MODEL_FILES_API_SUPPORTED" : "ORTHO_MODEL_FILES_API_UNSUPPORTED", + new Dictionary + { + ["ExpectedViewType"] = ExpectedViewType, + ["ActualViewType"] = TypeName(view), + ["ExpectedQueryType"] = ExpectedQueryType, + ["ActualQueryType"] = query is null ? null : TypeName(query), + ["ExpectedModelFileType"] = ExpectedModelFileType, + ["ActualModelFilesType"] = files?.GetType().AssemblyQualifiedName, + ["ExpectedAssemblyVersion"] = ExpectedVersion, + ["ActualViewAssemblyVersion"] = view.GetType().Assembly.GetName().Version?.ToString(), + ["ActualQueryAssemblyVersion"] = query?.GetType().Assembly.GetName().Version?.ToString(), + ["ActualModelFilesAssemblyVersion"] = files?.GetType().Assembly.GetName().Version?.ToString() + }); + return valid; + } + + private static bool HasExpectedType(object value, string expectedType, string expectedAssembly) => + string.Equals(value.GetType().FullName, expectedType, StringComparison.Ordinal) && + string.Equals(value.GetType().Assembly.GetName().Name, expectedAssembly, StringComparison.Ordinal) && + string.Equals(value.GetType().Assembly.GetName().Version?.ToString(), ExpectedVersion, StringComparison.Ordinal); + + private static string TypeName(object value) => $"{value.GetType().FullName}, {value.GetType().Assembly.GetName().Name}, {value.GetType().Assembly.GetName().Version}"; +} + +internal sealed record OrthoModelFilesContractDiagnostic(string Code, IReadOnlyDictionary Details); + internal sealed class PlantObjectReader : IPlantObjectReader { public IReadOnlyList ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken) { - var document = AcadApplication.DocumentManager.MdiActiveDocument; - if (document is null || string.IsNullOrWhiteSpace(viewport.SourceDrawingName)) return Array.Empty(); + if (string.IsNullOrWhiteSpace(viewport.SourceDrawingName)) + { + WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", viewport); + return Array.Empty(); + } + + PipingProject? pipingProject; + try { pipingProject = PlantApplication.ProjectPart("Piping") as PipingProject; } + catch (Exception exception) + { + WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport, exception); + return Array.Empty(); + } + if (pipingProject is null) + { + WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport); + return Array.Empty(); + } + + var sourceDrawing = ResolveProjectDrawing(pipingProject, viewport.SourceDrawingName); + if (sourceDrawing is null) return Array.Empty(); DataLinksManager? dataLinks; - try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; } - catch { return Array.Empty(); } - if (dataLinks is null) return Array.Empty(); - - if (!Path.IsPathRooted(viewport.SourceDrawingName) || !File.Exists(viewport.SourceDrawingName)) return Array.Empty(); + try { dataLinks = pipingProject.DataLinksManager; } + catch (Exception exception) + { + WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception); + return Array.Empty(); + } + if (dataLinks is null) + { + WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport); + return Array.Empty(); + } var snapshots = new List(); try { - using var sourceDatabase = new Database(false, true); - sourceDatabase.ReadDwgFile(viewport.SourceDrawingName, FileShare.ReadWrite, true, null); - sourceDatabase.CloseInput(true); - foreach (var rowId in dataLinks.SelectAcPpRowIds(sourceDatabase)) + // Confirmed Plant 3D 2026 contract: resolve the source model to the + // Piping project drawing record, then read its DataLinks by drawing Id. + // The active Ortho database is deliberately never passed to DataLinks. + var rowIds = dataLinks.SelectAcPpRowIds(sourceDrawing.Id); + if (rowIds.Count == 0) + { + WriteOutcome("SOURCE_DATALINKS_NO_ROWS", viewport, details: new Dictionary { ["ProjectDrawingResolved"] = true }); + return Array.Empty(); + } + + foreach (var rowId in rowIds) { try { @@ -217,20 +302,78 @@ internal sealed class PlantObjectReader : IPlantObjectReader Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" } }); } - catch + catch (Exception exception) { - // Keep the read path best-effort; no write operation is attempted. + RuntimeDiagnostics.Write("SourceDataLinksRowReadFailed", "WARNING", resultStatus: "SOURCE_DATABASE_READ_FAILED", exception: exception); } } } - catch (DLException) + catch (DLException exception) { - // An Ortho sheet can have no direct Piping DataLinks rows. The caller - // receives an empty read-only result rather than a command failure. + WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception); } + catch (Exception exception) + { + WriteOutcome("SOURCE_DATABASE_READ_FAILED", viewport, exception); + } + + WriteOutcome(snapshots.Count == 0 ? "SOURCE_PIPE_ROWS_NOT_FOUND" : "SOURCE_DATALINKS_READ", viewport, + details: new Dictionary { ["SourcePlantObjectsRead"] = snapshots.Count, ["PipeObjectsRead"] = snapshots.Count }); return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); } + private static PnPProjectDrawing? ResolveProjectDrawing(PipingProject pipingProject, string sourceDrawingPath) + { + if (!Path.IsPathRooted(sourceDrawingPath)) + { + WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT", null); + return null; + } + + string normalizedSource; + try { normalizedSource = Path.GetFullPath(sourceDrawingPath); } + catch (Exception exception) + { + WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", null, exception); + return null; + } + + var matches = pipingProject.GetPnPDrawingFiles() + .Where(drawing => MatchesSourcePath(drawing, normalizedSource)) + .OrderBy(drawing => drawing.Id) + .ToArray(); + if (matches.Length == 1) + { + RuntimeDiagnostics.Write("SourceProjectDrawingResolved", resultStatus: "SOURCE_FROM_PROJECT_DRAWING_ROW", details: new Dictionary { ["ProjectDrawingResolved"] = true }); + return matches[0]; + } + + WriteOutcome(matches.Length == 0 ? "ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT" : "ORTHO_SOURCE_DRAWING_AMBIGUOUS", null, + details: new Dictionary { ["CandidateCount"] = matches.Length }); + return null; + } + + private static bool MatchesSourcePath(PnPProjectDrawing drawing, string normalizedSource) => + EquivalentPath(drawing.AbsoluteFileName, normalizedSource) || + EquivalentPath(drawing.ResolvedFilePath, normalizedSource); + + private static bool EquivalentPath(string? candidate, string normalizedSource) + { + if (string.IsNullOrWhiteSpace(candidate)) return false; + try { return string.Equals(Path.GetFullPath(candidate), normalizedSource, StringComparison.OrdinalIgnoreCase); } + catch { return false; } + } + + private static void WriteOutcome(string code, OrthoViewportInfo? viewport, Exception? exception = null, IReadOnlyDictionary? details = null) + { + var sanitizedDetails = new Dictionary + { + ["SourceResolved"] = viewport?.SourceDrawingName is not null + }; + if (details is not null) foreach (var detail in details) sanitizedDetails[detail.Key] = detail.Value; + RuntimeDiagnostics.Write("SourceDataLinksReadCompleted", code == "SOURCE_DATALINKS_READ" ? "INFO" : "WARNING", resultStatus: code, exception: exception, details: sanitizedDetails); + } + private static string StableId(string drawingName, int rowId, string? className) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}")); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/RuntimeDiagnostics.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/RuntimeDiagnostics.cs index c8783f6..b85724f 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/RuntimeDiagnostics.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/RuntimeDiagnostics.cs @@ -14,6 +14,10 @@ internal static class RuntimeDiagnostics public static string RuntimeMode => Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE") ?? "Normal"; + public static bool IsOrthoStructureDiagnosticsEnabled => + string.Equals(RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) && + string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_DIAGNOSTICS"), "OrthoStructure", StringComparison.OrdinalIgnoreCase); + public static void Write( string eventType, string level = "INFO", 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 449bf99..aa91e57 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -164,6 +164,51 @@ public sealed class FoundationTests Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", source.EvidenceCodes); } + [Fact] public void DwgQueryContract_UsesConfirmedModelFileContract() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.Contains("view.DwgQuery?.ModelFiles", source); + Assert.Contains("file.ModelFileName", source); + Assert.Contains("ORTHO_MODEL_FILES_API_UNSUPPORTED", source); + } + + [Fact] public void DwgQueryContract_DoesNotGuessAlternativePropertyNames() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.DoesNotContain("GetProperty(\"ModelFileName\")", source); + Assert.DoesNotContain("GetProperty(\"Value\")", source); + Assert.DoesNotContain("GetProperty(\"FileName\")", source); + } + + [Fact] public void OrthoSourceReader_UsesResolvedPipingProjectDrawingId() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.Contains("pipingProject.GetPnPDrawingFiles()", source); + Assert.Contains("dataLinks.SelectAcPpRowIds(sourceDrawing.Id)", source); + } + + [Fact] public void OrthoSourceReader_DoesNotQueryTheActiveOrthoDatabase() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.DoesNotContain("SelectAcPpRowIds(sourceDatabase)", source); + Assert.DoesNotContain("ReadDwgFile(viewport.SourceDrawingName", source); + } + + [Fact] public void OrthoSourceReader_ReportsDistinctSourceDiagnostics() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); + Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT", source); + Assert.Contains("SOURCE_DATALINKS_UNAVAILABLE", source); + Assert.Contains("SOURCE_DATALINKS_NO_ROWS", source); + } + + [Fact] public void RuntimeInspector_IsDisabledByDefault() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs")); + Assert.Contains("P_REC_ANNOT_DIAGNOSTICS", source); + Assert.Contains("OrthoStructure", source); + } + [Fact] public void ApplyMode_IsBlocked() => Assert.Throws(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test")); [Fact] public void DryRunPath_HasNoForWriteOperations()