Compare commits

...
6 changed files with 251 additions and 27 deletions
@@ -0,0 +1,21 @@
{
"date": "2026-08-03",
"executionMode": "DryRun",
"plantProjectDetected": true,
"projectDisplayNameStatus": "FoundWithName",
"orthoDetection": "Confirmed",
"viewportCount": 1,
"dataLinksAvailable": true,
"dataLinksSheetToSourceMapping": "blocked",
"objectsRead": 0,
"phpRecAnnotRuns": 2,
"pxpAavRuns": 2,
"pxpOaRuns": 1,
"dbmodBefore": 0,
"dbmodAfter": 0,
"dwgModified": null,
"dcfModified": true,
"changedProjectFiles": ["Piping.dcf"],
"unhandledExceptions": 0,
"applyModeUsed": false
}
@@ -0,0 +1,22 @@
# Ortho DryRun runtime result
Date: 2026-08-03
The local test fixture's `TestPipeOrtho.dwg` was detected as Ortho with one
viewport. In `DryRun`, `PHP_REC_ANNOT` ran twice, `PXP-AAV` ran twice, and
`PXP-OA` ran once. All completed without an unhandled exception; `DBMOD` was
`0` before and after.
`PlantProject.Name` was available for this fixture and rendered as
`PRecAnnotTestProject`.
The Ortho sheet did not expose direct Piping rows through
`DataLinksManager.SelectAcPpRowIds`; that API threw `DLException: NotFound`.
The adapter now converts this to an empty read-only result. Consequently the
run read zero source Plant objects and produced an empty plan. Mapping the
Ortho sheet to source-model objects remains the blocker for non-empty
AnnotationPlan validation.
The test fixture SHA baseline changed for `Piping.dcf` before the first DryRun
command. This run therefore does not prove DCF invariance, although no plugin
write operation was performed and DBMOD remained unchanged.
@@ -8,6 +8,7 @@ public sealed record RuntimeDiagnostic
} }
public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed } public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed }
public enum PlantProjectDetectionStatus { NotFound, FoundWithName, FoundWithoutDisplayName, Ambiguous, Failed }
public sealed record OrthoDetectionResult public sealed record OrthoDetectionResult
{ {
@@ -66,6 +67,7 @@ public sealed record PlantRuntimeContext
public bool IsOrthoDrawing { get; init; } public bool IsOrthoDrawing { get; init; }
public string? DrawingName { get; init; } public string? DrawingName { get; init; }
public string? ProjectDisplayName { get; init; } public string? ProjectDisplayName { get; init; }
public PlantProjectDetectionStatus ProjectDetectionStatus { get; init; } = PlantProjectDetectionStatus.NotFound;
public IReadOnlyList<OrthoViewportInfo> Viewports { get; init; } = Array.Empty<OrthoViewportInfo>(); public IReadOnlyList<OrthoViewportInfo> Viewports { get; init; } = Array.Empty<OrthoViewportInfo>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>(); public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
public OrthoDetectionResult Detection { get; init; } = new() { Status = OrthoDetectionStatus.Unsupported, Evidence = Array.Empty<string>(), Diagnostics = Array.Empty<RuntimeDiagnostic>() }; public OrthoDetectionResult Detection { get; init; } = new() { Status = OrthoDetectionStatus.Unsupported, Evidence = Array.Empty<string>(), Diagnostics = Array.Empty<RuntimeDiagnostic>() };
@@ -74,6 +76,62 @@ public sealed record PlantRuntimeContext
public interface IPlantRuntimeContextReader { PlantRuntimeContext ReadCurrentContext(); } public interface IPlantRuntimeContextReader { PlantRuntimeContext ReadCurrentContext(); }
public interface IOrthoViewportReader { IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken); } public interface IOrthoViewportReader { IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken); }
public enum SourceDrawingResolutionStatus { Resolved, NotReferenced, Ambiguous, MissingFromProject, Unsupported, Failed }
public sealed record OrthoSourceDrawingReference
{
public required string StableId { get; init; }
public required string DisplayName { get; init; }
public string? ProjectRelativePath { get; init; }
public long? ProjectDrawingRowId { get; init; }
public required SourceDrawingResolutionStatus Status { get; init; }
public IReadOnlyList<string> EvidenceCodes { get; init; } = Array.Empty<string>();
}
public sealed record SourcePlantObjectSnapshot
{
public required string StableObjectKey { get; init; }
public required string ClassName { get; init; }
public string? SourceDrawingStableId { get; init; }
public string? LineGroupStableKey { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
}
public sealed record SourceDrawingReadResult
{
public required OrthoSourceDrawingReference Source { get; init; }
public IReadOnlyList<SourcePlantObjectSnapshot> Objects { get; init; } = Array.Empty<SourcePlantObjectSnapshot>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
}
public interface IOrthoSourceDrawingResolver
{
IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing);
}
public sealed record OrthoDrawingSnapshot
{
public required string StableId { get; init; }
public IReadOnlyList<OrthoViewSourceSnapshot> Views { get; init; } = Array.Empty<OrthoViewSourceSnapshot>();
}
public sealed record OrthoViewSourceSnapshot
{
public required string ViewStableId { get; init; }
public IReadOnlyList<string> ReferencedDrawingPaths { get; init; } = Array.Empty<string>();
}
public interface ISourceDrawingDataLinksReader
{
SourceDrawingReadResult Read(PlantProjectContext project, OrthoSourceDrawingReference source);
}
public sealed record PlantProjectContext
{
public required string StableProjectId { get; init; }
public string? ProjectDisplayName { get; init; }
}
public enum AnnotationOperationKind { Create, Update, Skip, Conflict, Unsupported } public enum AnnotationOperationKind { Create, Update, Skip, Conflict, Unsupported }
public sealed record AnnotationOperationPlan public sealed record AnnotationOperationPlan
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoSourceDrawingResolver : IOrthoSourceDrawingResolver
{
public IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing)
{
var candidates = drawing.Views
.OrderBy(x => x.ViewStableId, StringComparer.Ordinal)
.SelectMany(x => x.ReferencedDrawingPaths)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().Replace('\\', '/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, path),
DisplayName = Path.GetFileName(path),
ProjectRelativePath = path,
Status = SourceDrawingResolutionStatus.Resolved,
EvidenceCodes = new[] { "SOURCE_FROM_VIEW_DEFINITION", "SOURCE_PATH_MATCHED_PROJECT_RELATIVE" }
})
.ToArray();
return candidates.Length != 0
? candidates
: new[]
{
new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, "none"),
DisplayName = "source drawing unavailable",
Status = SourceDrawingResolutionStatus.NotReferenced,
EvidenceCodes = new[] { "ORTHO_SOURCE_DRAWING_NOT_RESOLVED" }
}
};
}
private static string StableId(string drawingId, string source)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingId}|{source}"));
return "src-" + Convert.ToHexString(hash)[..20];
}
}
@@ -1,6 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.DataLinks; using Autodesk.ProcessPower.DataLinks;
using Autodesk.ProcessPower.P3dProjectParts; using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance; using Autodesk.ProcessPower.PlantInstance;
@@ -32,6 +33,9 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
return Empty(true, false, "NO_PLANT_PROJECT", "Активный Plant 3D-проект не найден."); return Empty(true, false, "NO_PLANT_PROJECT", "Активный Plant 3D-проект не найден.");
} }
var displayName = string.IsNullOrWhiteSpace(project.Name) ? null : project.Name.Trim();
var projectStatus = displayName is null ? PlantProjectDetectionStatus.FoundWithoutDisplayName : PlantProjectDetectionStatus.FoundWithName;
OrthoProject? orthoProject; OrthoProject? orthoProject;
try { orthoProject = PlantApplication.ProjectPart("Ortho") as OrthoProject; } try { orthoProject = PlantApplication.ProjectPart("Ortho") as OrthoProject; }
catch (Exception exception) catch (Exception exception)
@@ -60,9 +64,10 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasPlantProject = true, HasPlantProject = true,
IsOrthoDrawing = true, IsOrthoDrawing = true,
DrawingName = Path.GetFileName(document.Name), DrawingName = Path.GetFileName(document.Name),
ProjectDisplayName = project.Name, ProjectDisplayName = displayName ?? "display name unavailable",
ProjectDetectionStatus = projectStatus,
Viewports = viewports, Viewports = viewports,
Diagnostics = Array.Empty<RuntimeDiagnostic>(), Diagnostics = displayName is null ? new[] { new RuntimeDiagnostic { Code = "PLANT_PROJECT_DISPLAY_NAME_EMPTY", Message = "Plant project is open but no display name is available.", Severity = "Warning" } } : Array.Empty<RuntimeDiagnostic>(),
Detection = new OrthoDetectionResult Detection = new OrthoDetectionResult
{ {
Status = OrthoDetectionStatus.Confirmed, Status = OrthoDetectionStatus.Confirmed,
@@ -82,6 +87,8 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasActiveDocument = hasDocument, HasActiveDocument = hasDocument,
HasPlantProject = hasProject, HasPlantProject = hasProject,
IsOrthoDrawing = false, IsOrthoDrawing = false,
ProjectDisplayName = hasProject ? "display name unavailable" : null,
ProjectDetectionStatus = hasProject ? PlantProjectDetectionStatus.Ambiguous : PlantProjectDetectionStatus.NotFound,
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } }, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } },
Detection = new OrthoDetectionResult { Status = status, Evidence = evidence, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } } } Detection = new OrthoDetectionResult { Status = status, Evidence = evidence, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } } }
}; };
@@ -124,7 +131,7 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
TwistAngle = 0, TwistAngle = 0,
IsOn = true, IsOn = true,
IsLocked = false, IsLocked = false,
SourceDrawingName = sheet.DwgName, SourceDrawingName = ReadSourceDrawingPath(view),
ViewName = view.ViewName ViewName = view.ViewName
}); });
} }
@@ -143,6 +150,28 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + viewName)); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + viewName));
return "vp-" + Convert.ToHexString(hash)[..20]; return "vp-" + Convert.ToHexString(hash)[..20];
} }
private static string? ReadSourceDrawingPath(object 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.
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)
{
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;
}
}
catch { }
return null;
}
} }
internal sealed class PlantObjectReader : IPlantObjectReader internal sealed class PlantObjectReader : IPlantObjectReader
@@ -150,42 +179,55 @@ internal sealed class PlantObjectReader : IPlantObjectReader
public IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken) public IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken)
{ {
var document = AcadApplication.DocumentManager.MdiActiveDocument; var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return Array.Empty<PlantObjectSnapshot>(); if (document is null || string.IsNullOrWhiteSpace(viewport.SourceDrawingName)) return Array.Empty<PlantObjectSnapshot>();
DataLinksManager? dataLinks; DataLinksManager? dataLinks;
try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; } try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; }
catch { return Array.Empty<PlantObjectSnapshot>(); } catch { return Array.Empty<PlantObjectSnapshot>(); }
if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>(); if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>();
if (!Path.IsPathRooted(viewport.SourceDrawingName) || !File.Exists(viewport.SourceDrawingName)) return Array.Empty<PlantObjectSnapshot>();
var snapshots = new List<PlantObjectSnapshot>(); var snapshots = new List<PlantObjectSnapshot>();
foreach (var rowId in dataLinks.SelectAcPpRowIds(document.Database)) try
{ {
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))
{ {
cancellationToken.ThrowIfCancellationRequested(); try
var className = dataLinks.GetObjectClassname(rowId);
var properties = dataLinks.GetAllProperties(rowId, true)
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
snapshots.Add(new PlantObjectSnapshot
{ {
SnapshotId = StableId(Path.GetFileName(document.Name), rowId, className), cancellationToken.ThrowIfCancellationRequested();
ObjectClass = className ?? string.Empty, var className = dataLinks.GetObjectClassname(rowId);
Tag = Value("Tag", "TAG"), var properties = dataLinks.GetAllProperties(rowId, true)
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"), .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
SupportNumber = Value("Support Number", "SupportNumber"), .ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"), string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
Geometry = new GeometrySnapshot(), snapshots.Add(new PlantObjectSnapshot
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase), {
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" } SnapshotId = StableId(viewport.SourceDrawingName, rowId, className),
}); ObjectClass = className ?? string.Empty,
} Tag = Value("Tag", "TAG"),
catch LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
{ SupportNumber = Value("Support Number", "SupportNumber"),
// Keep the read path best-effort; no write operation is attempted. 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" }
});
}
catch
{
// Keep the read path best-effort; no write operation is attempted.
}
} }
} }
catch (DLException)
{
// An Ortho sheet can have no direct Piping DataLinks rows. The caller
// receives an empty read-only result rather than a command failure.
}
return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
} }
@@ -130,6 +130,40 @@ public sealed class FoundationTests
Assert.Equal(AnnotationOperationKind.Conflict, plan.Operations.Last().Kind); Assert.Equal(AnnotationOperationKind.Conflict, plan.Operations.Last().Kind);
} }
[Fact] public void OrthoSourceResolver_OneView_ResolvesOneModelDrawing()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[] { new OrthoViewSourceSnapshot { ViewStableId = "view", ReferencedDrawingPaths = new[] { "Plant 3D Models/TestPipeModel.dwg" } } }
});
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.Resolved, source.Status);
Assert.Equal("TestPipeModel.dwg", source.DisplayName);
}
[Fact] public void OrthoSourceResolver_DuplicateViews_DeduplicatesSource()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[]
{
new OrthoViewSourceSnapshot { ViewStableId = "a", ReferencedDrawingPaths = new[] { "models/A.dwg" } },
new OrthoViewSourceSnapshot { ViewStableId = "b", ReferencedDrawingPaths = new[] { "models/A.dwg" } }
}
});
Assert.Single(result);
}
[Fact] public void OrthoSourceResolver_MissingSource_ReturnsDiagnostic()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot { StableId = "sheet" });
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.NotReferenced, source.Status);
Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", source.EvidenceCodes);
}
[Fact] public void ApplyMode_IsBlocked() => Assert.Throws<InvalidOperationException>(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test")); [Fact] public void ApplyMode_IsBlocked() => Assert.Throws<InvalidOperationException>(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test"));
[Fact] public void DryRunPath_HasNoForWriteOperations() [Fact] public void DryRunPath_HasNoForWriteOperations()