Compare commits

...
Author SHA1 Message Date
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
codex 75c27ee8e5 docs: define Ortho source DataLinks contract 2026-08-03 08:32:46 +03:00
codex c83ff3d9b3 docs: define Plant 3D 2026 migration agent contract 2026-08-03 08:32:23 +03:00
codex 09c53be7f4 fix: read Plant 3D model file references 2026-08-03 08:28:41 +03:00
codex bdd2c9c888 fix: resolve string Ortho model references 2026-08-03 08:18:44 +03:00
codex 95dba856c8 feat: resolve Ortho views to source model drawings 2026-08-03 08:16:11 +03:00
codex 6fa3af6eda docs: record real Ortho pipe DryRun evidence 2026-08-03 08:09:45 +03:00
codex c616188336 fix: tolerate missing Ortho DataLinks rows in DryRun 2026-08-03 07:54:00 +03:00
codex bb44b1b1aa fix: resolve Plant project context diagnostics 2026-07-31 15:57:06 +03:00
22 changed files with 1349 additions and 38 deletions
+129
View File
@@ -0,0 +1,129 @@
# AGENTS.md
## Назначение проекта
Этот репозиторий содержит аудит, архитектуру, исходный код, тесты, runtime
evidence и документацию миграции корпоративной экосистемы Autodesk AutoCAD
Plant 3D 2019 на Autodesk AutoCAD Plant 3D 2026.
Конечная цель — функционально эквивалентная рабочая схема Plant 3D 2026:
корпоративный Launcher, локальная доставка и активация пакетов, managed
плагины, команды, Ribbon, Plant project integration, DataLinks, Ortho,
аннотации, обновление, диагностика и rollback. Миграция не считается
завершённой после успешной компиляции: необходима runtime-проверка реальных
сценариев в Plant 3D 2026.
## Связанные репозитории
- основной Autodesk-репозиторий: `C:\dev\autodesk`;
- Launcher Next: `C:\dev\launcher-next`;
- legacy launcher (read-only): `C:\dev\launcher\git`, `C:\dev\p_serv_starter`;
- legacy P_Rec_Annot (read-only): `C:\dev\autodesk\link\P_Rec_Annot\v1.0.4\repo`.
Legacy и deployed-файлы нельзя изменять без отдельного прямого задания.
## Целевая платформа и принцип миграции
- Autodesk AutoCAD Plant 3D 2026, series R25.1, Windows x64, .NET 8;
- использовать реальные Autodesk 2026 managed assemblies;
- managed package загружается локально, не напрямую с UNC;
- Autodesk DLL нельзя коммитить, помещать в package/fixture или публиковать.
Переносится функциональное поведение, а не историческая структура файлов. Не
воспроизводить без отдельного доказательства 20 последовательных `NETLOAD`,
legacy LISP bootstrap, `OrthoLoader.dll`, загрузку с сети или прежние
workaround. Предпочтительная цепочка:
`Launcher Next → versioned local package → один managed entry assembly → internal dependencies → commands and Ribbon`.
## Статус legacy-команд
Ни одна активная команда Plant 3D 2019 не исчезает молча. Для каждой команды
фиксируется ровно один статус: `IMPLEMENTED`, `RUNTIME_VERIFIED`,
`REPLACED_WITH_NATIVE`, `RETIRED_APPROVED` или `BLOCKED_WITH_REASON`.
Aliases сохраняются, пока их удаление не утверждено отдельно.
## Runtime уровни
`Smoke` проверяет assembly load, регистрацию команд, Plant context, Ribbon,
отсутствие необработанных исключений и отсутствие записи в DWG.
`DryRun` проверяет реальные Plant data, Ortho/sheet/view definitions, DataLinks,
Plant objects, детерминированный план и отсутствие записи в DWG/project DB.
`Apply` разрешается только отдельным явным заданием: write guard, ограниченная
DWG transaction, тестовая копия проекта, baseline, rollback/UNDO, duplicate
protection и runtime evidence обязательны. Apply нельзя включать только
локальной конфигурацией.
## Runtime важнее предположений
Build и unit tests не доказывают Autodesk integration. Для
`RUNTIME_VERIFIED` необходимы фактические результаты: команда, module path,
DBMOD, Ribbon, DataLinks, Ortho status, telemetry и состояние файлов после
закрытия. Не выдумывать UI evidence: использовать `null`, `NOT VERIFIED` или
`BLOCKED WITH REASON`.
На тестовой машине реальный Plant 3D запуск разрешён, если он входит в задачу
и есть test branch, локальный fixture, пройденные build/tests, rollback и нет
production deploy. Не создавать искусственный review-gate перед Process.Start.
Остановка допустима лишь после завершения доступной части работы и при
конкретном внешнем blocker: установка, ОС, права, Autodesk runtime, fixture или
необходимость production-данных.
## Test fixtures и отсутствие записи
Fixtures создаются вне Git, например `<TEST_FIXTURE_ROOT>`. Ortho fixture —
настоящий Plant project с Pipe, DataLinks row, line group, Ortho drawing,
Ortho viewport и source-model connection; обычный AutoCAD viewport не годится.
Production DWG/project использовать нельзя без прямого разрешения.
Нельзя коммитить DWG, DCF, Project.xml с runtime-data, project DB, Ortho
generated files или реальные engineering values.
Для Smoke/DryRun проверять DBMOD до/после, SHA-256 содержательных project
files, отсутствие непредусмотренных transactions/DCF/project-metadata writes.
`LastWriteTime` недостаточен. Отделять служебные Plant cache/session files от
содержательных данных.
## Ribbon, механика и evidence
Ribbon — часть продукта: проверять tab, panel, button, command binding, stable
IDs, отсутствие дубликатов при документах/workspace/reinitialization. Команда
может быть отдельно рабочей, но plugin slice не завершён без Ribbon evidence
или отдельного UI defect.
Документировать каждую runtime-цепочку: component/project/class/method,
input/output, Autodesk transaction mode, writes, telemetry, failure code и
rollback action. Документация соответствует фактическому коду.
Evidence содержит только проверенные значения. Не добавлять username, permit,
полный environment, real project paths/line numbers/ObjectId/RowId/properties,
полные logs, local manifest или startup scripts. Путь fixture заменять на
`<TEST_FIXTURE_ROOT>`.
## Warnings и Git
Известный `MSB3277` WindowsBase 4.0.0.0/8.0.0.0 допустим только при полном
совпадении с ранее зафиксированным warning. Новые warnings исследовать; не
подавлять `NoWarn` без причины.
Запрещены force push, amend/rebase опубликованной истории, push binaries,
изменение неизвестного remote и production deployment из test branch. Перед
commit выполнять:
```powershell
git diff --check
git status --short
git diff --cached --check
```
Не смешивать Launcher defect, plugin defect, runtime evidence и unrelated
formatting в одном commit.
## Definition of Done
Срез завершён, когда реализованы код, build/tests, валидный package и требуемый
runtime-сценарий; записаны фактические результаты; выполнен rollback; fixture и
production изолированы; механика документирована; ветка опубликована обычным
push.
+89
View File
@@ -0,0 +1,89 @@
# AGENTS.md — P_Rec_Annot
## Назначение и package
`P_Rec_Annot` — приоритетный Plant 3D 2026 plugin для чтения, планирования и
последующего создания Ortho-аннотаций; это перенос активного legacy поведения
Plant 3D 2019 на .NET 8/Plant 3D 2026.
Единственная assembly для `NETLOAD` — `PRecAnnot.Plant3D2026.dll`.
`PRecAnnot.*.dll` — internal managed dependencies, их нельзя NETLOAD по одной.
Использовать Autodesk 2026 references; Autodesk DLL не включать в bundle.
## Commands и context
Поддерживаемые команды: `PHP_REC_ANNOT`, `PXP-AAV`, `PXP-OA`. Все
регистрируются реальным Autodesk API, работают в Smoke/DryRun, не изменяют DWG
в этих режимах и создают одно usage event на явный вызов.
Project status: `NotFound`, `FoundWithName`, `FoundWithoutDisplayName`,
`Ambiguous`, `Failed`. Никогда не выводить `проект '' найден`; для пустого
display name: `Plant 3D-проект открыт; отображаемое имя не определено.`
Diagnostic code: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`.
Drawing states: `Ortho`, `NotOrtho`, `Ambiguous`, `Failed`. Обычный DWG не
классифицируется как Ortho; для реального Ortho применять Autodesk API и
`OrthoDocUtils.IsValidOrthoDrawing`.
## DryRun и determinism
Pipeline: `Plant context → Ortho detection → sheet/view definitions → viewport
snapshots → DataLinks → Plant properties → independent DTO → deterministic
AnnotationPlan → diagnostics → no write`.
Autodesk objects, transactions и database handles остаются в adapter layer;
Core/Application используют только независимые DTO. На неизменном fixture
повторный DryRun обязан давать тот же PlanId, counts, порядок операций и warning
codes, без side effects. PlanId не включает время, GUID, absolute path,
нестабильный ObjectId или пользовательские данные.
## Write guard, Ribbon, tests
Все writes проходят централизованный write guard; в Smoke/DryRun он всегда
запрещает изменения. Apply не обходится через command handler/adapter.
Ожидаемый Ribbon путь: `Промхимпроект → Аннотации → Аннотировать`. Требуются
stable IDs, отсутствие дублей, binding к `PHP_REC_ANNOT`, устойчивость к
документам/workspace и корректный Terminate.
Unit tests не требуют Autodesk runtime. Runtime evidence отдельно фиксирует
initialization, commands, context, Ortho, DataLinks, plan, Ribbon, DBMOD,
telemetry и shutdown.
Bundle содержит только фактический runtime dependency graph и никогда не
содержит Autodesk DLL, PDB, test assemblies, legacy 2019 assemblies, bin/obj
или production configuration.
Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line
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
DataLinks активного Ortho drawing не являются источником Piping rows. Piping
rows читаются из source model drawing и Piping project part, разрешённых через
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, созданных из
одного закрытого состояния проекта. Сравнивать нужно `control delta` с
`DryRun delta`: одинаковые изменения классифицируются как baseline behavior
Plant 3D, а plugin-only изменения блокируют дальнейшую работу.
@@ -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."
}
}
@@ -0,0 +1,28 @@
{
"date": "2026-08-03",
"executionMode": "DryRun",
"plantProjectDetected": true,
"projectDisplayNameStatus": "FoundWithName",
"orthoDetection": "Confirmed",
"viewportCount": 1,
"dataLinksAvailable": true,
"dataLinksSheetToSourceMapping": "resolved-through-piping-project-drawing-id",
"sourceDrawingsResolved": 1,
"sourceDataLinksAvailable": true,
"objectsRead": 7,
"pipeObjectsRead": 7,
"phpRecAnnotRuns": 1,
"pxpAavRuns": null,
"pxpOaRuns": null,
"planId": null,
"repeatedPlanIdEqual": null,
"dbmodBefore": null,
"dbmodAfter": null,
"dwgModified": null,
"dcfModified": null,
"changedProjectFiles": null,
"unhandledExceptions": 0,
"applyModeUsed": false,
"launcherRollback": "RolledBack",
"exactViewportMembership": "not-verified"
}
@@ -0,0 +1,46 @@
# Ortho DryRun runtime result
Date: 2026-08-03
The local test fixture's `TestPipeOrtho.dwg` was detected as Ortho with one
viewport. In the paired `DryRun`, `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA`
each ran twice. Plugin telemetry recorded all six preflight completions without
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
`PRecAnnotTestProject`.
The active Ortho sheet does not expose direct Piping rows and must not be used
as a DataLinks source. Metadata inspection and a sanitized runtime probe
confirmed `IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName` as the source-model
reference. The adapter resolves that reference through
`PipingProject.GetPnPDrawingFiles()` and reads
`DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`.
On the paired real DryRun, one source drawing was resolved and seven source
Plant objects were read. The confirmed view query resolved five source rows to
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.
@@ -8,6 +8,7 @@ public sealed record RuntimeDiagnostic
}
public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed }
public enum PlantProjectDetectionStatus { NotFound, FoundWithName, FoundWithoutDisplayName, Ambiguous, Failed }
public sealed record OrthoDetectionResult
{
@@ -57,6 +58,19 @@ public sealed record PlantObjectSnapshot
public required GeometrySnapshot Geometry { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, 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
@@ -66,6 +80,7 @@ public sealed record PlantRuntimeContext
public bool IsOrthoDrawing { get; init; }
public string? DrawingName { get; init; }
public string? ProjectDisplayName { get; init; }
public PlantProjectDetectionStatus ProjectDetectionStatus { get; init; } = PlantProjectDetectionStatus.NotFound;
public IReadOnlyList<OrthoViewportInfo> Viewports { get; init; } = Array.Empty<OrthoViewportInfo>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
public OrthoDetectionResult Detection { get; init; } = new() { Status = OrthoDetectionStatus.Unsupported, Evidence = Array.Empty<string>(), Diagnostics = Array.Empty<RuntimeDiagnostic>() };
@@ -74,6 +89,62 @@ public sealed record PlantRuntimeContext
public interface IPlantRuntimeContextReader { PlantRuntimeContext ReadCurrentContext(); }
public interface IOrthoViewportReader { IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken); }
public enum SourceDrawingResolutionStatus { Resolved, NotReferenced, Ambiguous, MissingFromProject, Unsupported, Failed }
public sealed record OrthoSourceDrawingReference
{
public required string StableId { get; init; }
public required string DisplayName { get; init; }
public string? ProjectRelativePath { get; init; }
public long? ProjectDrawingRowId { get; init; }
public required SourceDrawingResolutionStatus Status { get; init; }
public IReadOnlyList<string> EvidenceCodes { get; init; } = Array.Empty<string>();
}
public sealed record SourcePlantObjectSnapshot
{
public required string StableObjectKey { get; init; }
public required string ClassName { get; init; }
public string? SourceDrawingStableId { get; init; }
public string? LineGroupStableKey { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
}
public sealed record SourceDrawingReadResult
{
public required OrthoSourceDrawingReference Source { get; init; }
public IReadOnlyList<SourcePlantObjectSnapshot> Objects { get; init; } = Array.Empty<SourcePlantObjectSnapshot>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
}
public interface IOrthoSourceDrawingResolver
{
IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing);
}
public sealed record OrthoDrawingSnapshot
{
public required string StableId { get; init; }
public IReadOnlyList<OrthoViewSourceSnapshot> Views { get; init; } = Array.Empty<OrthoViewSourceSnapshot>();
}
public sealed record OrthoViewSourceSnapshot
{
public required string ViewStableId { get; init; }
public IReadOnlyList<string> ReferencedDrawingPaths { get; init; } = Array.Empty<string>();
}
public interface ISourceDrawingDataLinksReader
{
SourceDrawingReadResult Read(PlantProjectContext project, OrthoSourceDrawingReference source);
}
public sealed record PlantProjectContext
{
public required string StableProjectId { get; init; }
public string? ProjectDisplayName { get; init; }
}
public enum AnnotationOperationKind { Create, Update, Skip, Conflict, Unsupported }
public sealed record AnnotationOperationPlan
@@ -148,6 +219,7 @@ public sealed record AnnotationIntent
public sealed record AnnotationPlan
{
public int PlanSchemaVersion { get; init; }
public string PlanId { get; init; } = string.Empty;
public string CommandName { get; init; } = string.Empty;
public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>();
@@ -6,6 +6,7 @@ namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{
public const int CurrentPlanSchemaVersion = 1;
public AnnotationPlan Build(AnnotationPlanningRequest request)
{
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
@@ -30,6 +31,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
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;
}
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
if (string.IsNullOrWhiteSpace(text))
{
@@ -84,7 +91,8 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
return new AnnotationPlan
{
PlanId = BuildPlanId(request, objects),
PlanSchemaVersion = CurrentPlanSchemaVersion,
PlanId = BuildPlanId(request, objects, operations),
CommandName = request.CommandName,
Operations = operations,
Diagnostics = diagnostics,
@@ -92,18 +100,39 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
Create = creates,
Update = updates,
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,
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);
foreach (var item in objects)
canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition);
var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
.Append("|command=").Append(Normalize(request.CommandName))
.Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.Append("|view=").Append(Normalize(request.Viewport.Id));
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()));
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,
Tag = item.Tag,
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();
return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoSourceDrawingResolver : IOrthoSourceDrawingResolver
{
public IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing)
{
var candidates = drawing.Views
.OrderBy(x => x.ViewStableId, StringComparer.Ordinal)
.SelectMany(x => x.ReferencedDrawingPaths)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().Replace('\\', '/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, path),
DisplayName = Path.GetFileName(path),
ProjectRelativePath = path,
Status = SourceDrawingResolutionStatus.Resolved,
EvidenceCodes = new[] { "SOURCE_FROM_VIEW_DEFINITION", "SOURCE_PATH_MATCHED_PROJECT_RELATIVE" }
})
.ToArray();
return candidates.Length != 0
? candidates
: new[]
{
new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, "none"),
DisplayName = "source drawing unavailable",
Status = SourceDrawingResolutionStatus.NotReferenced,
EvidenceCodes = new[] { "ORTHO_SOURCE_DRAWING_NOT_RESOLVED" }
}
};
}
private static string StableId(string drawingId, string source)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingId}|{source}"));
return "src-" + Convert.ToHexString(hash)[..20];
}
}
@@ -77,9 +77,11 @@ public sealed class EntryPoint : IExtensionApplication
var builder = new AnnotationPlanBuilder();
var plans = new List<AnnotationPlan>();
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
foreach (var viewport in context.Viewports)
{
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
objectsByViewport.Add(objects);
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects }));
}
@@ -94,7 +96,18 @@ public sealed class EntryPoint : IExtensionApplication
WarningCount = plans.Sum(x => x.Summary.WarningCount),
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["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
details["PlanId"] = planId;
details["MembershipResolved"] = membershipResolved;
details["MembershipPartial"] = membershipPartial;
details["MembershipUnresolved"] = membershipUnresolved;
details["MembershipOutsideView"] = membershipOutside;
details["PlanCreateCount"] = summary.CreateCount;
details["PlanUpdateCount"] = summary.UpdateCount;
details["PlanSkipCount"] = summary.SkipCount;
@@ -103,6 +116,7 @@ public sealed class EntryPoint : IExtensionApplication
details["ErrorCount"] = summary.ErrorCount;
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
WriteEditor(document, $"\nP_Rec_Annot — диагностический план\nПроект: {context.ProjectDisplayName}\nТип чертежа: Ortho\nViewport: {context.Viewports.Count}\nОбъектов найдено: {summary.ObjectsExamined}\nБудет создано: {summary.CreateCount}\nБудет обновлено: {summary.UpdateCount}\nБудет пропущено: {summary.SkipCount}\nКонфликтов: {summary.ConflictCount}\nОшибок: {summary.ErrorCount}\nDWG не изменён.");
WriteEditor(document, $"\nPlan schema: {AnnotationPlanBuilder.CurrentPlanSchemaVersion}; PlanId: {planId ?? "multiple"}; membership resolved/partial/outside/unresolved: {membershipResolved}/{membershipPartial}/{membershipOutside}/{membershipUnresolved}.");
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
}
@@ -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="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="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="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>
</Project>
@@ -1,7 +1,10 @@
using System.Security.Cryptography;
using System.Collections.Specialized;
using System.Text;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.DataLinks;
using Autodesk.ProcessPower.Drawings2d;
using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager;
@@ -32,6 +35,9 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
return Empty(true, false, "NO_PLANT_PROJECT", "Активный Plant 3D-проект не найден.");
}
var displayName = string.IsNullOrWhiteSpace(project.Name) ? null : project.Name.Trim();
var projectStatus = displayName is null ? PlantProjectDetectionStatus.FoundWithoutDisplayName : PlantProjectDetectionStatus.FoundWithName;
OrthoProject? orthoProject;
try { orthoProject = PlantApplication.ProjectPart("Ortho") as OrthoProject; }
catch (Exception exception)
@@ -60,9 +66,10 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasPlantProject = true,
IsOrthoDrawing = true,
DrawingName = Path.GetFileName(document.Name),
ProjectDisplayName = project.Name,
ProjectDisplayName = displayName ?? "display name unavailable",
ProjectDetectionStatus = projectStatus,
Viewports = viewports,
Diagnostics = Array.Empty<RuntimeDiagnostic>(),
Diagnostics = displayName is null ? new[] { new RuntimeDiagnostic { Code = "PLANT_PROJECT_DISPLAY_NAME_EMPTY", Message = "Plant project is open but no display name is available.", Severity = "Warning" } } : Array.Empty<RuntimeDiagnostic>(),
Detection = new OrthoDetectionResult
{
Status = OrthoDetectionStatus.Confirmed,
@@ -82,6 +89,8 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasActiveDocument = hasDocument,
HasPlantProject = hasProject,
IsOrthoDrawing = false,
ProjectDisplayName = hasProject ? "display name unavailable" : null,
ProjectDetectionStatus = hasProject ? PlantProjectDetectionStatus.Ambiguous : PlantProjectDetectionStatus.NotFound,
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } },
Detection = new OrthoDetectionResult { Status = status, Evidence = evidence, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } } }
};
@@ -124,7 +133,7 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
TwistAngle = 0,
IsOn = true,
IsLocked = false,
SourceDrawingName = sheet.DwgName,
SourceDrawingName = ReadSourceDrawingPath(view),
ViewName = view.ViewName
});
}
@@ -143,52 +152,233 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + viewName));
return "vp-" + Convert.ToHexString(hash)[..20];
}
private static string? ReadSourceDrawingPath(IDwgViewDef view)
{
// Confirmed Plant 3D 2026 contract (PnPDwg2dDef/PnPDwg2dUtil):
// IDwgViewDef.DwgQuery -> IDwgQuery.ModelFiles -> ModelFile.ModelFileName.
// The Ortho sheet is never used as a Piping DataLinks source.
try
{
if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic))
{
RuntimeDiagnostics.Write("OrthoModelFilesContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details);
return null;
}
if (RuntimeDiagnostics.IsOrthoStructureDiagnosticsEnabled)
{
OrthoRuntimeStructureInspector.Inspect(view);
}
return view.DwgQuery?.ModelFiles
.Select(file => file.ModelFileName)
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("OrthoModelFilesReadFailed", "WARNING", resultStatus: "ORTHO_MODEL_FILES_API_UNSUPPORTED", exception: exception);
}
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
{
public IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return Array.Empty<PlantObjectSnapshot>();
if (string.IsNullOrWhiteSpace(viewport.SourceDrawingName))
{
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;
try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; }
catch { return Array.Empty<PlantObjectSnapshot>(); }
if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>();
try { dataLinks = pipingProject.DataLinksManager; }
catch (Exception exception)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
return Array.Empty<PlantObjectSnapshot>();
}
if (dataLinks is null)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
var snapshots = new List<PlantObjectSnapshot>();
foreach (var rowId in dataLinks.SelectAcPpRowIds(document.Database))
try
{
try
// Confirmed Plant 3D 2026 contract: resolve the source model to the
// Piping project drawing record, then read its DataLinks by drawing Id.
// The active Ortho database is deliberately never passed to DataLinks.
var rowIds = dataLinks.SelectAcPpRowIds(sourceDrawing.Id);
if (rowIds.Count == 0)
{
cancellationToken.ThrowIfCancellationRequested();
var className = dataLinks.GetObjectClassname(rowId);
var properties = dataLinks.GetAllProperties(rowId, true)
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
snapshots.Add(new PlantObjectSnapshot
{
SnapshotId = StableId(Path.GetFileName(document.Name), rowId, className),
ObjectClass = className ?? string.Empty,
Tag = Value("Tag", "TAG"),
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }
});
WriteOutcome("SOURCE_DATALINKS_NO_ROWS", viewport, details: new Dictionary<string, object?> { ["ProjectDrawingResolved"] = true });
return Array.Empty<PlantObjectSnapshot>();
}
catch
var membership = PlantViewportMembershipReader.Read(viewport, dataLinks, rowIds);
foreach (var rowId in rowIds)
{
// Keep the read path best-effort; no write operation is attempted.
try
{
cancellationToken.ThrowIfCancellationRequested();
var className = dataLinks.GetObjectClassname(rowId);
var properties = dataLinks.GetAllProperties(rowId, true)
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
snapshots.Add(new PlantObjectSnapshot
{
SnapshotId = StableId(viewport.SourceDrawingName, rowId, className),
ObjectClass = className ?? string.Empty,
Tag = Value("Tag", "TAG"),
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" },
MembershipStatus = membership.StatusFor(rowId),
MembershipEvidenceCodes = membership.EvidenceFor(rowId)
});
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("SourceDataLinksRowReadFailed", "WARNING", resultStatus: "SOURCE_DATABASE_READ_FAILED", exception: exception);
}
}
}
catch (DLException exception)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
}
catch (Exception exception)
{
WriteOutcome("SOURCE_DATABASE_READ_FAILED", viewport, exception);
}
WriteOutcome(snapshots.Count == 0 ? "SOURCE_PIPE_ROWS_NOT_FOUND" : "SOURCE_DATALINKS_READ", viewport,
details: new Dictionary<string, object?> { ["SourcePlantObjectsRead"] = snapshots.Count, ["PipeObjectsRead"] = snapshots.Count });
return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
}
private static PnPProjectDrawing? ResolveProjectDrawing(PipingProject pipingProject, string sourceDrawingPath)
{
if (!Path.IsPathRooted(sourceDrawingPath))
{
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT", null);
return null;
}
string normalizedSource;
try { normalizedSource = Path.GetFullPath(sourceDrawingPath); }
catch (Exception exception)
{
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", null, exception);
return null;
}
var matches = pipingProject.GetPnPDrawingFiles()
.Where(drawing => MatchesSourcePath(drawing, normalizedSource))
.OrderBy(drawing => drawing.Id)
.ToArray();
if (matches.Length == 1)
{
RuntimeDiagnostics.Write("SourceProjectDrawingResolved", resultStatus: "SOURCE_FROM_PROJECT_DRAWING_ROW", details: new Dictionary<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)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}"));
@@ -196,6 +386,88 @@ internal sealed class PlantObjectReader : IPlantObjectReader
}
}
internal sealed class PlantViewportMembershipReader
{
internal readonly record struct MembershipReadResult(
IReadOnlySet<int> SelectedRows,
ViewMembershipStatus DefaultStatus,
string DefaultCode)
{
public ViewMembershipStatus StatusFor(int rowId) =>
DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId)
? ViewMembershipStatus.ObjectOutsideView
: DefaultStatus;
public IReadOnlyList<string> EvidenceFor(int rowId) =>
DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId)
? new[] { "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP" }
: new[] { DefaultCode };
}
internal static MembershipReadResult Read(OrthoViewportInfo viewport, DataLinksManager dataLinks, Autodesk.ProcessPower.DataObjects.PnPRowIdArray sourceRows)
{
try
{
var project = PlantApplication.ProjectPart("Ortho") as OrthoProject;
if (project is null) return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED");
var candidates = project.DwgSheetDefinitions.AsArray()
.SelectMany(sheet => sheet.DwgViewDefs.AsArray()
.Where(view => string.Equals(StableId(sheet.DwgGuid.ToString(), view.ViewName), viewport.Id, StringComparison.Ordinal))
.Select(view => (sheet, view)))
.ToArray();
if (candidates.Length != 1) return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED");
var view = candidates[0].view;
if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic))
{
RuntimeDiagnostics.Write("ViewportMembershipContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details);
return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED");
}
var selectedRows = new HashSet<int>();
var selected = view.DwgQuery.Execute(new StringCollection(), new StringCollection());
for (var index = 0; index < selected.Count; index++)
{
Autodesk.ProcessPower.Drawings2d.MdObjectId sourceId = default;
if (!selected.GetItem(index, ref sourceId) || sourceId.IsNull || sourceId.PrjObjectId.IsNull) continue;
try
{
var rowId = dataLinks.FindAcPpRowId(sourceId.PrjObjectId);
if (sourceRows.Contains(rowId)) selectedRows.Add(rowId);
}
catch (DLException)
{
// A generated entity without a Piping DataLinks row is outside this reader's source set.
}
}
RuntimeDiagnostics.Write("ViewportMembershipReadCompleted", "INFO", resultStatus: "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", details: new Dictionary<string, object?>
{
["SelectedSourceRows"] = selectedRows.Count,
["SourceRows"] = sourceRows.Count
});
return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID");
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("ViewportMembershipReadCompleted", "WARNING", resultStatus: "VIEW_MEMBERSHIP_API_UNSUPPORTED", exception: exception);
return Unsupported("VIEW_MEMBERSHIP_API_UNSUPPORTED");
}
}
private static MembershipReadResult Unsupported(string code) => new(new HashSet<int>(), ViewMembershipStatus.NotResolved, code);
// Ortho viewport IDs are generated by PlantOrthoViewportReader from exactly this pair.
// Only an exact regenerated ID is accepted; the reader never chooses an arbitrary view.
private static string StableId(string sheetId, string? viewName)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + (viewName ?? string.Empty)));
return "vp-" + Convert.ToHexString(hash)[..20];
}
}
internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard
{
public void EnsureWriteAllowed(string operation)
@@ -14,6 +14,10 @@ internal static class RuntimeDiagnostics
public static string RuntimeMode => Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE") ?? "Normal";
public static bool IsOrthoStructureDiagnosticsEnabled =>
string.Equals(RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) &&
string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_DIAGNOSTICS"), "OrthoStructure", StringComparison.OrdinalIgnoreCase);
public static void Write(
string eventType,
string level = "INFO",
@@ -104,6 +104,62 @@ public sealed class FoundationTests
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()
{
var plan = Build(Snapshot("a", "Pipe", null, null));
@@ -130,6 +186,101 @@ public sealed class FoundationTests
Assert.Equal(AnnotationOperationKind.Conflict, plan.Operations.Last().Kind);
}
[Fact] public void OrthoSourceResolver_OneView_ResolvesOneModelDrawing()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[] { new OrthoViewSourceSnapshot { ViewStableId = "view", ReferencedDrawingPaths = new[] { "Plant 3D Models/TestPipeModel.dwg" } } }
});
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.Resolved, source.Status);
Assert.Equal("TestPipeModel.dwg", source.DisplayName);
}
[Fact] public void OrthoSourceResolver_DuplicateViews_DeduplicatesSource()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[]
{
new OrthoViewSourceSnapshot { ViewStableId = "a", ReferencedDrawingPaths = new[] { "models/A.dwg" } },
new OrthoViewSourceSnapshot { ViewStableId = "b", ReferencedDrawingPaths = new[] { "models/A.dwg" } }
}
});
Assert.Single(result);
}
[Fact] public void OrthoSourceResolver_MissingSource_ReturnsDiagnostic()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot { StableId = "sheet" });
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.NotReferenced, source.Status);
Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", source.EvidenceCodes);
}
[Fact] public void 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 DryRunPath_HasNoForWriteOperations()
@@ -142,5 +293,5 @@ public sealed class FoundationTests
}
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 };
}