Compare commits

..
13 changed files with 797 additions and 26 deletions
+10
View File
@@ -57,6 +57,16 @@ Bundle содержит только фактический runtime dependency g
Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line
number, Ortho drawing/viewport и SHA baseline до DryRun. number, Ortho drawing/viewport и SHA baseline до DryRun.
## Viewport membership and source DataLinks
Legacy P_Rec_Annot source for read-only analysis is
`\\phpsrv03\NBIM\code\CS\P_Rec_Annot\v1.0.4\repo`.
A source object is not a member of an Ortho viewport without confirmed
evidence. A geometry-only filter is at most `PartiallyResolved`. Apply is
blocked by any `PLUGIN_ONLY_CHANGE` in a DWG, DCF, or project metadata found by
a paired CONTROL/DryRun comparison.
## Ortho source DataLinks and DCF control ## Ortho source DataLinks and DCF control
DataLinks активного Ortho drawing не являются источником Piping rows. Piping DataLinks активного Ortho drawing не являются источником Piping rows. Piping
@@ -1,3 +1,26 @@
# DCF control comparison # CONTROL versus DryRun DCF comparison
`DBMOD` covers only the active DWG. DCF and project metadata require paired control and DryRun fixture copies created from one fully closed state. This comparison has not yet been executed, so plugin-only DCF and metadata deltas remain unverified. Apply remains blocked. DBMOD covers only the active drawing. This procedure separately compares
content-bearing project files after identical CONTROL and DryRun sessions.
1. With Plant 3D closed and the Launcher session `RolledBack`, copy the local
fixture to `<FROZEN_FIXTURE>`.
2. Create `<CONTROL_FIXTURE>` and `<DRYRUN_FIXTURE>` from that same frozen
state; calculate complete local SHA-256 inventories before each run.
3. Open model and Ortho drawings in CONTROL through the same Launcher DryRun
launch contract, but invoke no P_Rec_Annot command. Close without saving,
wait for Launcher rollback, then calculate the after inventory.
4. Repeat with DRYRUN and invoke each supported command twice. Close without
saving, wait for rollback, then calculate the after inventory.
5. Compare deltas by logical relative path. Classify each content-bearing file
as `UNCHANGED`, `AUTODESK_BASELINE_CHANGE`, `PLUGIN_ONLY_CHANGE`, or
`INCONCLUSIVE`.
Content-bearing groups include model DWG, Ortho DWG, `Piping.dcf`, remaining
DCF/database files, `Project.xml` or equivalent project metadata, and Ortho
metadata. Lock, cache, session, log, preview, and temporary files are reported
separately and cannot establish project invariance.
Any `PLUGIN_ONLY_CHANGE` blocks Apply. Full paths and hashes remain local;
repository evidence contains only logical fixture markers, groups, counts, and
classifications.
@@ -0,0 +1,66 @@
{
"schemaVersion": 1,
"executionMode": "DryRun",
"fixture": {
"frozen": "<FROZEN_FIXTURE>",
"control": "<CONTROL_FIXTURE>",
"dryRun": "<DRYRUN_FIXTURE>",
"trackedFileCount": 210
},
"membership": {
"mechanism": "IDwgViewDef.DwgQuery.Execute -> MdObjectId.PrjObjectId -> DataLinksManager.FindAcPpRowId",
"orthoViews": 1,
"sourceObjects": 7,
"resolved": 5,
"partiallyResolved": 0,
"outsideView": 2,
"notResolved": 0,
"evidenceCode": "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID"
},
"commands": {
"PHP_REC_ANNOT": {
"planSchemaVersion": 1,
"run1PlanId": "plan-AD701C7AA38D99E4C28D7D6D",
"run2PlanId": "plan-AD701C7AA38D99E4C28D7D6D",
"planDeterministic": true,
"summaryDeterministic": true
},
"PXP-AAV": {
"planSchemaVersion": 1,
"run1PlanId": "plan-C4FCD62F4EEBA1D1948FA22B",
"run2PlanId": "plan-C4FCD62F4EEBA1D1948FA22B",
"planDeterministic": true,
"summaryDeterministic": true
},
"PXP-OA": {
"planSchemaVersion": 1,
"run1PlanId": "plan-C30D133E2D5B4BF4C0AF2ED1",
"run2PlanId": "plan-C30D133E2D5B4BF4C0AF2ED1",
"planDeterministic": true,
"summaryDeterministic": true
}
},
"projectFileComparison": {
"controlDeltaCount": 0,
"dryRunDeltaCount": 0,
"modelDwgPluginOnlyDelta": "UNCHANGED",
"orthoDwgPluginOnlyDelta": "UNCHANGED",
"pipingDcfPluginOnlyDelta": "UNCHANGED",
"projectMetadataPluginOnlyDelta": "UNCHANGED"
},
"dbmod": {
"pairedRun": null,
"reason": "Command-window output was not retained."
},
"launcher": {
"controlSession": "RolledBack",
"dryRunSession": "RolledBack",
"netloadCount": 1,
"profileArgumentPresent": false
},
"apply": {
"performed": false,
"authorised": false,
"reason": "All operations remain Skip; annotation rules are not yet implemented."
}
}
@@ -3,9 +3,11 @@
Date: 2026-08-03 Date: 2026-08-03
The local test fixture's `TestPipeOrtho.dwg` was detected as Ortho with one 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 viewport. In the paired `DryRun`, `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA`
`PXP-OA` ran once. All completed without an unhandled exception; `DBMOD` was each ran twice. Plugin telemetry recorded all six preflight completions without
`0` before and after. an unhandled exception. The command-window DBMOD output was not retained for
this paired run, so its DBMOD value is recorded as `NOT RECORDED` rather than
inferred from telemetry.
`PlantProject.Name` was available for this fixture and rendered as `PlantProject.Name` was available for this fixture and rendered as
`PRecAnnotTestProject`. `PRecAnnotTestProject`.
@@ -17,12 +19,28 @@ reference. The adapter resolves that reference through
`PipingProject.GetPnPDrawingFiles()` and reads `PipingProject.GetPnPDrawingFiles()` and reads
`DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`. `DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`.
On the latest real DryRun, one source drawing was resolved and seven source On the paired real DryRun, one source drawing was resolved and seven source
Plant objects were read. `PHP_REC_ANNOT` reported seven skipped operations, Plant objects were read. The confirmed view query resolved five source rows to
zero writes, zero conflicts, and zero errors. This verifies source-model the active viewport and classified two as outside the view. Each command
DataLinks access; it does not yet verify exact source-object-to-viewport reported seven skipped operations, zero writes, zero conflicts, and zero
membership or repeated-plan determinism. errors. The repeated plans were deterministic:
The test fixture SHA baseline changed for `Piping.dcf` before the first DryRun | Command | Run 1 / run 2 PlanId |
command. This run therefore does not prove DCF invariance. A paired control | --- | --- |
fixture versus DryRun fixture comparison remains required. | `PHP_REC_ANNOT` | `plan-AD701C7AA38D99E4C28D7D6D` |
| `PXP-AAV` | `plan-C4FCD62F4EEBA1D1948FA22B` |
| `PXP-OA` | `plan-C30D133E2D5B4BF4C0AF2ED1` |
This verifies source-model DataLinks access, exact view membership through the
stored view query, and repeated-plan determinism. The remaining functional
limitation is annotation-rule coverage: all seven operations remain safe
`Skip` operations, so no first Apply is authorised.
The earlier single-fixture SHA baseline changed before the first command and
was therefore inconclusive. A fresh paired comparison used 210-file CONTROL
and DRYRUN copies created from the same closed frozen fixture. Both inventories
had zero SHA-256 delta after their respective sessions. The CONTROL session
invoked no plugin command; the DRYRUN session invoked all six commands. Thus
the model DWG, Ortho DWG, `Piping.dcf`, project metadata, and other tracked
content-bearing files have no plugin-only delta in this comparison. Both
Launcher sessions completed automatic rollback to `RolledBack`.
@@ -1,5 +1,36 @@
# Ortho viewport membership # Ortho viewport membership
Every source snapshot has an explicit `ViewMembershipStatus`. The current Plant 3D 2026 adapter has no confirmed official object-to-view mapping API, so the seven source objects are `NotResolved` and skipped with `VIEW_MEMBERSHIP_NOT_RESOLVED`. Source presence is not treated as viewport membership. ## Runtime path
No geometric heuristic is used as an exact mapping. The next step requires a confirmed generated-entity linkage or documented view mapping. `EntryPoint.RunDiagnostic` reads each `OrthoViewportInfo` and calls
`PlantObjectReader.ReadObjects`. The reader resolves source Piping rows, then
`PlantViewportMembershipReader.Read` evaluates the stored query for that exact
view. It maps `MdObjectId.PrjObjectId` to a source DataLinks row and assigns a
membership status to every independent `PlantObjectSnapshot`.
| Status | Meaning | Diagnostic evidence |
| --- | --- | --- |
| `Resolved` | The source row was returned by the exact view query. | `VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID` |
| `ObjectOutsideView` | The source row was not returned by that confirmed query. | `VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP` |
| `NotResolved` | The view contract is unavailable, ambiguous, or failed. | `VIEW_MEMBERSHIP_API_UNSUPPORTED` |
No source object is treated as visible solely because it belongs to the same
model drawing. No geometry-only result is upgraded to `Resolved`.
## Plan effect
`AnnotationPlanBuilder` includes membership status in its canonical plan input.
`ObjectOutsideView`, unsupported, and unresolved objects are `Skip` operations
with stable reason codes. `Resolved` membership can progress only through the
existing annotation rules; this DryRun slice never writes an annotation.
## Paired DryRun evidence
The paired local DryRun resolved five of seven source rows through the exact
view query and classified the remaining two as `ObjectOutsideView`. Each of
the three supported commands produced an identical PlanId on its repeated
invocation. Both Launcher sessions reached `RolledBack`.
The paired command-window DBMOD output was not retained. It is intentionally
reported as `NOT RECORDED`; project-file SHA-256 inventories nevertheless had
zero delta for both CONTROL and DRYRUN copies.
@@ -0,0 +1,56 @@
# Ortho viewport membership API
## Scope
This report records the Plant 3D 2026 contract used to map source-model
DataLinks objects to one Ortho view. Autodesk binaries were inspected in
`<PLANT3D_2026_INSTALL>` and are not part of this repository.
## Confirmed Plant 3D 2026 contract
| Assembly | Version | Type/member | Purpose |
| --- | --- | --- | --- |
| `PnPDwg2dDef` | `0.0.0.0` | `IDwgViewDef.DwgQuery` | Returns the query stored by an Ortho view. |
| `PnPDwg2dDef` | `0.0.0.0` | `DwgQueryByBox.Execute(StringCollection, StringCollection)` | Executes the stored view selection and returns `IDwgSet`. |
| `PnPDwg2dDef` | `0.0.0.0` | `IDwgSet.GetItem(int, ref MdObjectId)` | Enumerates selected source identities. |
| `PnPDwg2dUtil` | `0.0.0.0` | `MdObjectId.PrjObjectId` | Exposes the selected project object identity. |
| `PnPDataLinks` | `17.0.60.1` | `DataLinksManager.FindAcPpRowId(PpObjectId)` | Maps a selected project object to its DataLinks row. |
`DwgQueryByBox` also exposes `CoordinateSystem`, `XLength`, `YLength`,
`ZLength`, and `SelectionMask`. `DwgViewDef` exposes orientation, clipping,
and layout fields. They are not used as a heuristic in this slice because the
query result provides a stronger project-object identity.
## Selected implementation
`PlantViewportMembershipReader.Read` locates exactly one view by the stable
ID already generated from the sheet GUID and view name. It executes that
view's stored `DwgQuery`, converts every returned `MdObjectId.PrjObjectId`
through `DataLinksManager.FindAcPpRowId`, and intersects the result with the
source drawing's Piping rows.
Matching rows receive `Resolved` with
`VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID`. Source rows absent from the confirmed
query result receive `ObjectOutsideView` with
`VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP`. An unsupported or ambiguous runtime
contract stays `NotResolved` with `VIEW_MEMBERSHIP_API_UNSUPPORTED`; the code
does not guess alternate Autodesk properties or select an arbitrary view.
## Legacy analysis
The read-only legacy source at
`\\phpsrv03\NBIM\code\CS\P_Rec_Annot\v1.0.4\repo` was available. Its
`UpdateOrthoDrawings/Commands.cs` configured `DwgQuery.ModelFiles` and called
`DwgQuery.Execute`; `OrthoAutoAnnotate/Commands.cs` and
`AutoAnnotateView/ViewPorts.cs` also used viewport and entity extents. Those
extents checks are a geometric filter, not an authoritative object identity,
so they were not ported as `Resolved` membership.
## Side effects and failure handling
The reader opens no Autodesk write transaction and calls no persistence API.
It does not modify DWG, DCF, or project metadata. `DLException` while mapping
an individual generated identity is ignored only for that non-Piping identity;
other failures yield the stable API diagnostic and preserve no false positive.
Runtime DryRun and paired control/DryRun hash comparison remain required to
verify actual no-write behavior.
@@ -64,6 +64,15 @@ public sealed record PlantObjectSnapshot
public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed } 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 sealed record PlantRuntimeContext
{ {
public bool HasActiveDocument { get; init; } public bool HasActiveDocument { get; init; }
@@ -168,12 +177,40 @@ public sealed record AnnotationPlanningRequest
public required OrthoViewportInfo Viewport { get; init; } public required OrthoViewportInfo Viewport { get; init; }
public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>(); public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>();
public IReadOnlyList<ExistingAnnotation> Existing { get; init; } = Array.Empty<ExistingAnnotation>(); public IReadOnlyList<ExistingAnnotation> Existing { get; init; } = Array.Empty<ExistingAnnotation>();
// Test-only limited Apply intent. It is part of the canonical plan and is
// deliberately explicit: the planner must never choose an arbitrary Pipe.
public string? LimitedApplyTargetSnapshotId { get; init; }
public string? LimitedApplyRuleId { get; init; }
} }
public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); } public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); }
public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply } public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply }
public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); } public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); }
public sealed record CreatePipeAnnotationOperation
{
public required string OperationId { get; init; }
public required string SourceObjectStableKey { get; init; }
public required string ViewStableKey { get; init; }
public required string RuleId { get; init; }
public required AnnotationPoint Placement { get; init; }
public required string DuplicateKey { get; init; }
}
public sealed record FirstLimitedApplyPermit
{
public required string FixtureMarker { get; init; }
public required bool AllowApply { get; init; }
public required string TargetSnapshotId { get; init; }
public required string ExpectedPlanId { get; init; }
}
public sealed record FirstLimitedApplyPreflight
{
public required string PlanId { get; init; }
public required CreatePipeAnnotationOperation Operation { get; init; }
}
public sealed record OrthoViewportRef(string DrawingId, string ViewportId); public sealed record OrthoViewportRef(string DrawingId, string ViewportId);
public sealed record PlantObjectData public sealed record PlantObjectData
@@ -6,7 +6,7 @@ namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{ {
public const int CurrentPlanSchemaVersion = 1; public const int CurrentPlanSchemaVersion = 2;
public AnnotationPlan Build(AnnotationPlanningRequest request) public AnnotationPlan Build(AnnotationPlanningRequest request)
{ {
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
@@ -31,9 +31,62 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
continue; continue;
} }
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
!string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_NOT_SELECTED_TARGET" } });
continue;
}
if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved) 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;
}
// The first Apply slice is intentionally narrower than the
// historic bulk rules: exactly one explicitly selected, resolved
// Pipe can become a PIPE block operation. All other objects keep
// their normal diagnostic treatment.
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
if (!string.Equals(item.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "APPLY_UNSUPPORTED_OBJECT_CLASS" } });
continue;
}
if (item.MembershipStatus != ViewMembershipStatus.Resolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_REQUIRES_RESOLVED_MEMBERSHIP" } });
continue;
}
if (item.Geometry.PaperPosition is not { } pipePosition)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "ANNOTATION_PLACEMENT_NOT_RESOLVED" } });
continue;
}
var pipeIntent = new AnnotationIntent
{
StableKey = $"{request.Viewport.Id}:{item.SnapshotId}:{FirstLimitedApplyPlanner.RuleId}",
Text = FirstLimitedApplyPlanner.RuleId,
Position = new AnnotationPoint(pipePosition.X, pipePosition.Y),
Rotation = item.Geometry.Rotation ?? 0,
ClassName = item.ObjectClass,
Layer = "PXP_ANNOTATION"
};
operations.Add(new AnnotationOperationPlan
{
SourceSnapshotId = item.SnapshotId,
Kind = AnnotationOperationKind.Create,
AnnotationType = "PIPE",
Text = FirstLimitedApplyPlanner.RuleId,
Position = pipePosition,
Rotation = item.Geometry.Rotation,
Reasons = Array.Empty<string>()
});
creates.Add(pipeIntent);
continue; continue;
} }
@@ -111,7 +164,9 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion) var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
.Append("|command=").Append(Normalize(request.CommandName)) .Append("|command=").Append(Normalize(request.CommandName))
.Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName))) .Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.Append("|view=").Append(Normalize(request.Viewport.Id)); .Append("|view=").Append(Normalize(request.Viewport.Id))
.Append("|limited-target=").Append(Normalize(request.LimitedApplyTargetSnapshotId))
.Append("|limited-rule=").Append(Normalize(request.LimitedApplyRuleId));
foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal)) foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal))
{ {
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass)) canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
@@ -127,4 +182,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
} }
private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC); 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"
};
} }
@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class FirstLimitedApplyPlanner
{
public const string FixtureMarker = "FIRST_LIMITED_APPLY";
public const string RuleId = "PXP-OA.PIPE.OPEN-END.V1";
public FirstLimitedApplyPreflight Prepare(
PRecAnnotExecutionMode mode,
AnnotationPlan plan,
OrthoViewportInfo viewport,
IReadOnlyList<PlantObjectSnapshot> objects,
FirstLimitedApplyPermit permit)
{
if (mode != PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_REQUIRED");
if (!permit.AllowApply || !string.Equals(permit.FixtureMarker, FixtureMarker, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!string.Equals(permit.ExpectedPlanId, plan.PlanId, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_PLAN_STALE");
var targets = objects.Where(x => string.Equals(x.SnapshotId, permit.TargetSnapshotId, StringComparison.Ordinal)).ToArray();
if (targets.Length != 1) throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_TARGET");
var target = targets[0];
if (target.MembershipStatus != ViewMembershipStatus.Resolved) throw new InvalidOperationException("APPLY_REQUIRES_RESOLVED_MEMBERSHIP");
if (!string.Equals(target.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("APPLY_UNSUPPORTED_OBJECT_CLASS");
if (target.Geometry.PaperPosition is not { } placement) throw new InvalidOperationException("ANNOTATION_PLACEMENT_NOT_RESOLVED");
var create = plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create && string.Equals(x.SourceSnapshotId, target.SnapshotId, StringComparison.Ordinal)).ToArray();
if (create.Length != 1) throw new InvalidOperationException("APPLY_TARGET_CREATE_NOT_PLANNED");
var duplicateKey = Stable("duplicate", RuleId, viewport.Id, target.SnapshotId);
return new FirstLimitedApplyPreflight
{
PlanId = plan.PlanId,
Operation = new CreatePipeAnnotationOperation
{
OperationId = Stable("operation", RuleId, viewport.Id, target.SnapshotId),
SourceObjectStableKey = target.SnapshotId,
ViewStableKey = viewport.Id,
RuleId = RuleId,
Placement = new AnnotationPoint(placement.X, placement.Y),
DuplicateKey = duplicateKey
}
};
}
private static string Stable(params string[] values) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", values))))[..24];
}
@@ -43,7 +43,6 @@ public sealed class EntryPoint : IExtensionApplication
{ {
document = AcadApplication.DocumentManager.MdiActiveDocument; document = AcadApplication.DocumentManager.MdiActiveDocument;
var mode = ParseMode(RuntimeDiagnostics.RuntimeMode); var mode = ParseMode(RuntimeDiagnostics.RuntimeMode);
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED");
var context = new PlantRuntimeContextReader().ReadCurrentContext(); var context = new PlantRuntimeContextReader().ReadCurrentContext();
var details = new Dictionary<string, object?> var details = new Dictionary<string, object?>
@@ -75,6 +74,15 @@ public sealed class EntryPoint : IExtensionApplication
return; return;
} }
if (mode == PRecAnnotExecutionMode.Apply && !string.Equals(commandName, "PXP-OA", StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_RULE_NOT_SUPPORTED");
var hasLimitedApplyPermit = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH"));
var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null
? FirstLimitedApplyPermitReader.ReadFor(document)
: null;
if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1)
throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT");
var builder = new AnnotationPlanBuilder(); var builder = new AnnotationPlanBuilder();
var plans = new List<AnnotationPlan>(); var plans = new List<AnnotationPlan>();
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>(); var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
@@ -82,7 +90,26 @@ public sealed class EntryPoint : IExtensionApplication
{ {
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None); var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
objectsByViewport.Add(objects); objectsByViewport.Add(objects);
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects })); plans.Add(builder.Build(new AnnotationPlanningRequest
{
CommandName = commandName,
Viewport = viewport,
Objects = objects,
LimitedApplyTargetSnapshotId = applyPermit?.TargetSnapshotId,
LimitedApplyRuleId = applyPermit is null ? null : FirstLimitedApplyPlanner.RuleId
}));
}
if (mode == PRecAnnotExecutionMode.DryRun &&
string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_DIAGNOSTICS"), "FirstLimitedApply", StringComparison.Ordinal))
{
var candidates = objectsByViewport.SelectMany(x => x)
.Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null)
.Select(x => x.SnapshotId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToArray();
WriteEditor(document, $"\nP_Rec_Annot Apply preview candidates: {string.Join(",", candidates)}");
details["ApplyPreviewCandidateCount"] = candidates.Length;
} }
var summary = new var summary = new
@@ -99,6 +126,7 @@ public sealed class EntryPoint : IExtensionApplication
var membershipResolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.Resolved); 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 membershipPartial = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.PartiallyResolved);
var membershipUnresolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.NotResolved); 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; var planId = plans.Count == 1 ? plans[0].PlanId : null;
details["ObjectsExamined"] = summary.ObjectsExamined; details["ObjectsExamined"] = summary.ObjectsExamined;
details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion; details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
@@ -106,17 +134,39 @@ public sealed class EntryPoint : IExtensionApplication
details["MembershipResolved"] = membershipResolved; details["MembershipResolved"] = membershipResolved;
details["MembershipPartial"] = membershipPartial; details["MembershipPartial"] = membershipPartial;
details["MembershipUnresolved"] = membershipUnresolved; details["MembershipUnresolved"] = membershipUnresolved;
details["MembershipOutsideView"] = membershipOutside;
details["PlanCreateCount"] = summary.CreateCount; details["PlanCreateCount"] = summary.CreateCount;
details["PlanUpdateCount"] = summary.UpdateCount; details["PlanUpdateCount"] = summary.UpdateCount;
details["PlanSkipCount"] = summary.SkipCount; details["PlanSkipCount"] = summary.SkipCount;
details["PlanConflictCount"] = summary.ConflictCount; details["PlanConflictCount"] = summary.ConflictCount;
details["PlanUnsupportedCount"] = summary.UnsupportedCount; details["PlanUnsupportedCount"] = summary.UnsupportedCount;
details["ErrorCount"] = summary.ErrorCount; details["ErrorCount"] = summary.ErrorCount;
if (mode == PRecAnnotExecutionMode.Apply)
{
var corePermit = new FirstLimitedApplyPermit
{
FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker,
AllowApply = applyPermit!.AllowApply,
TargetSnapshotId = applyPermit.TargetSnapshotId,
ExpectedPlanId = applyPermit.ExpectedPlanId
};
var preflight = new FirstLimitedApplyPlanner().Prepare(mode, plans.Single(), context.Viewports.Single(), objectsByViewport.Single(), corePermit);
new RuntimeWriteGuard(mode).EnsureWriteAllowed(preflight.Operation.OperationId);
RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document);
RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document);
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState);
details["ApplyOutcome"] = outcome.ToString();
details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0;
details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0;
RuntimeDiagnostics.Write(outcome == PipeAnnotationWriteOutcome.Created ? "AnnotationCreated" : "AnnotationDuplicateDetected", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document);
RuntimeDiagnostics.Write("AnnotationTransactionCommitted", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document);
}
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); 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, $"\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); var completedStatus = mode == PRecAnnotExecutionMode.Apply ? "ApplyCompleted" : "DryRunCompleted";
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); WriteUsage(operationId, commandName, completedStatus, document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: completedStatus, document: document, details: details);
} }
catch (System.Exception exception) catch (System.Exception exception)
{ {
@@ -0,0 +1,113 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
// This file is deliberately runtime-only. Core receives only the anonymised
// permit and canonical operation; no AutoCAD handle, database or project path
// crosses the adapter boundary.
internal sealed record FirstLimitedApplyRuntimePermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
public required string TargetSnapshotId { get; init; }
public required string ExpectedPlanId { get; init; }
public required string VisibilityState { get; init; }
}
internal static class FirstLimitedApplyPermitReader
{
private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY";
public static FirstLimitedApplyRuntimePermit ReadFor(Document document)
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_PERMIT_PATH_REQUIRED");
if (!File.Exists(path)) throw new InvalidOperationException("APPLY_PERMIT_MISSING");
var permit = JsonSerializer.Deserialize<FirstLimitedApplyRuntimePermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (permit is null || !permit.AllowApply || string.IsNullOrWhiteSpace(permit.TargetSnapshotId) || string.IsNullOrWhiteSpace(permit.ExpectedPlanId) || string.IsNullOrWhiteSpace(permit.VisibilityState))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
var root = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))!;
var drawing = Path.GetFullPath(document.Name);
if (!drawing.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
return permit;
}
}
internal enum PipeAnnotationWriteOutcome { Created, AlreadyExists }
internal sealed class FirstLimitedPipeAnnotationWriter
{
private const string BlockName = "PIPE";
private const string XdataApplication = "PXP-OA";
public PipeAnnotationWriteOutcome Write(Document document, CreatePipeAnnotationOperation operation, string visibilityState)
{
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var table = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
if (!table.Has(BlockName)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_BLOCK_MISSING");
var paper = (BlockTableRecord)transaction.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
if (HasOwnedAnnotation(transaction, paper, operation.DuplicateKey)) return PipeAnnotationWriteOutcome.AlreadyExists;
EnsureRegApp(transaction, database);
using var annotation = new BlockReference(
new Point3d(operation.Placement.X, operation.Placement.Y, operation.Placement.Z),
table[BlockName]);
paper.AppendEntity(annotation);
transaction.AddNewlyCreatedDBObject(annotation, true);
SetConfirmedVisibility(annotation, visibilityState);
annotation.XData = new ResultBuffer(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, XdataApplication),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.DuplicateKey),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.RuleId));
transaction.Commit();
return PipeAnnotationWriteOutcome.Created;
}
private static bool HasOwnedAnnotation(Transaction transaction, BlockTableRecord paper, string duplicateKey)
{
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue;
using var xdata = reference.GetXDataForApplication(XdataApplication);
if (xdata?.AsArray().Any(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(value.Value as string, duplicateKey, StringComparison.Ordinal)) == true)
return true;
}
return false;
}
private static void EnsureRegApp(Transaction transaction, Database database)
{
var apps = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead);
if (apps.Has(XdataApplication)) return;
apps.UpgradeOpen();
using var record = new RegAppTableRecord { Name = XdataApplication };
apps.Add(record);
transaction.AddNewlyCreatedDBObject(record, true);
}
// The "V" property and allowed values are the confirmed legacy PXP-OA
// dynamic-block contract. Failure is pre-commit and leaves no entity.
private static void SetConfirmedVisibility(BlockReference annotation, string visibilityState)
{
var property = annotation.DynamicBlockReferencePropertyCollection
.Cast<DynamicBlockReferenceProperty>()
.SingleOrDefault(value => string.Equals(value.PropertyName, "V", StringComparison.Ordinal));
if (property is null || !property.GetAllowedValues().OfType<string>().Contains(visibilityState, StringComparer.Ordinal))
throw new InvalidOperationException("ANNOTATION_VISIBILITY_STATE_UNSUPPORTED");
property.Value = visibilityState;
}
}
@@ -1,9 +1,11 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Collections.Specialized;
using System.Text; using System.Text;
using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices; using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.DataLinks; using Autodesk.ProcessPower.DataLinks;
using Autodesk.ProcessPower.Drawings2d; using Autodesk.ProcessPower.Drawings2d;
using Autodesk.ProcessPower.PnP3dObjects;
using Autodesk.ProcessPower.P3dProjectParts; using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance; using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager; using Autodesk.ProcessPower.ProjectManager;
@@ -279,6 +281,8 @@ internal sealed class PlantObjectReader : IPlantObjectReader
return Array.Empty<PlantObjectSnapshot>(); return Array.Empty<PlantObjectSnapshot>();
} }
var membership = PlantViewportMembershipReader.Read(viewport, dataLinks, rowIds);
foreach (var rowId in rowIds) foreach (var rowId in rowIds)
{ {
try try
@@ -297,9 +301,11 @@ internal sealed class PlantObjectReader : IPlantObjectReader
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"), LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"), SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"), EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(), Geometry = membership.GeometryFor(rowId),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase), 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) catch (Exception exception)
@@ -381,11 +387,139 @@ internal sealed class PlantObjectReader : IPlantObjectReader
} }
} }
internal sealed class PlantViewportMembershipReader
{
internal readonly record struct MembershipReadResult(
IReadOnlySet<int> SelectedRows,
ViewMembershipStatus DefaultStatus,
string DefaultCode,
IReadOnlyDictionary<int, GeometrySnapshot>? Geometry = null)
{
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 };
public GeometrySnapshot GeometryFor(int rowId) =>
Geometry is not null && Geometry.TryGetValue(rowId, out var geometry)
? geometry
: new GeometrySnapshot();
}
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 geometry = new Dictionary<int, GeometrySnapshot>();
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);
if (TryReadPipePlacement(sourceId, dataLinks, out var placement)) geometry[rowId] = placement;
}
}
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", geometry);
}
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);
// Confirmed Plant 3D 2026 linkage: DwgQuery.Execute returns MdObjectId,
// whose PrjObjectId identifies the source entity and whose Xform projects
// that source entity into the particular Ortho view. This is not a sheet
// coordinate heuristic.
private static bool TryReadPipePlacement(MdObjectId sourceId, DataLinksManager dataLinks, out GeometrySnapshot geometry)
{
geometry = new GeometrySnapshot();
try
{
var openedByQuery = false;
var sourceDatabase = sourceId.GetDatabase(false, ref openedByQuery);
try
{
using var transaction = sourceDatabase.TransactionManager.StartOpenCloseTransaction();
var objectId = dataLinks.MakeAcDbObjectId(sourceId.PrjObjectId, sourceDatabase);
if (transaction.GetObject(objectId, OpenMode.ForRead) is not Pipe pipe) return false;
var point = pipe.StartPoint.TransformBy(sourceId.Xform);
geometry = new GeometrySnapshot
{
PaperPosition = new Point2Dto(point.X, point.Y),
Rotation = 0
};
return true;
}
finally
{
if (openedByQuery) sourceDatabase.Dispose();
}
}
catch
{
return false;
}
}
// 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 internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard
{ {
public void EnsureWriteAllowed(string operation) public void EnsureWriteAllowed(string operation)
{ {
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED"); if (mode == PRecAnnotExecutionMode.Apply) return;
throw new InvalidOperationException("SMOKE_WRITE_BLOCKED"); throw new InvalidOperationException("SMOKE_WRITE_BLOCKED");
} }
} }
@@ -135,6 +135,31 @@ public sealed class FoundationTests
Assert.Contains("VIEW_MEMBERSHIP_NOT_RESOLVED", plan.SkipReasons); 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() [Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped()
{ {
var plan = Build(Snapshot("a", "Pipe", null, null)); var plan = Build(Snapshot("a", "Pipe", null, null));
@@ -233,6 +258,22 @@ public sealed class FoundationTests
Assert.Contains("SOURCE_DATALINKS_NO_ROWS", source); 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() [Fact] public void RuntimeInspector_IsDisabledByDefault()
{ {
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs")); var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs"));
@@ -242,6 +283,68 @@ public sealed class FoundationTests
[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 FirstLimitedApply_RequiresApplyMode()
{
var context = FirstApplyContext();
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.DryRun, context.Plan, context.Viewport, context.Objects, context.Permit));
}
[Fact] public void FirstLimitedApply_RequiresFreshPlan()
{
var context = FirstApplyContext();
var permit = context.Permit with { ExpectedPlanId = "stale" };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit));
}
[Fact] public void FirstLimitedApply_RequiresResolvedMembership()
{
var context = FirstApplyContext();
var objects = new[] { context.Objects[0] with { MembershipStatus = ViewMembershipStatus.NotResolved } };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, objects, context.Permit));
}
[Fact] public void FirstLimitedApply_RequiresExactlyOneTarget()
{
var context = FirstApplyContext();
var permit = context.Permit with { TargetSnapshotId = "missing" };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit));
}
[Fact] public void FirstLimitedApply_CreatesDeterministicOperation()
{
var context = FirstApplyContext();
var planner = new FirstLimitedApplyPlanner();
var first = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit);
var second = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit);
Assert.Equal(first.Operation.OperationId, second.Operation.OperationId);
Assert.Equal(first.Operation.DuplicateKey, second.Operation.DuplicateKey);
Assert.DoesNotContain("C:\\", first.Operation.DuplicateKey, StringComparison.OrdinalIgnoreCase);
}
[Fact] public void FirstLimitedApplyPlan_OneResolvedPipeCreatesOnePipeOperation()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
{
CommandName = "PXP-OA", Viewport = viewport,
Objects = new[] { Snapshot("pipe", "Pipe", null), Snapshot("other", "Valve", "TEST") },
LimitedApplyTargetSnapshotId = "pipe", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId
});
Assert.Equal(1, plan.Summary.CreateCount);
Assert.Equal("PIPE", Assert.Single(plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create)).AnnotationType);
Assert.Contains("APPLY_NOT_SELECTED_TARGET", plan.Operations.Single(x => x.SourceSnapshotId == "other").Reasons);
}
[Fact] public void FirstLimitedApplyPlan_SelectionChangesCanonicalPlanId()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var objects = new[] { Snapshot("one", "Pipe", null), Snapshot("two", "Pipe", null) };
var builder = new AnnotationPlanBuilder();
var one = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "one", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId });
var two = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "two", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId });
Assert.NotEqual(one.PlanId, two.PlanId);
}
[Fact] public void DryRunPath_HasNoForWriteOperations() [Fact] public void DryRunPath_HasNoForWriteOperations()
{ {
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
@@ -251,6 +354,21 @@ public sealed class FoundationTests
Assert.DoesNotContain("SetProperties", source); Assert.DoesNotContain("SetProperties", source);
} }
private static (AnnotationPlan Plan, OrthoViewportInfo Viewport, PlantObjectSnapshot[] Objects, FirstLimitedApplyPermit Permit) FirstApplyContext()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var objects = new[] { Snapshot("pipe", "Pipe", "TEST") };
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
{
CommandName = "PXP-OA",
Viewport = viewport,
Objects = objects,
LimitedApplyTargetSnapshotId = "pipe",
LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId
});
return (plan, viewport, objects, new FirstLimitedApplyPermit { FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, AllowApply = true, TargetSnapshotId = "pipe", ExpectedPlanId = plan.PlanId });
}
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 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) }, MembershipStatus = ViewMembershipStatus.Resolved }; 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 };
} }