Compare commits

..
Author SHA1 Message Date
codex bfd31a3747 fix: activate fixture project in script preparation 2026-08-04 10:10:49 +03:00
codex c5cf142159 refactor: observe commands driven by AutoCAD automation script 2026-08-04 10:06:57 +03:00
codex bc481d4378 fix: execute automation commands in AutoCAD command context 2026-08-04 09:37:24 +03:00
codex 1fa25809de fix: queue readiness command for active fixture document 2026-08-04 09:26:43 +03:00
codex 6ed1e4086b fix: activate fixture command queue before dispatch 2026-08-04 07:39:21 +03:00
codex c633107273 fix: terminate automation commands through AutoCAD queue 2026-08-04 07:37:56 +03:00
codex 2d9fbdd474 fix: queue readiness barrier after fixture activation 2026-08-04 07:29:37 +03:00
codex 98564b807f fix: wait for AutoCAD command-queue readiness barrier 2026-08-04 07:28:32 +03:00
codex 1b4ff816ba fix: match active fixture document by stable name 2026-08-04 07:26:45 +03:00
codex 13578b7c9b fix: recover fixture activation raised during document open 2026-08-04 07:25:53 +03:00
codex 523d063ceb fix: preserve completed automation evidence when quit is unavailable 2026-08-04 07:22:27 +03:00
codex 31171c9ed8 test: correct lifecycle exit assertions 2026-08-03 17:24:13 +03:00
codex edf1640c4f fix: dispatch lifecycle exit through live document 2026-08-03 17:23:44 +03:00
codex 81a075e684 fix: exit lifecycle probe without document command queue 2026-08-03 17:21:08 +03:00
codex 32607e2f34 fix: defer Plant activation until document is quiescent 2026-08-03 17:19:01 +03:00
codex c9c4113e86 fix: queue native quit after automation commands 2026-08-03 17:14:14 +03:00
codex 33f91c26aa fix: close all automation-opened documents 2026-08-03 17:10:02 +03:00
codex ef615a1f8e fix: dispatch automation exit from final command 2026-08-03 17:07:40 +03:00
codex e7765cbdda fix: close live document before automation exit 2026-08-03 17:04:56 +03:00
codex d8e61b7a5e fix: exit lifecycle probe from verified idle 2026-08-03 17:02:18 +03:00
codex 79067b72de fix: contain automation callback failures 2026-08-03 16:58:18 +03:00
codex 44de0cf787 fix: dispatch controlled AutoCAD exit from idle 2026-08-03 16:49:57 +03:00
codex 239796aafe fix: arm automation on post-netload command 2026-08-03 16:43:32 +03:00
codex 8a1735408a fix: trace gated automation bootstrap 2026-08-03 16:41:15 +03:00
codex 544b329077 fix: defer Plant automation to first post-startup idle 2026-08-03 16:34:17 +03:00
codex 1563e2fae0 feat: expose limited Apply preview candidates 2026-08-03 11:00:13 +03:00
codex bd5f47d811 feat: implement first limited Pipe annotation Apply 2026-08-03 10:50:51 +03:00
codex 1bfb91bc4b feat: add first limited Pipe Apply preflight 2026-08-03 10:37:37 +03:00
codex 209b8b89b8 docs: record control versus DryRun project deltas 2026-08-03 10:25:49 +03:00
codex 31787fc05a docs: strengthen P_Rec_Annot mapping and Apply rules 2026-08-03 10:14:29 +03:00
codex 11cf97c476 docs: document legacy and Plant 3D 2026 viewport mapping APIs 2026-08-03 10:14:21 +03:00
codex 76d362d2c3 feat: resolve Ortho viewport membership from confirmed evidence 2026-08-03 10:14:11 +03:00
codex 37d3cacc13 docs: record DryRun plan determinism evidence 2026-08-03 09:18:20 +03:00
codex 713e620485 feat: add deterministic annotation plan canonicalization 2026-08-03 09:14:19 +03:00
codex 6f32371418 docs: record source-model DataLinks runtime evidence 2026-08-03 09:08:47 +03:00
codex 243080a1aa feat: resolve Ortho source using confirmed ModelFiles metadata 2026-08-03 09:08:38 +03:00
24 changed files with 2207 additions and 68 deletions
+20
View File
@@ -57,12 +57,32 @@ 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
rows читаются из source model drawing и Piping project part, разрешённых через rows читаются из source model drawing и Piping project part, разрешённых через
Ortho sheet/view definitions. Ortho sheet/view definitions.
Перед предположениями об Autodesk runtime API необходимо исследовать текущий
код, доступный legacy usage, assembly metadata и реальные runtime types.
Reflection/runtime inspection применяется только для подтверждения данных,
которых нельзя установить статически. Production resolver не угадывает
Autodesk properties по строковым именам.
Подтверждённый контракт Plant 3D 2026: `IDwgViewDef.DwgQuery` →
`IDwgQuery.ModelFiles` → `ModelFile[].ModelFileName`. При несовпадении типа
возвращать `ORTHO_MODEL_FILES_API_UNSUPPORTED`, без fallback-эвристики.
Проверка DCF выполняется на парных `control` и `DryRun` fixtures, созданных из Проверка DCF выполняется на парных `control` и `DryRun` fixtures, созданных из
одного закрытого состояния проекта. Сравнивать нужно `control delta` с одного закрытого состояния проекта. Сравнивать нужно `control delta` с
`DryRun delta`: одинаковые изменения классифицируются как baseline behavior `DryRun delta`: одинаковые изменения классифицируются как baseline behavior
@@ -0,0 +1,5 @@
# Annotation-plan determinism
`AnnotationPlanBuilder` uses schema version `1`. Its canonical input is the normalized command, source drawing basename, view ID, source snapshots sorted by stable ID, properties sorted by key, membership state, and operations sorted by stable source key and type. Absolute paths, process data, timestamps and runtime ObjectIds are excluded.
Runtime DryRun confirmed equal PlanIds and summaries for two calls of each supported command. Changing a relevant normalized input changes the PlanId.
@@ -0,0 +1,26 @@
# CONTROL versus DryRun DCF comparison
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,17 @@
{
"executionMode": "DryRun",
"planSchemaVersion": 1,
"sourceObjectsRead": 7,
"membershipResolved": 0,
"membershipPartial": 0,
"membershipUnresolved": 7,
"phpRecAnnotPlanIds": ["plan-C5D40DC32505D5D9CFE6D38B", "plan-C5D40DC32505D5D9CFE6D38B"],
"pxpAavPlanIds": ["plan-E4877CA13418D43A17F47AAA", "plan-E4877CA13418D43A17F47AAA"],
"pxpOaPlanIds": ["plan-6C794D25C292E73AA3BDDC31", "plan-6C794D25C292E73AA3BDDC31"],
"summariesDeterministic": true,
"dbmodBefore": 0,
"dbmodAfter": 0,
"launcherSession": "RolledBack",
"dcfControlComparison": null,
"applyModeUsed": false
}
@@ -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."
}
}
@@ -6,16 +6,23 @@
"orthoDetection": "Confirmed", "orthoDetection": "Confirmed",
"viewportCount": 1, "viewportCount": 1,
"dataLinksAvailable": true, "dataLinksAvailable": true,
"dataLinksSheetToSourceMapping": "blocked", "dataLinksSheetToSourceMapping": "resolved-through-piping-project-drawing-id",
"objectsRead": 0, "sourceDrawingsResolved": 1,
"phpRecAnnotRuns": 2, "sourceDataLinksAvailable": true,
"pxpAavRuns": 2, "objectsRead": 7,
"pxpOaRuns": 1, "pipeObjectsRead": 7,
"dbmodBefore": 0, "phpRecAnnotRuns": 1,
"dbmodAfter": 0, "pxpAavRuns": null,
"pxpOaRuns": null,
"planId": null,
"repeatedPlanIdEqual": null,
"dbmodBefore": null,
"dbmodAfter": null,
"dwgModified": null, "dwgModified": null,
"dcfModified": true, "dcfModified": null,
"changedProjectFiles": ["Piping.dcf"], "changedProjectFiles": null,
"unhandledExceptions": 0, "unhandledExceptions": 0,
"applyModeUsed": false "applyModeUsed": false,
"launcherRollback": "RolledBack",
"exactViewportMembership": "not-verified"
} }
@@ -3,20 +3,44 @@
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`.
The Ortho sheet did not expose direct Piping rows through The active Ortho sheet does not expose direct Piping rows and must not be used
`DataLinksManager.SelectAcPpRowIds`; that API threw `DLException: NotFound`. as a DataLinks source. Metadata inspection and a sanitized runtime probe
The adapter now converts this to an empty read-only result. Consequently the confirmed `IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName` as the source-model
run read zero source Plant objects and produced an empty plan. Mapping the reference. The adapter resolves that reference through
Ortho sheet to source-model objects remains the blocker for non-empty `PipingProject.GetPnPDrawingFiles()` and reads
AnnotationPlan validation. `DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`.
The test fixture SHA baseline changed for `Piping.dcf` before the first DryRun On the paired real DryRun, one source drawing was resolved and seven source
command. This run therefore does not prove DCF invariance, although no plugin Plant objects were read. The confirmed view query resolved five source rows to
write operation was performed and DBMOD remained unchanged. the active viewport and classified two as outside the view. Each command
reported seven skipped operations, zero writes, zero conflicts, and zero
errors. The repeated plans were deterministic:
| Command | Run 1 / run 2 PlanId |
| --- | --- |
| `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`.
@@ -0,0 +1,39 @@
# Ortho source-model mapping
## Confirmed Plant 3D 2026 read path
```text
Active Ortho document
→ OrthoProject.DwgSheetDefinitions
→ IDwgSheetDef.DwgViewDefs
→ IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
→ PipingProject.GetPnPDrawingFiles()
→ exact normalized-path match to PnPProjectDrawing
→ PnPProjectDrawing.Id
→ DataLinksManager.SelectAcPpRowIds(int dwgid)
→ GetObjectClassname / GetAllProperties
→ independent PlantObjectSnapshot
→ AnnotationPlanBuilder
```
`PlantOrthoViewportReader.ReadViewports` reads the typed DwgQuery contract and
creates an Ortho viewport snapshot. `PlantObjectReader.ReadObjects` resolves
the resulting model-file reference through the Piping project drawing list and
reads DataLinks by project drawing ID. The active Ortho database is not a
Piping-row source.
All calls are read-only. The adapter opens no write transaction and does not
save a drawing, DCF, or project metadata.
## Diagnostics
The source reader returns stable telemetry diagnostics rather than suppressing
the result of a failed query: `ORTHO_SOURCE_DRAWING_NOT_RESOLVED`,
`ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT`, `ORTHO_SOURCE_DRAWING_AMBIGUOUS`,
`PIPING_PROJECT_PART_NOT_FOUND`, `SOURCE_DATALINKS_UNAVAILABLE`,
`SOURCE_DATALINKS_NO_ROWS`, `SOURCE_DATABASE_READ_FAILED`,
`SOURCE_PIPE_ROWS_NOT_FOUND`, or `SOURCE_DATALINKS_READ`.
Exact membership of a source object in a particular Ortho viewport is not yet
implemented. A source-level DryRun plan must not be presented as proof of
viewport membership.
@@ -0,0 +1,36 @@
# Ortho viewport membership
## Runtime path
`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,101 @@
# Plant 3D 2026 DwgQuery / ModelFiles API contract
## Scope and method
This report is based on metadata-only inspection of the installed Plant 3D 2026
assemblies. The assemblies were read from `<PLANT3D_2026_INSTALL>` and were not
copied into the repository. Metadata-only inspection was necessary because the
normal PowerShell host cannot load the product's .NET 8 dependencies safely.
Legacy source at the documented local path was not present on this machine, so
there is no legacy API claim in this report.
## Confirmed assemblies
| Assembly | Version | Relevant type |
| --- | --- | --- |
| `PnPDwg2dDef` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.DwgQuery`, `IDwgQuery`, `DwgViewDef`, `IDwgViewDef`, `DwgSheetDef`, `IDwgSheetDef` |
| `PnPDwg2dUtil` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.ModelFile` |
| `PnP3dOrthoProjectPart` | `17.0.60.1` | Ortho project-part integration assembly; the metadata scan found no public DwgQuery declaration there |
`PnPDwg2dDef` and `PnPDwg2dUtil` use `0.0.0.0` assembly versions in the
installed metadata. The supported runtime contract is therefore identified by
the exact public type names below, not by an invented product-version number.
## Confirmed contract
```text
Autodesk.ProcessPower.Drawings2d.IDwgViewDef.DwgQuery
: Autodesk.ProcessPower.Drawings2d.IDwgQuery
Autodesk.ProcessPower.Drawings2d.IDwgQuery.ModelFiles
: Autodesk.ProcessPower.Drawings2d.ModelFile[]
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileName
: System.String
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileGuid
: System.String
```
`DwgViewDef` implements `IDwgViewDef`; `DwgQuery` implements `IDwgQuery` and
`IDisposable`; `ModelFile` implements `IComparable<ModelFile>`.
`ModelFiles` is an array, not an untyped enumerable. `ModelFileName` is the
only confirmed string field representing the model-file reference. `ModelFileGuid`
is a separate identity field. `IsReferencedDwg` and `Xform` describe reference
state and transform, not a substitute path.
## Public members relevant to source resolution
`IDwgViewDef` exposes `DwgQuery`, `ViewName`, layout/orientation properties,
and `SetClippingPlanes`, `SetLayout`, `SetOrientation`, `Erase`, and `New`.
`IDwgQuery` exposes `ModelFiles`, `Execute`, and `Erase`.
`ModelFile` exposes `ModelFileName`, `ModelFileGuid`, `IsReferencedDwg`, and
`Xform`; its constructors accept a file-name string and optional transform,
reference flag, and GUID.
`IDwgSheetDef.DwgViewDefs` exposes the view-definition collection. Sheet
`DwgName`/`DwgPath` identify the Ortho sheet and must not be used as the source
of Piping DataLinks.
## Current and legacy usage
The original adapter used reflection and tried several possible names
(`ModelFileName`, `Value`, and arbitrary string values). That was not a valid
Plant 3D 2026 contract.
The production read path now uses the confirmed typed chain only:
```text
IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
```
The legacy source path was unavailable locally, so no legacy source-model or
DataLinks workaround has been imported.
## Runtime evidence
Static metadata cannot prove whether `ModelFileName` is project-relative or
absolute in a concrete Ortho fixture, or whether a particular view has zero,
one, or multiple model files. In DryRun only, setting
`P_REC_ANNOT_DIAGNOSTICS=OrthoStructure` enables a sanitized structure probe.
It records type names, collection counts, null state, string length, and a
one-way value hash; it does not emit paths or engineering data.
On the local DryRun fixture, the runtime probe recorded one view with one
`ModelFile`. The concrete types were `DwgViewDef`, `DwgQueryByBox`, and
`ModelFile[]`; `ModelFileName` was non-null and an absolute fixture-model
reference. The path itself was not retained in repository evidence.
The resolver resolves that reference to exactly one
`PipingProject.GetPnPDrawingFiles()` entry by normalized full-path equality,
then uses the confirmed `DataLinksManager.SelectAcPpRowIds(int dwgid)` overload
with the resolved `PnPProjectDrawing.Id`. It does not open or query the active
Ortho database for Piping rows.
The production resolver has no fallback property-name search. If the exact
contract is unavailable at runtime it reports
`ORTHO_MODEL_FILES_API_UNSUPPORTED` rather than guessing.
@@ -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.
@@ -58,6 +58,19 @@ public sealed record PlantObjectSnapshot
public required GeometrySnapshot Geometry { get; init; } public required GeometrySnapshot Geometry { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>(); public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>();
public ViewMembershipStatus MembershipStatus { get; init; } = ViewMembershipStatus.NotResolved;
public IReadOnlyList<string> MembershipEvidenceCodes { get; init; } = Array.Empty<string>();
}
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
@@ -164,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
@@ -206,6 +247,7 @@ public sealed record AnnotationIntent
public sealed record AnnotationPlan public sealed record AnnotationPlan
{ {
public int PlanSchemaVersion { get; init; }
public string PlanId { get; init; } = string.Empty; public string PlanId { get; init; } = string.Empty;
public string CommandName { get; init; } = string.Empty; public string CommandName { get; init; } = string.Empty;
public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>(); public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>();
@@ -6,6 +6,7 @@ namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{ {
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();
@@ -30,6 +31,65 @@ 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)
{
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;
}
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim(); var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
@@ -84,7 +144,8 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
return new AnnotationPlan return new AnnotationPlan
{ {
PlanId = BuildPlanId(request, objects), PlanSchemaVersion = CurrentPlanSchemaVersion,
PlanId = BuildPlanId(request, objects, operations),
CommandName = request.CommandName, CommandName = request.CommandName,
Operations = operations, Operations = operations,
Diagnostics = diagnostics, Diagnostics = diagnostics,
@@ -92,18 +153,41 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
Create = creates, Create = creates,
Update = updates, Update = updates,
Delete = Array.Empty<string>(), Delete = Array.Empty<string>(),
SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).ToArray(), SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).OrderBy(x => x, StringComparer.Ordinal).ToArray(),
ExaminedCount = objects.Length, ExaminedCount = objects.Length,
IsDeterministic = true IsDeterministic = true
}; };
} }
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects) private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects, IReadOnlyList<AnnotationOperationPlan> operations)
{ {
var canonical = new StringBuilder(request.CommandName).Append('|').Append(request.Viewport.SourceDrawingName).Append('|').Append(request.Viewport.Id); var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
foreach (var item in objects) .Append("|command=").Append(Normalize(request.CommandName))
canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition); .Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.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))
{
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
.Append('|').Append(Normalize(item.Tag)).Append('|').Append(Normalize(item.LineNumber))
.Append('|').Append(item.MembershipStatus);
foreach (var property in item.Properties.OrderBy(x => x.Key, StringComparer.Ordinal))
canonical.Append("|property=").Append(Normalize(property.Key)).Append('=').Append(Normalize(property.Value));
}
foreach (var operation in operations.OrderBy(x => x.SourceSnapshotId, StringComparer.Ordinal).ThenBy(x => x.Kind))
canonical.Append("|operation=").Append(Normalize(operation.SourceSnapshotId)).Append(':').Append(operation.Kind).Append(':').Append(string.Join(',', operation.Reasons.OrderBy(x => x, StringComparer.Ordinal)));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
return "plan-" + Convert.ToHexString(hash)[..24]; return "plan-" + Convert.ToHexString(hash)[..24];
} }
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"
};
} }
@@ -12,7 +12,8 @@ public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
ObjectClass = item.ClassName, ObjectClass = item.ClassName,
Tag = item.Tag, Tag = item.Tag,
LineNumber = item.LineNumber, LineNumber = item.LineNumber,
Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation } Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation },
MembershipStatus = ViewMembershipStatus.Resolved
}).ToArray(); }).ToArray();
return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
@@ -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];
}
@@ -14,6 +14,8 @@ public sealed class EntryPoint : IExtensionApplication
{ {
RuntimeDiagnostics.Write("AssemblyInitializeStarted"); RuntimeDiagnostics.Write("AssemblyInitializeStarted");
RibbonIntegration.Register(); RibbonIntegration.Register();
// Test automation is driven only by the explicitly generated /b script.
// Initialize remains production-equivalent and must not orchestrate commands.
RuntimeDiagnostics.Write("AssemblyInitializeCompleted"); RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
} }
@@ -33,6 +35,21 @@ public sealed class EntryPoint : IExtensionApplication
[CommandMethod("PXP-OA")] [CommandMethod("PXP-OA")]
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA"); public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
[CommandMethod("PHP_REC_AUTOMATION_START", CommandFlags.Session)]
public void AutomationStartCommand() => FirstLimitedApplyAutomationBootstrap.Arm();
// Test-only command-queue barrier. It deliberately performs no Plant or
// drawing work; its CommandEnded event proves the activated document is
// ready before the coordinator calls the Plant APIs.
[CommandMethod("PHP_REC_AUTOMATION_READY")]
public void AutomationReadyCommand() { }
[CommandMethod("PHP_REC_AUTOMATION_PREPARE", CommandFlags.Session)]
public void AutomationPrepareCommand() => ScriptDrivenFirstApplyAutomation.Prepare();
[CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)]
public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview();
private static void RunDiagnostic(string commandName) private static void RunDiagnostic(string commandName)
{ {
var operationId = Guid.NewGuid().ToString("N"); var operationId = Guid.NewGuid().ToString("N");
@@ -43,7 +60,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,12 +91,44 @@ 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;
var automationTarget = FirstLimitedApplyAutomationCoordinator.SelectedTargetSnapshotId;
var limitedTarget = applyPermit?.TargetSnapshotId ?? automationTarget;
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>>();
foreach (var viewport in context.Viewports) foreach (var viewport in context.Viewports)
{ {
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None); var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects })); objectsByViewport.Add(objects);
plans.Add(builder.Build(new AnnotationPlanningRequest
{
CommandName = commandName,
Viewport = viewport,
Objects = objects,
LimitedApplyTargetSnapshotId = limitedTarget,
LimitedApplyRuleId = limitedTarget 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
@@ -94,17 +142,59 @@ public sealed class EntryPoint : IExtensionApplication
WarningCount = plans.Sum(x => x.Summary.WarningCount), WarningCount = plans.Sum(x => x.Summary.WarningCount),
ErrorCount = plans.Sum(x => x.Summary.ErrorCount) ErrorCount = plans.Sum(x => x.Summary.ErrorCount)
}; };
var membershipResolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.Resolved);
var membershipPartial = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.PartiallyResolved);
var membershipUnresolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.NotResolved);
var membershipOutside = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.ObjectOutsideView);
var planId = plans.Count == 1 ? plans[0].PlanId : null;
details["ObjectsExamined"] = summary.ObjectsExamined; details["ObjectsExamined"] = summary.ObjectsExamined;
details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
details["PlanId"] = planId;
details["MembershipResolved"] = membershipResolved;
details["MembershipPartial"] = membershipPartial;
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 (string.Equals(commandName, "PXP-OA", StringComparison.OrdinalIgnoreCase))
{
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();
ScriptDrivenFirstApplyAutomation.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null, summary.ObjectsExamined, membershipResolved);
FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null);
}
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);
FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome);
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 не изменён.");
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds); WriteEditor(document, $"\nPlan schema: {AnnotationPlanBuilder.CurrentPlanSchemaVersion}; PlanId: {planId ?? "multiple"}; membership resolved/partial/outside/unresolved: {membershipResolved}/{membershipPartial}/{membershipOutside}/{membershipUnresolved}.");
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details); var completedStatus = mode == PRecAnnotExecutionMode.Apply ? "ApplyCompleted" : "DryRunCompleted";
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,495 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager;
using PRecAnnot.Abstractions;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
// Test-only coordinator. It is not registered in Ribbon and is inert unless
// the explicit NextPilot automation environment and local marker are present.
internal sealed record FirstApplyAutomationConfiguration
{
public required string Phase { get; init; }
public string? OrthoDrawingPath { get; init; }
public string? ModelDrawingPath { get; init; }
public required string EvidencePath { get; init; }
public required string FixtureMarkerPath { get; init; }
}
internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned);
internal static class FirstLimitedApplyAutomationBootstrap
{
private enum BootstrapState { Created, Prepared, Armed, Starting, Completed }
private static BootstrapState state;
private static bool observedIdle;
private static int idleCallbackCount;
private static int coordinatorStartCount;
internal static int IdleCallbackCount => idleCallbackCount;
internal static int CoordinatorStartCount => coordinatorStartCount;
// Called from Initialize: BCL gates and one lightweight subscription only.
internal static void Prepare()
{
if (state != BootstrapState.Created) return;
state = BootstrapState.Prepared;
AcadApplication.Idle += OnIdle;
RuntimeDiagnostics.Write("AutomationBootstrapSubscribed", resultStatus: "Prepared");
}
// Public test command is intentionally side-effect free with respect to Plant APIs.
internal static void Arm()
{
if (state is BootstrapState.Prepared)
{
// The command itself is the post-NETLOAD barrier. The next Idle
// callback, not this command callback, is allowed to start work.
observedIdle = true;
state = BootstrapState.Armed;
}
}
private static void OnIdle(object? sender, EventArgs args)
{
try
{
idleCallbackCount++;
RuntimeDiagnostics.Write("AutomationBootstrapIdle", resultStatus: state.ToString());
// The script engine can consume the custom command after NETLOAD. The
// first idle is therefore the explicit post-NETLOAD arming point.
if (state == BootstrapState.Prepared) { observedIdle = true; state = BootstrapState.Armed; }
if (!observedIdle || state != BootstrapState.Armed) return;
state = BootstrapState.Starting;
AcadApplication.Idle -= OnIdle;
coordinatorStartCount++;
RuntimeDiagnostics.Write("AutomationCoordinatorStarting", resultStatus: "FirstPostStartupIdle");
new FirstLimitedApplyAutomationCoordinator().Start();
}
catch (System.Exception exception)
{
state = BootstrapState.Completed;
AcadApplication.Idle -= OnIdle;
FirstLimitedApplyAutomationCoordinator.WriteBootstrapFailure(exception);
}
}
}
internal sealed record FirstApplyAutomationEvidence
{
public int SchemaVersion { get; init; } = 1;
public required string Phase { get; init; }
public bool Completed { get; init; }
public bool? Prepared { get; init; }
public bool? FunctionalEvidenceCompleted { get; init; }
public string? ErrorCode { get; init; }
public int? DbmodBefore { get; init; }
public int? DbmodAfterCommand1 { get; init; }
public int? DbmodAfterCommand2 { get; init; }
public int? DbmodAfterUndo { get; init; }
public AutomationEntityCounts? Baseline { get; init; }
public AutomationEntityCounts? AfterCommand1 { get; init; }
public AutomationEntityCounts? AfterCommand2 { get; init; }
public AutomationEntityCounts? AfterUndo { get; init; }
public IReadOnlyList<string> CompletedCommands { get; init; } = Array.Empty<string>();
public string? PlanIdRun1 { get; init; }
public string? PlanIdRun2 { get; init; }
public bool? PlanIdsEqual { get; init; }
public int? EligibleCandidateCount { get; init; }
public string? SelectedCandidateHash { get; init; }
public string? ViewHash { get; init; }
public int? CreateRun1 { get; init; }
public int? CreateRun2 { get; init; }
public int? SkipRun2 { get; init; }
public string? ApplyOutcomeRun1 { get; init; }
public string? ApplyOutcomeRun2 { get; init; }
public bool? PermitAccepted { get; init; }
public bool? CommandsCompleted { get; init; }
public bool? InspectorsCompleted { get; init; }
public bool? EvidenceFlushed { get; init; }
public bool? ExitAttemptQueued { get; init; }
public bool? QuitCommandWillStart { get; init; }
public bool? QuitCommandEnded { get; init; }
public string? ExitFailureCode { get; init; }
public int? IdleCallbackCount { get; init; }
public int? CoordinatorStartCount { get; init; }
// Local test evidence only. This is intentionally never copied into Git
// evidence because exception stacks may contain machine-specific paths.
public string? ExceptionText { get; init; }
public string? PreviousState { get; init; }
public string? CurrentState { get; init; }
public IReadOnlyList<string> StateTransitions { get; init; } = Array.Empty<string>();
}
internal sealed class FirstLimitedApplyAutomationCoordinator
{
private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued }
private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome);
private static string? selectedTargetSnapshotId;
private static readonly List<PxpResult> pxpResults = [];
internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId;
internal static void WriteBootstrapFailure(System.Exception exception) =>
RuntimeDiagnostics.Write("AutomationBootstrapFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception);
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted)
{
if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal))
pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted, null));
}
internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome)
{
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || pxpResults.Count == 0) return;
var last = pxpResults[^1];
pxpResults[^1] = last with { ApplyOutcome = outcome.ToString() };
}
private FirstApplyAutomationConfiguration? config;
private Document? document;
private readonly List<Document> automationOpenedDocuments = [];
private readonly List<string> completed = [];
private int step;
private int? before, after1, after2, afterUndo;
private AutomationEntityCounts? baseline, counts1, counts2, countsUndo;
private readonly List<string> transitions = ["Created"];
private CoordinatorState state = CoordinatorState.Created;
private CoordinatorState previousState = CoordinatorState.Created;
private bool documentHandlersSubscribed;
private bool lifecycleHandlersSubscribed;
private bool fixtureDocumentActivated;
private bool projectActivationStarted;
private bool exitQueued;
private bool exitHandlerSubscribed;
private string? exceptionText;
private bool functionalCompleted;
private bool evidenceFlushed;
private bool exitAttemptQueued;
private string? exitFailureCode;
public void Start()
{
try
{
Transition(CoordinatorState.Starting, "PostStartupReady");
config = ReadConfiguration();
if (string.Equals(config.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase))
{
CompleteLifecycleProbe();
return;
}
if (string.IsNullOrWhiteSpace(config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
// A first startup Idle is sufficient to arm automation, but not to
// call Plant APIs. Establish an AutoCAD document first and wait for
// its activation plus a quiescent callback before project activation.
AcadApplication.DocumentManager.DocumentActivated += OnDocumentActivated;
AcadApplication.Idle += OnIdle;
lifecycleHandlersSubscribed = true;
if (!string.IsNullOrWhiteSpace(config.ModelDrawingPath)) automationOpenedDocuments.Add(AcadApplication.DocumentManager.Open(config.ModelDrawingPath, false));
transitions.Add("DocumentOpenStarted");
RuntimeDiagnostics.Write("AutomationBeforeDocumentOpen", resultStatus: "PostStartupReady");
document = AcadApplication.DocumentManager.Open(config.OrthoDrawingPath, false);
automationOpenedDocuments.Add(document);
AcadApplication.DocumentManager.MdiActiveDocument = document;
transitions.Add("DocumentOpened");
RuntimeDiagnostics.Write("AutomationAfterDocumentOpen", resultStatus: "WaitingForDocumentActivation", document: document);
document.CommandEnded += OnCommandEnded;
document.CommandFailed += OnCommandFailed;
document.CommandCancelled += OnCommandCancelled;
documentHandlersSubscribed = true;
Transition(CoordinatorState.Running, "AutomationRunning");
// DocumentActivated can be raised synchronously by Open before the
// coordinator receives the returned Document. Confirm the current
// active document after assignment so that one missed event cannot
// strand the workflow waiting for another startup Idle.
// Open was requested for this exact local fixture and it has just
// been made MDI-active. Queue a no-op public command regardless of
// wrapper identity/name normalisation; CommandEnded is the actual
// readiness proof before any Plant API is used.
fixtureDocumentActivated = true;
transitions.Add("FixtureDocumentActiveAfterOpen");
QueueReadinessBarrier();
}
catch (System.Exception ex) { FailFromCallback("Start", ex, "AUTOMATION_START_FAILED"); }
}
private void CompleteLifecycleProbe()
{
transitions.Add("LifecycleProbeCompleted");
WriteEvidence(true, null);
// LifecycleProbe is already running from the first verified Idle callback.
// Do not wait for a second Idle: a startup-script host is permitted not to
// raise one, which otherwise leaves acad.exe responsive but stranded.
RequestControlledExit(idleSafe: true);
}
private void OnIdle(object? sender, EventArgs e) => RunCallback("Idle", TryActivateProjectAndVerifyContext);
private void OnDocumentActivated(object sender, DocumentCollectionEventArgs e)
{
RunCallback("DocumentActivated", () =>
{
if (document is null || e.Document is null) return;
if (!string.Equals(e.Document.Name, document.Name, StringComparison.OrdinalIgnoreCase)) return;
fixtureDocumentActivated = true;
transitions.Add("FixtureDocumentActivated");
TryActivateProjectAndVerifyContext();
});
}
private void TryActivateProjectAndVerifyContext()
{
if (IsTerminal || document is null || !fixtureDocumentActivated || projectActivationStarted) return;
if (!AcadApplication.IsQuiescent) return;
projectActivationStarted = true;
AcadApplication.Idle -= OnIdle;
AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated;
lifecycleHandlersSubscribed = false;
try
{
transitions.Add("ProjectActivationStarted");
RuntimeDiagnostics.Write("AutomationBeforeProjectActivation", resultStatus: "DocumentActivatedAndQuiescent", document: document);
var projectXml = Path.Combine(Path.GetDirectoryName(config!.FixtureMarkerPath)!, "Project.xml");
var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty);
PlantApplication.SetCurrentProject(project, true);
transitions.Add("ProjectActivated");
RuntimeDiagnostics.Write("AutomationAfterProjectActivation", resultStatus: "ProjectAvailable", document: document);
transitions.Add("ContextVerificationStarted");
var context = new PlantRuntimeContextReader().ReadCurrentContext(document);
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) { Complete(false, $"AUTOMATION_ORTHO_CONTEXT_INVALID:P={context.HasPlantProject};O={context.IsOrthoDrawing};V={context.Viewports.Count}"); return; }
var candidates = new PlantObjectReader().ReadObjects(context.Viewports.Single(), CancellationToken.None)
.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();
if (candidates.Length == 0) { Complete(false, "AUTOMATION_NO_ELIGIBLE_CANDIDATE"); return; }
selectedTargetSnapshotId = candidates[0];
transitions.Add("ContextVerified");
before = Dbmod(); baseline = Counts();
Queue("PXP-OA");
}
catch (System.Exception ex) { FailFromCallback("ContextVerification", ex, "AUTOMATION_CONTEXT_FAILED"); }
}
private void Queue(string command) => _ = DispatchCommandAsync(command, false);
private void QueueReadinessBarrier()
{
_ = DispatchCommandAsync("PHP_REC_AUTOMATION_READY", true);
}
private async Task DispatchCommandAsync(string command, bool readiness)
{
if (IsTerminal || document is null) return;
var expected = document;
transitions.Add(readiness ? "ReadinessCommandDispatchRequested" : "CommandDispatchRequested:" + command);
try
{
await AcadApplication.DocumentManager.ExecuteInCommandContextAsync(async _ =>
{
transitions.Add("CommandContextEntered:" + command);
var active = AcadApplication.DocumentManager.MdiActiveDocument;
if (active is null || !string.Equals(active.Name, expected.Name, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE");
transitions.Add(readiness ? "ReadinessBarrierQueued" : "CommandQueued:" + command);
await active.Editor.CommandAsync(command);
transitions.Add("CommandInvocationReturned:" + command);
}, null);
}
catch (System.Exception exception) { FailFromCallback("CommandDispatcher", exception, "AUTOMATION_COMMAND_CONTEXT_FAILED"); }
}
private void OnCommandEnded(object sender, CommandEventArgs e)
{
RunCallback("CommandEnded", () =>
{
var name = e.GlobalCommandName?.Trim().TrimStart('.', '_').ToUpperInvariant();
if (name == "PHP_REC_AUTOMATION_READY")
{
transitions.Add("ReadinessBarrierCompleted");
TryActivateProjectAndVerifyContext();
return;
}
if (name is not ("PXP-OA" or "UNDO" or "U")) return;
completed.Add(name);
transitions.Add("CommandCompleted:" + name);
if (step++ == 0) { after1 = Dbmod(); counts1 = Counts(); Queue("PXP-OA"); return; }
if (step == 2 && string.Equals(config?.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { after2 = Dbmod(); counts2 = Counts(); Queue("_.UNDO _1"); return; }
if (string.Equals(config?.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { afterUndo = Dbmod(); countsUndo = Counts(); Complete(true, null, safeToExitNow: true); }
else { after2 = Dbmod(); counts2 = Counts(); Complete(true, null, safeToExitNow: true); }
});
}
private void OnCommandFailed(object sender, CommandEventArgs e) => RunCallback("CommandFailed", () => Complete(false, "AUTOMATION_COMMAND_FAILED:" + e.GlobalCommandName));
private void OnCommandCancelled(object sender, CommandEventArgs e) => RunCallback("CommandCancelled", () => Complete(false, "AUTOMATION_COMMAND_CANCELLED:" + e.GlobalCommandName));
private int Dbmod() => Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD"));
private AutomationEntityCounts Counts()
{
if (document is null) throw new InvalidOperationException("AUTOMATION_DOCUMENT_UNAVAILABLE");
using var tx = document.Database.TransactionManager.StartOpenCloseTransaction();
var table = (BlockTable)tx.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)tx.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForRead);
var total = 0; var pipes = 0; var xdata = 0; var owned = 0;
foreach (ObjectId id in paper)
{
total++;
if (tx.GetObject(id, OpenMode.ForRead) is BlockReference block && string.Equals(block.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++;
if (tx.GetObject(id, OpenMode.ForRead) is Entity entity)
{
using var data = entity.GetXDataForApplication("PXP-OA");
if (data is not null) { xdata++; if (data.AsArray().Any(x => x.TypeCode == 1000)) owned++; }
}
}
return new(total, pipes, xdata, owned);
}
private FirstApplyAutomationConfiguration ReadConfiguration()
{
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_DISABLED");
if (!string.Equals(RuntimeDiagnostics.RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) && !string.Equals(RuntimeDiagnostics.RuntimeMode, "Apply", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_MODE_BLOCKED");
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_CONFIGURATION_REQUIRED");
var loaded = JsonSerializer.Deserialize<FirstApplyAutomationConfiguration>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("AUTOMATION_CONFIGURATION_INVALID");
if (!File.Exists(loaded.FixtureMarkerPath) || !string.Equals(File.ReadAllText(loaded.FixtureMarkerPath).Trim(), "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_FIXTURE_MARKER_INVALID");
if (!string.Equals(loaded.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase) &&
(string.IsNullOrWhiteSpace(loaded.OrthoDrawingPath) || !Path.GetFullPath(loaded.OrthoDrawingPath).StartsWith(Path.GetDirectoryName(Path.GetFullPath(loaded.FixtureMarkerPath))! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("AUTOMATION_FIXTURE_OUTSIDE_MARKER_ROOT");
return loaded;
}
private void Complete(bool success, string? error, bool safeToExitNow = false)
{
if (IsTerminal) return;
functionalCompleted = success;
Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed");
UnsubscribeHandlers();
TryWriteEvidence(success, error);
selectedTargetSnapshotId = null;
pxpResults.Clear();
RequestControlledExit(safeToExitNow);
}
// The managed Application.Quit call can leave a startup-script host alive
// when it is invoked from a CommandEnded callback. Queue AutoCAD's native
// QUIT command instead; the command processor runs it after the current
// public PXP-OA command has fully returned and discards every open drawing.
private void RequestControlledExit(bool idleSafe = false)
{
if (exitQueued) return;
exitQueued = true;
Transition(CoordinatorState.ExitQueued, "ExitQueued");
if (idleSafe)
{
DispatchNativeQuit();
return;
}
AcadApplication.Idle += OnExitIdle;
exitHandlerSubscribed = true;
}
private void OnExitIdle(object? sender, EventArgs args)
{
RunCallback("ControlledExitIdle", DispatchNativeQuit);
}
private void DispatchNativeQuit()
{
try
{
if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle;
exitHandlerSubscribed = false;
exitAttemptQueued = true;
if (!AcadApplication.IsQuiescent)
{
transitions.Add("QuitDispatchDeferredNotQuiescent");
TryWriteEvidence(functionalCompleted, null);
return;
}
// LifecycleProbe opens no fixture document, but the real startup
// document is available at this point (observed as Drawing1.dwg).
// Use that live document only to queue native QUIT; never retain it
// past this callback or use it for fixture/Plant work.
var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;
if (exitDocument is null)
{
exitFailureCode = "AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE";
transitions.Add("QuitDispatchSkippedNoLiveDocument");
TryWriteEvidence(functionalCompleted, null);
return;
}
transitions.Add("QuitDispatchAttempted");
// Persist the exit boundary before handing control back to AutoCAD;
// process termination can otherwise prevent the final evidence flush.
TryWriteEvidence(functionalCompleted, null);
exitDocument.SendStringToExecute("_.QUIT _N\n", true, true, false);
document = null;
automationOpenedDocuments.Clear();
}
catch (System.Exception exception)
{
// This is terminal cleanup: contain the exception and leave process
// cleanup to Launcher rather than re-entering document APIs.
// A failed graceful-exit dispatch is not a failure of already
// completed PXP-OA/UNDO work. Launcher owns bounded forced cleanup.
exitFailureCode = "AUTOMATION_CONTROLLED_EXIT_FAILED:" + exception.GetType().Name;
transitions.Add("QuitDispatchFailed");
TryWriteEvidence(functionalCompleted, null);
}
}
private bool IsTerminal => state is CoordinatorState.Completed or CoordinatorState.Failed or CoordinatorState.ExitQueued;
private void Transition(CoordinatorState next, string transition)
{
previousState = state;
state = next;
transitions.Add(transition);
}
private void RunCallback(string callback, Action action)
{
if (IsTerminal && callback != "ControlledExitIdle") return;
try { action(); }
catch (System.Exception exception) { FailFromCallback(callback, exception, "AUTOMATION_CALLBACK_FAILED"); }
}
private void FailFromCallback(string callback, System.Exception exception, string code)
{
exceptionText = exception.ToString();
RuntimeDiagnostics.Write("AutomationCallbackFailed", "ERROR", resultStatus: code + ":" + callback, exception: exception);
if (!IsTerminal) Complete(false, code + ":" + callback + ":" + exception.GetType().Name);
else
{
UnsubscribeHandlers();
if (functionalCompleted)
{
exitFailureCode = code + ":" + callback + ":" + exception.GetType().Name;
transitions.Add("ExitCallbackFailed");
TryWriteEvidence(true, null);
}
else TryWriteEvidence(false, code + ":" + callback + ":" + exception.GetType().Name);
}
}
private void UnsubscribeHandlers()
{
if (documentHandlersSubscribed && document is not null)
{
try { document.CommandEnded -= OnCommandEnded; document.CommandFailed -= OnCommandFailed; document.CommandCancelled -= OnCommandCancelled; }
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
}
documentHandlersSubscribed = false;
if (lifecycleHandlersSubscribed)
{
try { AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated; AcadApplication.Idle -= OnIdle; }
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
}
lifecycleHandlersSubscribed = false;
}
private bool TryWriteEvidence(bool success, string? error)
{
try { evidenceFlushed = true; WriteEvidence(success, error); return true; }
catch (System.Exception exception) { evidenceFlushed = false; RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); return false; }
}
private void WriteEvidence(bool success, string? error)
{
if (config is null) return;
var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1);
var expectedCommands = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) ? 3 : 2;
var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = completed.Count >= expectedCommands, InspectorsCompleted = baseline is not null && counts1 is not null && counts2 is not null && (!string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) || countsUndo is not null), EvidenceFlushed = evidenceFlushed, ExitAttemptQueued = exitAttemptQueued, ExitFailureCode = exitFailureCode, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, ExceptionText = exceptionText, PreviousState = previousState.ToString(), CurrentState = state.ToString(), StateTransitions = transitions };
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence));
}
}
@@ -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;
}
}
@@ -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<string, object?>
{
["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<IReadOnlyDictionary<string, object?>>()
};
RuntimeDiagnostics.Write("OrthoRuntimeStructureInspected", resultStatus: "Captured", details: details);
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("OrthoRuntimeStructureInspectionFailed", "WARNING", resultStatus: "Failed", exception: exception);
}
}
private static IReadOnlyDictionary<string, object?> DescribeModelFile(ModelFile? modelFile)
{
if (modelFile is null)
{
return new Dictionary<string, object?> { ["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<string, object?>
{
["RuntimeType"] = TypeName(modelFile.GetType()),
["Properties"] = properties
};
}
private static IReadOnlyDictionary<string, object?> DescribeProperty(ModelFile modelFile, PropertyInfo property)
{
object? value;
try { value = property.GetValue(modelFile); }
catch { value = null; }
return new Dictionary<string, object?>
{
["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];
}
@@ -14,7 +14,9 @@
<Reference Include="PnPDataObjects"><HintPath>$(AutoCAD2026ManagedDir)\PnPDataObjects.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference> <Reference Include="PnPDataObjects"><HintPath>$(AutoCAD2026ManagedDir)\PnPDataObjects.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPProjectManagerMgd"><HintPath>$(Plant3D2026ManagedDir)\PnPProjectManagerMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference> <Reference Include="PnPProjectManagerMgd"><HintPath>$(Plant3D2026ManagedDir)\PnPProjectManagerMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dObjectsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dObjectsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference> <Reference Include="PnP3dObjectsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dObjectsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dProjectPartsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dProjectPartsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dOrthoProjectPart"><HintPath>$(Plant3D2026ManagedDir)\PnP3dOrthoProjectPart.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference> <Reference Include="PnP3dOrthoProjectPart"><HintPath>$(Plant3D2026ManagedDir)\PnP3dOrthoProjectPart.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPDwg2dDef"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dDef.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference> <Reference Include="PnPDwg2dDef"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dDef.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPDwg2dUtil"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dUtil.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,8 +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.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;
@@ -13,9 +16,11 @@ namespace PRecAnnot.Plant3D2026;
internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
{ {
public PlantRuntimeContext ReadCurrentContext() public PlantRuntimeContext ReadCurrentContext() => ReadCurrentContext(null);
public PlantRuntimeContext ReadCurrentContext(Document? explicitDocument = null)
{ {
var document = AcadApplication.DocumentManager.MdiActiveDocument; var document = explicitDocument ?? AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) if (document is null)
{ {
return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует."); return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует.");
@@ -151,50 +156,136 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
return "vp-" + Convert.ToHexString(hash)[..20]; 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 // Confirmed Plant 3D 2026 contract (PnPDwg2dDef/PnPDwg2dUtil):
// the authoritative source-model references; sheet.DwgName is the Ortho // IDwgViewDef.DwgQuery -> IDwgQuery.ModelFiles -> ModelFile.ModelFileName.
// sheet and must not be used for Piping DataLinks. // The Ortho sheet is never used as a Piping DataLinks source.
try try
{ {
var query = view.GetType().GetProperty("DwgQuery")?.GetValue(view); if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic))
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 RuntimeDiagnostics.Write("OrthoModelFilesContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details);
?? file?.GetType().GetProperty("ModelFileName")?.GetValue(file)?.ToString() return null;
?? file?.GetType().GetProperty("Value")?.GetValue(file)?.ToString();
if (!string.IsNullOrWhiteSpace(value)) return value;
} }
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; 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<string, object?>
{
["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<string, object?> Details);
internal sealed class PlantObjectReader : IPlantObjectReader 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; if (string.IsNullOrWhiteSpace(viewport.SourceDrawingName))
if (document is null || string.IsNullOrWhiteSpace(viewport.SourceDrawingName)) return Array.Empty<PlantObjectSnapshot>(); {
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
PipingProject? pipingProject;
try { pipingProject = PlantApplication.ProjectPart("Piping") as PipingProject; }
catch (Exception exception)
{
WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport, exception);
return Array.Empty<PlantObjectSnapshot>();
}
if (pipingProject is null)
{
WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
var sourceDrawing = ResolveProjectDrawing(pipingProject, viewport.SourceDrawingName);
if (sourceDrawing is null) return Array.Empty<PlantObjectSnapshot>();
DataLinksManager? dataLinks; DataLinksManager? dataLinks;
try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; } try { dataLinks = pipingProject.DataLinksManager; }
catch { return Array.Empty<PlantObjectSnapshot>(); } catch (Exception exception)
if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>(); {
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
if (!Path.IsPathRooted(viewport.SourceDrawingName) || !File.Exists(viewport.SourceDrawingName)) return Array.Empty<PlantObjectSnapshot>(); return Array.Empty<PlantObjectSnapshot>();
}
if (dataLinks is null)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
var snapshots = new List<PlantObjectSnapshot>(); var snapshots = new List<PlantObjectSnapshot>();
try try
{ {
using var sourceDatabase = new Database(false, true); // Confirmed Plant 3D 2026 contract: resolve the source model to the
sourceDatabase.ReadDwgFile(viewport.SourceDrawingName, FileShare.ReadWrite, true, null); // Piping project drawing record, then read its DataLinks by drawing Id.
sourceDatabase.CloseInput(true); // The active Ortho database is deliberately never passed to DataLinks.
foreach (var rowId in dataLinks.SelectAcPpRowIds(sourceDatabase)) var rowIds = dataLinks.SelectAcPpRowIds(sourceDrawing.Id);
if (rowIds.Count == 0)
{
WriteOutcome("SOURCE_DATALINKS_NO_ROWS", viewport, details: new Dictionary<string, object?> { ["ProjectDrawingResolved"] = true });
return Array.Empty<PlantObjectSnapshot>();
}
var membership = PlantViewportMembershipReader.Read(viewport, dataLinks, rowIds);
foreach (var rowId in rowIds)
{ {
try try
{ {
@@ -212,25 +303,85 @@ 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 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 WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
// receives an empty read-only result rather than a command failure.
} }
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<string, object?> { ["SourcePlantObjectsRead"] = snapshots.Count, ["PipeObjectsRead"] = snapshots.Count });
return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray(); 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<string, object?> { ["ProjectDrawingResolved"] = true });
return matches[0];
}
WriteOutcome(matches.Length == 0 ? "ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT" : "ORTHO_SOURCE_DRAWING_AMBIGUOUS", null,
details: new Dictionary<string, object?> { ["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<string, object?>? details = null)
{
var sanitizedDetails = new Dictionary<string, object?>
{
["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) private static string StableId(string drawingName, int rowId, string? className)
{ {
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}")); var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}"));
@@ -238,11 +389,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");
} }
} }
@@ -14,6 +14,10 @@ internal static class RuntimeDiagnostics
public static string RuntimeMode => Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE") ?? "Normal"; 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( public static void Write(
string eventType, string eventType,
string level = "INFO", string level = "INFO",
@@ -0,0 +1,141 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
// Test-only observer. The AutoCAD /b processor, never this type, drives PXP-OA.
internal static class ScriptDrivenFirstApplyAutomation
{
private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath);
private sealed record Run(int Sequence, string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, int? DbmodBefore, int? DbmodAfter, AutomationEntityCounts? Before, AutomationEntityCounts? After, bool WillStart, bool Ended, bool Failed, bool Cancelled, int? SourceRows, int? MembershipResolved);
private static Config? config;
private static Document? fixture;
private static Database? database;
private static AutomationEntityCounts? baseline;
private static int? dbmodBaseline;
private static readonly List<Run> runs = [];
private static readonly List<string> transitions = [];
private static bool prepared;
private static bool terminal;
internal static void Prepare()
{
try
{
if (terminal) return;
config = ReadConfig();
RequireGate(config);
var active = AcadApplication.DocumentManager.MdiActiveDocument ?? throw new InvalidOperationException("AUTOMATION_ACTIVE_DOCUMENT_MISSING");
if (!SamePath(active.Name, config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_UNEXPECTED_ACTIVE_FIXTURE");
fixture = active;
database = active.Database ?? throw new InvalidOperationException("AUTOMATION_FIXTURE_DATABASE_MISSING");
fixture.CommandWillStart += OnWillStart;
fixture.CommandEnded += OnEnded;
fixture.CommandFailed += OnFailed;
fixture.CommandCancelled += OnCancelled;
var projectXml = Path.Combine(Path.GetDirectoryName(config.FixtureMarkerPath)!, "Project.xml");
if (!File.Exists(projectXml)) throw new InvalidOperationException("AUTOMATION_PROJECT_XML_MISSING");
var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty);
PlantApplication.SetCurrentProject(project, true);
var context = new PlantRuntimeContextReader().ReadCurrentContext(fixture);
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1)
throw new InvalidOperationException("AUTOMATION_ORTHO_CONTEXT_INVALID");
dbmodBaseline = Dbmod();
baseline = Counts(database);
prepared = true;
transitions.Add("Prepared");
Write(false, null);
}
catch (System.Exception exception) { Fail("AUTOMATION_PREPARE_FAILED", exception); }
}
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted, int sourceRows, int membershipResolved)
{
if (!prepared || terminal || runs.Count == 0) return;
var last = runs[^1];
if (!last.WillStart || last.PlanId is not null) return;
runs[^1] = last with { PlanId = planId, Candidates = candidates, View = view, Create = create, Skip = skip, SourceRows = sourceRows, MembershipResolved = membershipResolved };
}
internal static void FinalizePreview()
{
try
{
if (!prepared) throw new InvalidOperationException("AUTOMATION_NOT_PREPARED");
if (runs.Count != 2) throw new InvalidOperationException("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA");
if (runs.Any(x => !x.WillStart || !x.Ended || x.Failed || x.Cancelled)) throw new InvalidOperationException("AUTOMATION_PXP_OA_NOT_ENDED");
if (runs.Any(x => string.IsNullOrWhiteSpace(x.PlanId)) || !string.Equals(runs[0].PlanId, runs[1].PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_PLAN_IDS_DIFFER");
if (runs.Any(x => x.Create != 1)) throw new InvalidOperationException("AUTOMATION_PREVIEW_CREATE_NOT_ONE");
if (runs[0].Candidates.Count == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE");
if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED");
terminal = true;
transitions.Add("PreviewCompleted");
Write(true, null);
Unsubscribe();
}
catch (System.Exception exception) { Fail("PREVIEW_ACCEPTANCE_FAILED", exception); }
}
private static void OnWillStart(object sender, CommandEventArgs args)
{
Guard(() =>
{
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return;
runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null));
transitions.Add("PxpOaWillStart:" + runs.Count);
});
}
private static void OnEnded(object sender, CommandEventArgs args) => Finish(sender, args, false, false);
private static void OnFailed(object sender, CommandEventArgs args) => Finish(sender, args, true, false);
private static void OnCancelled(object sender, CommandEventArgs args) => Finish(sender, args, false, true);
private static void Finish(object sender, CommandEventArgs args, bool failed, bool cancelled)
{
Guard(() =>
{
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return;
var last = runs[^1];
if (last.Ended || last.Failed || last.Cancelled) return;
runs[^1] = last with { Ended = !failed && !cancelled, Failed = failed, Cancelled = cancelled, DbmodAfter = Dbmod(), After = Counts(database!) };
transitions.Add(failed ? "PxpOaFailed" : cancelled ? "PxpOaCancelled" : "PxpOaEnded:" + last.Sequence);
Write(false, null);
});
}
private static bool IsFixture(object sender) => !terminal && fixture is not null && ReferenceEquals(sender, fixture) && database is not null && ReferenceEquals(fixture.Database, database);
private static bool IsPxp(string name) => string.Equals(name.TrimStart('.', '_'), "PXP-OA", StringComparison.OrdinalIgnoreCase);
private static void Guard(Action action) { try { if (!terminal) action(); } catch (System.Exception ex) { Fail("AUTOMATION_EVENT_HANDLER_FAILED", ex); } }
private static void Fail(string code, System.Exception exception) { if (terminal) return; terminal = true; transitions.Add("Failed:" + code); try { Write(false, code + ":" + exception); } finally { Unsubscribe(); } }
private static void Unsubscribe() { if (fixture is null) return; try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } }
private static int? Dbmod() { try { return Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD")); } catch { return null; } }
private static AutomationEntityCounts Counts(Database db)
{
using var tx = db.TransactionManager.StartTransaction();
var space = (BlockTableRecord)tx.GetObject(db.CurrentSpaceId, OpenMode.ForRead);
var total = 0; var pipes = 0; var xdata = 0; var owned = 0;
foreach (ObjectId id in space) { if (tx.GetObject(id, OpenMode.ForRead) is not Entity entity) continue; total++; if (entity is BlockReference br && string.Equals(br.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++; if (entity.XData is not null) { foreach (TypedValue value in entity.XData) if (value.Value is string text && string.Equals(text, "PXP-OA", StringComparison.OrdinalIgnoreCase)) { xdata++; owned++; break; } } }
return new(total, pipes, xdata, owned);
}
private static Config ReadConfig()
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG") ?? throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
using var json = JsonDocument.Parse(File.ReadAllText(path)); var root = json.RootElement;
return new(root.GetProperty("phase").GetString() ?? "Preview", root.GetProperty("orthoDrawingPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"), root.GetProperty("evidencePath").GetString() ?? throw new InvalidOperationException("AUTOMATION_EVIDENCE_PATH_REQUIRED"), root.GetProperty("fixtureMarkerPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_MARKER_PATH_REQUIRED"));
}
private static void RequireGate(Config value)
{
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_LAUNCHER_MODE"), "NextPilot", StringComparison.Ordinal) || !File.Exists(value.FixtureMarkerPath)) throw new InvalidOperationException("AUTOMATION_GATE_REJECTED");
}
private static bool SamePath(string left, string right) => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
private static void Write(bool completed, string? error)
{
if (config is null) return;
var first = runs.ElementAtOrDefault(0); var second = runs.ElementAtOrDefault(1);
var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").ToArray(), PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = first?.Candidates.OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault(), ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null, EvidenceFlushed = true, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions };
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true);
}
}
@@ -61,6 +61,170 @@ public sealed class FoundationTests
Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source); Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source);
Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source); Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source);
Assert.Contains("[CommandMethod(\"PXP-OA\")]", source); Assert.Contains("[CommandMethod(\"PXP-OA\")]", source);
Assert.Contains("PHP_REC_AUTOMATION_READY", source);
}
[Fact] public void AutomationBootstrap_DefersPlantAndDocumentAccessUntilCoordinator()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var bootstrap = source[..source.IndexOf("internal sealed record FirstApplyAutomationEvidence", StringComparison.Ordinal)];
Assert.DoesNotContain("PlantProject.LoadProject", bootstrap);
Assert.DoesNotContain("MdiActiveDocument", bootstrap);
Assert.Contains("AcadApplication.Idle += OnIdle", bootstrap);
Assert.Contains("observedIdle = true", bootstrap);
Assert.Contains("coordinatorStartCount++", bootstrap);
}
[Fact] public void AutomationLifecycleProbe_CompletesBeforePlantActivation()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("LifecycleProbe", source);
Assert.True(source.IndexOf("CompleteLifecycleProbe", StringComparison.Ordinal) < source.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal));
Assert.Contains("PostStartupReady", source);
}
[Fact] public void AutomationProjectActivation_WaitsForFixtureDocumentAndQuiescence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var activation = source[source.IndexOf("private void TryActivateProjectAndVerifyContext", StringComparison.Ordinal)..];
Assert.Contains("!fixtureDocumentActivated", activation);
Assert.Contains("!AcadApplication.IsQuiescent", activation);
Assert.True(activation.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal) > activation.IndexOf("AutomationBeforeProjectActivation", StringComparison.Ordinal));
}
[Fact] public void AutomationDocumentActivation_IsSubscribedBeforeFixtureOpen()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.True(source.IndexOf("DocumentActivated += OnDocumentActivated", StringComparison.Ordinal) < source.IndexOf("DocumentManager.Open(config.OrthoDrawingPath", StringComparison.Ordinal));
Assert.Contains("FixtureDocumentActivated", source);
}
[Fact] public void AutomationDocumentActivation_RecoversSynchronousOpenEvent()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var start = source[source.IndexOf("public void Start()", StringComparison.Ordinal)..source.IndexOf("private void CompleteLifecycleProbe", StringComparison.Ordinal)];
Assert.Contains("FixtureDocumentActiveAfterOpen", start);
Assert.Contains("fixtureDocumentActivated = true", start);
Assert.Contains("QueueReadinessBarrier();", start);
Assert.Contains("ReadinessBarrierCompleted", source);
Assert.Contains("TryActivateProjectAndVerifyContext();", source);
}
[Fact] public void QueueReadinessBarrier_RequiresActiveFixtureAndUsesSafeDispatch()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var dispatcher = source[source.IndexOf("private async Task DispatchCommandAsync", StringComparison.Ordinal)..source.IndexOf("private void OnCommandEnded", StringComparison.Ordinal)];
Assert.Contains("ExecuteInCommandContextAsync", dispatcher);
Assert.Contains("MdiActiveDocument", dispatcher);
Assert.Contains("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE", dispatcher);
Assert.Contains("Editor.CommandAsync", dispatcher);
Assert.DoesNotContain("SendStringToExecute", dispatcher);
}
[Fact] public void AutomationControlledExit_QueuesNativeQuitAfterPublicCommand()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("RequestControlledExit", source);
Assert.Contains("AcadApplication.Idle += OnExitIdle", source);
Assert.Contains("QuitDispatchAttempted", source);
Assert.Contains("exitDocument.SendStringToExecute(\"_.QUIT _N\\n\", true, true, false);", source);
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", source);
}
[Fact] public void Callback_AfterDocumentDestroyed_DoesNotAccessDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("if (document is null || e.Document is null) return;", source);
Assert.Contains("if (IsTerminal || document is null) return;", source);
}
[Fact] public void Idle_WhenActiveDocumentNull_DoesNotThrow()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE", source);
Assert.Contains("QuitDispatchSkippedNoLiveDocument", source);
}
[Fact] public void TerminalState_IgnoresFurtherCallbacks()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private bool IsTerminal", source);
Assert.Contains("if (IsTerminal && callback != \"ControlledExitIdle\") return;", source);
}
[Fact] public void HandlerException_ProducesFailedEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("exceptionText = exception.ToString();", source);
Assert.Contains("ExceptionText = exceptionText", source);
Assert.Contains("FailFromCallback", source);
}
[Fact] public void HandlerException_DoesNotEscapeToAutoCad()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private void RunCallback", source);
Assert.Contains("catch (System.Exception exception) { FailFromCallback", source);
Assert.Contains("=> RunCallback(\"Idle\"", source);
Assert.Contains("=> RunCallback(\"CommandFailed\"", source);
}
[Fact] public void Unsubscribe_IsIdempotent()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private bool documentHandlersSubscribed", source);
Assert.Contains("private bool lifecycleHandlersSubscribed", source);
Assert.Contains("private void UnsubscribeHandlers()", source);
}
[Fact] public void ControlledExit_DoesNotReuseDestroyedDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..];
Assert.DoesNotContain("document.CloseAndDiscard", controlledExit);
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", controlledExit);
Assert.Contains("document = null;", controlledExit);
}
[Fact] public void FinalCommandCompletion_UsesItsQuiescentExitBoundary()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("Complete(true, null, safeToExitNow: true)", source);
Assert.Contains("RequestControlledExit(safeToExitNow);", source);
}
[Fact] public void TerminalEvidence_IsFlushedBeforeExitAttempt()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
Assert.True(exit.IndexOf("TryWriteEvidence(functionalCompleted, null);", StringComparison.Ordinal) < exit.IndexOf("SendStringToExecute(\"_.QUIT _N\\n", StringComparison.Ordinal));
Assert.Contains("EvidenceFlushed = evidenceFlushed", source);
Assert.Contains("CommandsCompleted", source);
Assert.Contains("InspectorsCompleted", source);
}
[Fact] public void ExitFailure_DoesNotInvalidateCompletedFunctionalEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("if (functionalCompleted)", source);
Assert.Contains("ExitFailureCode = exitFailureCode", source);
Assert.Contains("TryWriteEvidence(functionalCompleted, null);", source);
}
[Fact] public void ExitDispatch_DoesNotUseUnavailableDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
Assert.Contains("AcadApplication.IsQuiescent", exit);
Assert.Contains("QuitDispatchSkippedNoLiveDocument", exit);
Assert.DoesNotContain("document ?? AcadApplication.DocumentManager", exit);
}
[Fact] public void ProductionRuntime_DoesNotEnableForceAfterEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.DoesNotContain("ForceAfterEvidence", source);
Assert.Contains("P_REC_ANNOT_AUTOMATION", source);
} }
[Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs"))); [Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
@@ -104,6 +268,62 @@ public sealed class FoundationTests
Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId)); Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId));
} }
[Fact] public void AnnotationPlan_ReversedObjectOrder_HasSamePlanId()
{
var first = Build(Snapshot("a", "Pipe", "L-1"), Snapshot("b", "Pipe", "L-2"));
var second = Build(Snapshot("b", "Pipe", "L-2"), Snapshot("a", "Pipe", "L-1"));
Assert.Equal(first.PlanId, second.PlanId);
Assert.Equal(first.Summary, second.Summary);
}
[Fact] public void AnnotationPlan_AbsolutePath_DoesNotAffectPlanId()
{
var builder = new AnnotationPlanBuilder();
var item = Snapshot("a", "Pipe", "L-1");
var first = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "C:\\one\\model.dwg" }, Objects = new[] { item } });
var second = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "D:\\two\\model.dwg" }, Objects = new[] { item } });
Assert.Equal(first.PlanId, second.PlanId);
}
[Fact] public void AnnotationPlan_ChangedRelevantProperty_ChangesPlanId()
{
var first = Build(Snapshot("a", "Pipe", "L-1"));
var second = Build(Snapshot("a", "Pipe", "L-2"));
Assert.NotEqual(first.PlanId, second.PlanId);
}
[Fact] public void AnnotationPlan_UnresolvedMembership_IsStableSkip()
{
var plan = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.NotResolved });
Assert.Equal(AnnotationOperationKind.Skip, Assert.Single(plan.Operations).Kind);
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));
@@ -164,8 +384,131 @@ public sealed class FoundationTests
Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", source.EvidenceCodes); 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 ViewportMembership_UsesConfirmedPnpIdentityContract()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.Contains("view.DwgQuery.Execute", source);
Assert.Contains("sourceId.PrjObjectId", source);
Assert.Contains("dataLinks.FindAcPpRowId", source);
Assert.Contains("VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", source);
}
[Fact] public void ViewportMembership_DoesNotSelectFirstViewport()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.DoesNotContain("DwgViewDefs.AsArray().First", source);
Assert.Contains("candidates.Length != 1", source);
}
[Fact] public void RuntimeInspector_IsDisabledByDefault()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs"));
Assert.Contains("P_REC_ANNOT_DIAGNOSTICS", source);
Assert.Contains("OrthoStructure", source);
}
[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"));
@@ -175,6 +518,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) } }; 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 };
} }