Compare commits

..
Author SHA1 Message Date
codex 0e0c13ee00 test: materialize controlled fixture families 2026-08-10 16:35:30 +03:00
codex 07abdc013b release: package P_Rec_Annot 0.2.19 2026-08-10 16:21:38 +03:00
codex 990d057ae3 feat: bridge settings assets and define fixture matrix 2026-08-10 16:20:52 +03:00
codex 8ad3d97edb release: package P_Rec_Annot 0.2.18 2026-08-10 15:46:30 +03:00
codex 6624c4ffd0 feat: model legacy support annotation settings 2026-08-10 15:46:01 +03:00
codex a80ff5220d release: package P_Rec_Annot 0.2.17 2026-08-10 15:45:04 +03:00
codex beadf751b0 feat: model legacy line number marker settings 2026-08-10 15:44:38 +03:00
codex 5047b7d41b release: package P_Rec_Annot 0.2.16 2026-08-10 15:43:17 +03:00
codex f706840d5d fix: preserve legacy settings decimal semantics 2026-08-10 15:42:18 +03:00
codex eb70c9d1a1 release: package p-rec-annot 0.2.15 2026-08-10 15:19:27 +03:00
codex f127fb1ed2 feat: bind controlled evidence to target command 2026-08-10 15:17:12 +03:00
codex 0e53ffb810 release: package p-rec-annot 0.2.14 2026-08-10 15:12:14 +03:00
codex 9242d2bb08 feat: record p-rec-annot selected-view automation evidence 2026-08-10 15:11:40 +03:00
codex 9aad3662ed release: package p-rec-annot 0.2.13 2026-08-10 14:44:55 +03:00
codex eaa6044682 fix: bind p-rec-annot line edit apply permits 2026-08-10 14:44:23 +03:00
codex 87887ffc76 release: package p-rec-annot 0.2.12 2026-08-10 14:39:02 +03:00
codex 9443babaf1 fix: guard p-rec-annot line edit commands 2026-08-10 14:11:58 +03:00
codex bb45add91d release: package p-rec-annot 0.2.11 2026-08-10 13:27:21 +03:00
codex 5bd39d56c4 fix: guard p-rec-annot line refresh apply 2026-08-10 13:26:19 +03:00
codex 4a351d9b44 fix: accept schema-2 composite module manifests 2026-08-10 11:02:22 +03:00
codex 4a84c268f8 feat: restore manual annotation command surface 2026-08-10 09:51:00 +03:00
codex 289b262053 feat: expose ortho maintenance commands in composite ribbon 2026-08-07 15:35:59 +03:00
codex 64ace55338 feat: port p-rec-annot ortho dimension workflow 2026-08-07 15:34:19 +03:00
codex 8510974d75 feat: publish p-rec-annot 0.2.6 source navigation surface 2026-08-07 15:29:35 +03:00
codex 14c244657d feat: add ortho annotation source navigation 2026-08-07 15:26:37 +03:00
codex 79f6124885 feat: expose ortho annotation removal in ribbon 2026-08-07 14:59:53 +03:00
codex 11a8130494 docs: map p-rec-annot release assets and package contract 2026-08-07 14:57:05 +03:00
codex 1986d15c2b feat: add ortho annotation removal and package integrity manifest 2026-08-07 14:51:57 +03:00
codex 3eaa659947 feat: publish p-rec-annot 0.2.3 settings bundle 2026-08-07 14:25:06 +03:00
codex 652515c2ba feat: add typed production settings contract 2026-08-07 14:23:34 +03:00
codex 9a41d831b4 feat: add manual annotation edit and reopen workflow 2026-08-07 14:21:00 +03:00
codex 700922b832 feat: persist p-rec-annot manual annotation state 2026-08-07 14:14:34 +03:00
codex 064cfdd803 feat: expand p-rec-annot user surface and datum controls 2026-08-07 14:00:54 +03:00
codex fcdec4d227 feat: restore p-rec-annot manual annotation palette 2026-08-07 13:36:17 +03:00
codex 461cc8f71b feat: port p-rec-annot ortho batch update lifecycle 2026-08-07 13:27:52 +03:00
codex b381f83bbd feat: port p-rec-annot ortho status and attribute maintenance 2026-08-07 13:21:32 +03:00
codex d4016237d8 feat: port p-rec-annot viewport layer update command 2026-08-07 13:14:53 +03:00
codex aaec3f93f2 feat: port p-rec-annot ortho batch and layer rules 2026-08-07 13:09:50 +03:00
codex 548c73cb27 feat: expand p-rec-annot ortho annotation workflows 2026-08-07 12:51:54 +03:00
codex c57c844f78 feat: port p-rec-annot auto annotate view pipeline 2026-08-07 12:44:07 +03:00
codex 7d8cc570df feat: port p-rec-annot ortho viewport maintenance foundation 2026-08-07 12:43:08 +03:00
codex ba39a649be feat: expand p-rec-annot component geometry adapter 2026-08-07 12:00:00 +03:00
codex b69a858f8e feat: port p-rec-annot elevation subsystem 2026-08-07 11:58:13 +03:00
codex 532c9dc1f2 feat: complete p-rec-annot line connection pipeline 2026-08-07 09:59:54 +03:00
codex 01a0b711d0 feat: validate p-rec-annot connector view eligibility 2026-08-07 09:48:22 +03:00
codex acfa6723c9 feat: read p-rec-annot connector ports 2026-08-07 09:47:20 +03:00
codex 53d6378cfa feat: plan p-rec-annot connector annotations 2026-08-07 09:45:07 +03:00
codex 20ac5e4808 feat: add p-rec-annot Plant connector relationship reader 2026-08-07 09:37:43 +03:00
codex f9cd0a43bf fix: preserve legacy p-rec-annot elevation quotient formatting 2026-08-07 09:32:11 +03:00
codex a8749ffa69 feat: port p-rec-annot line connection boundary rules 2026-08-07 09:24:32 +03:00
codex 43c9e42b5b feat: add p-rec-annot Plant component geometry adapter 2026-08-07 09:22:16 +03:00
codex 95ae9dfd18 feat: port p-rec-annot line-number refresh pipeline 2026-08-07 09:19:56 +03:00
codex 34cdfa225c build: issue p-rec-annot composite candidate 0.2.1 2026-08-07 09:14:43 +03:00
codex 2626b64e63 feat: activate p-rec-annot line-number edit commands 2026-08-07 09:09:47 +03:00
codex 6e909f89bc feat: establish p-rec-annot composite module manifest 2026-08-07 09:06:49 +03:00
codex 91f316d79b feat: port p-rec-annot line-number edit mutations 2026-08-07 09:04:07 +03:00
codex 7090321167 feat: add p-rec-annot line connection planning 2026-08-07 09:00:25 +03:00
codex 6f39bb204d feat: add p-rec-annot elevation annotation planning 2026-08-07 08:59:18 +03:00
codex 3128568965 feat: add controlled p-rec-annot line-number apply 2026-08-07 08:58:38 +03:00
codex 58f71267a2 feat: add p-rec-annot interactive line-number preview 2026-08-07 08:54:31 +03:00
codex 066a9f3d94 feat: add p-rec-annot Plant line-number candidate reader 2026-08-07 08:48:42 +03:00
codex 0a7f05989a feat: add p-rec-annot line-number command preview service 2026-08-07 08:47:48 +03:00
codex 315d36a36a test: cover p-rec-annot line-number list planning 2026-08-07 08:42:34 +03:00
codex 431366c27c feat: add p-rec-annot line-number list writer 2026-08-07 08:41:19 +03:00
codex 172d0fa0f1 feat: port p-rec-annot elevation formatting foundation 2026-08-07 08:38:19 +03:00
codex d3da634894 feat: add p-rec-annot line-number marker writer 2026-08-07 08:34:55 +03:00
codex a17ad87fc2 feat: port p-rec-annot line-number asset settings 2026-08-07 08:32:15 +03:00
codex ed7218983b feat: add p-rec-annot line-number apply preflight 2026-08-07 08:28:47 +03:00
codex 10af8c8f12 feat: port p-rec-annot line-number command planning 2026-08-07 08:22:40 +03:00
codex b6a2cd06af feat: add p-rec-annot datalinks line-number read path 2026-08-07 08:18:55 +03:00
codex 8e197cdcde build: resolve local plant 3d 2026 managed references 2026-08-07 08:14:19 +03:00
codex 7070e4dffe feat: port p-rec-annot extents intersection rules 2026-08-07 08:09:09 +03:00
codex cce3ff71c5 feat: port p-rec-annot line-number formatting foundation 2026-08-07 08:08:17 +03:00
codex 70ae18981a fix: make p-rec-update initialization load-safe 2026-08-07 07:34:54 +03:00
codex 20960ae91f fix: remove generated p-rec-update artifacts 2026-08-06 16:41:10 +03:00
codex ca0f49ee5a feat: add p-rec-update plant 3d 2026 slice 2026-08-06 16:40:38 +03:00
codex c3ac183fff docs: link shared project map from agent instructions 2026-08-05 07:14:45 +03:00
codex 68f500692c fix: verify apply duplicate and capture runtime failures 2026-08-04 16:35:42 +03:00
codex 86cabb584b fix: import legacy Pipe annotation template 2026-08-04 14:59:51 +03:00
codex aefd3aa487 fix: finalize script-driven limited Apply evidence 2026-08-04 14:49:54 +03:00
codex 07ab3c436f fix: open source database for Ortho placement 2026-08-04 14:43:59 +03:00
codex c97f02c550 fix: identify failed Ortho pipe placement boundary 2026-08-04 14:34:15 +03:00
codex 62f470c7ba fix: retain Ortho placement failure stack trace 2026-08-04 14:32:30 +03:00
codex 2fe1ca71df fix: diagnose unavailable Ortho pipe placement 2026-08-04 14:30:29 +03:00
codex e707456ab6 fix: retain prepared Ortho context for script PXP-OA 2026-08-04 14:25:44 +03:00
codex 3cecee4777 fix: bind script PXP-OA planning to command document 2026-08-04 14:19:20 +03:00
codex 8a9f95a605 fix: correlate script-driven PXP-OA command events 2026-08-04 14:09:14 +03:00
codex c270a182ce fix: verify PXP-OA registration for script Preview 2026-08-04 12:23:36 +03:00
codex 25311e89f0 Merge remote-tracking branch 'gitea/design/autodesk-launcher-next-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
codex f5a70df68a Merge remote-tracking branch 'gitea/audit/launcher-source-architecture-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
codex 5725bc8737 Merge remote-tracking branch 'gitea/audit/p-rec-annot-legacy-loading-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
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
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
codex 6e4cc74e82 docs: define Launcher Next pilot and performance plan 2026-07-31 14:34:05 +03:00
codex 1127a8376f docs: define Autodesk launcher next architecture 2026-07-31 14:30:19 +03:00
codex 36178374db audit: document launcher source architecture 2026-07-31 14:06:39 +03:00
codex b33351117b audit: clarify legacy bootstrap evidence 2026-07-31 13:39:55 +03:00
codex 56dcce42f6 audit: document legacy P_Rec_Annot loading chain 2026-07-31 13:39:29 +03:00
codex ea3a9e157e test: cover deterministic Ortho planning 2026-07-31 13:08:22 +03:00
codex 837ac1d994 feat: read Plant 3D Ortho context and viewports 2026-07-31 13:08:16 +03:00
codex 142b5f8b93 test: add controlled Plant 3D 2026 runtime smoke mode 2026-07-31 12:55:22 +03:00
codex dcf95d7e3a test: validate Plant 3D 2026 build and bundle 2026-07-31 11:37:36 +03:00
codex c733a27cbb feat: register real Plant 3D 2026 commands and ribbon 2026-07-31 11:37:31 +03:00
codex 9a7a6cd678 fix: bind P_Rec_Annot to Plant 3D 2026 assemblies 2026-07-31 11:36:46 +03:00
codex 66e52e5f50 chore: accept user-approved report presentation updates
Accept the three existing user changes as approved by the project owner. The changes are intentionally committed as-is without semantic normalization or regeneration.
2026-07-31 10:03:39 +03:00
codex 60b3934cd2 feat: scaffold P_Rec_Annot .NET 8 architecture 2026-07-31 09:57:53 +03:00
codex 34222cedc8 audit: inventory P_Rec_Annot v1.0.4 sources 2026-07-31 09:37:32 +03:00
codex 596c95a4b6 fix: aggregate Plant3D plugin usage without computer field 2026-07-31 09:07:06 +03:00
codex 6f29a5bce4 audit: parse Plant3D usage from Excel reports 2026-07-31 08:45:35 +03:00
codex 871499fa03 audit: build plugin usage matrix from logs 2026-07-31 08:00:19 +03:00
283 changed files with 54640 additions and 649 deletions
+4
View File
@@ -1,6 +1,10 @@
/link/ /link/
/link /link
# Generated build output is never source or package input.
**/bin/
**/obj/
# DocumentAutomation generated files and local source data # DocumentAutomation generated files and local source data
DocumentAutomation/**/bin/ DocumentAutomation/**/bin/
DocumentAutomation/**/obj/ DocumentAutomation/**/obj/
+163
View File
@@ -0,0 +1,163 @@
# 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.
## Общая карта проекта
Каноническая общая карта проекта находится в Google Drive:
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
Локальная синхронизированная копия, если она доступна, определяется только
переменной среды `PHIM_PLANT3D_PROJECT_MAP_ROOT`; её путь не хардкодить.
Перед работой над архитектурой, миграцией или runtime-сценарием обязательно
прочитать доступные индекс/обзор карты, целевую архитектуру и migration status,
индекс runtime evidence, а также записи карты для затрагиваемых Launcher и
P_Rec_Annot. Затем сверить их с локальными AGENTS.md, релевантными tracked
документами, текущим кодом и тестами.
Источники истины имеют следующий приоритет: фактическое runtime evidence;
текущий код, tests и tracked configuration на проверенном Git HEAD; tracked
документация репозитория; общая карта проекта. Внешняя карта описывает и
связывает работу, но не является build/runtime dependency и не переопределяет
текущий код или runtime evidence.
Проверять свежесть каждой используемой записи карты по фактическим `git
rev-parse HEAD`, ветке и привязанному runtime evidence. Если карта или её
локальная копия недоступна, фиксировать `PROJECT_MAP_UNAVAILABLE`; если
зафиксированные commit/runtime status не совпадают с фактическими —
`PROJECT_MAP_STALE`. Эти статусы не разрешают выдумывать evidence: продолжать
по доступным локальным источникам и явно сообщать расхождение.
Системные инварианты сохраняются: Launcher доставляет versioned local package
с одним managed entry assembly и без Autodesk DLL; P_Rec_Annot подтверждает
реальное Plant/Ortho/DataLinks поведение runtime evidence, а не только build
или tests. После изменения архитектуры либо runtime-статуса в отчёте указывать
конкретные документы общей карты, которые требуют обновления (архитектура,
статус миграции, evidence index и запись затронутой подсистемы); не заявлять,
что они уже обновлены, без фактического изменения.
+6
View File
@@ -0,0 +1,6 @@
artifacts/
.local-dotnet-sdk/
.local-tools/
bin/
obj/
.vs/
+119
View File
@@ -0,0 +1,119 @@
# 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 изменения блокируют дальнейшую работу.
## Общая карта проекта
Каноническая общая карта проекта:
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
Необязательная локальная копия доступна только через
`PHIM_PLANT3D_PROJECT_MAP_ROOT`; локальный путь синхронизации не хардкодить.
До изменения P_Rec_Annot читать доступные индекс карты, запись архитектуры и
статуса P_Rec_Annot, DryRun/Apply contract, карту Ortho/DataLinks и связанный
runtime evidence index. Сверять их с фактическим Git HEAD, текущими plugin
code/tests/configuration и runtime evidence конкретной fixture.
Приоритет источников: runtime evidence, затем code/tests/configuration на
проверенном HEAD, затем tracked repository documentation, затем общая карта.
Карта не является build/runtime dependency и не заменяет фактический код либо
runtime evidence. Недоступную карту отмечать `PROJECT_MAP_UNAVAILABLE`;
несовпадение её commit/runtime status с фактическими данными —
`PROJECT_MAP_STALE`.
Инварианты P_Rec_Annot: остаётся единственная NETLOAD entry assembly, aliases
и статусы команд не исчезают молча, Smoke/DryRun не пишут DWG, а Apply требует
явного разрешения, локальной fixture, ограниченной transaction, evidence и
проверяемого rollback/UNDO. Не ослаблять подтверждённый Ortho source/DataLinks
контракт и DCF control comparison ради сведений из карты.
После изменения plugin architecture или runtime-status в отчёте перечислять
документы карты, требующие обновления: запись P_Rec_Annot, command/status,
Ortho/DataLinks contract, DryRun/Apply/UNDO evidence и migration status. Не
утверждать обновление внешней документации без фактического изменения.
@@ -0,0 +1,17 @@
<Project>
<Import Project="Local.Build.props" Condition="Exists('Local.Build.props')" />
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<DebugType>portable</DebugType>
<AutoCAD2026ManagedDir Condition="'$(AutoCAD2026ManagedDir)' == '' and '$(Plant3D2026InstallDir)' != ''">$(Plant3D2026InstallDir)</AutoCAD2026ManagedDir>
<Plant3D2026ManagedDir Condition="'$(Plant3D2026ManagedDir)' == '' and '$(Plant3D2026InstallDir)' != ''">$(Plant3D2026InstallDir)\PLNT3D</Plant3D2026ManagedDir>
<Plant3D2026InstallDir Condition="'$(Plant3D2026InstallDir)' == '' and '$(AutoCAD2026ManagedDir)' != ''">$(AutoCAD2026ManagedDir)</Plant3D2026InstallDir>
<Plant3D2026StandardInstallDir Condition="'$(Plant3D2026StandardInstallDir)' == ''">$(ProgramW6432)\Autodesk\AutoCAD 2026</Plant3D2026StandardInstallDir>
<AutoCAD2026ManagedDir Condition="'$(AutoCAD2026ManagedDir)' == '' and Exists('$(Plant3D2026StandardInstallDir)\AcMgd.dll')">$(Plant3D2026StandardInstallDir)</AutoCAD2026ManagedDir>
<Plant3D2026ManagedDir Condition="'$(Plant3D2026ManagedDir)' == '' and Exists('$(Plant3D2026StandardInstallDir)\PLNT3D\PnPProjectManagerMgd.dll')">$(Plant3D2026StandardInstallDir)\PLNT3D</Plant3D2026ManagedDir>
</PropertyGroup>
</Project>
+54
View File
@@ -0,0 +1,54 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Abstractions", "src\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj", "{10000000-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core", "src\PRecAnnot.Core\PRecAnnot.Core.csproj", "{10000000-0000-0000-0000-000000000002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Application", "src\PRecAnnot.Application\PRecAnnot.Application.csproj", "{10000000-0000-0000-0000-000000000003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Plant3D2026", "src\PRecAnnot.Plant3D2026\PRecAnnot.Plant3D2026.csproj", "{10000000-0000-0000-0000-000000000004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.UI", "src\PRecAnnot.UI\PRecAnnot.UI.csproj", "{10000000-0000-0000-0000-000000000005}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Telemetry", "src\PRecAnnot.Telemetry\PRecAnnot.Telemetry.csproj", "{10000000-0000-0000-0000-000000000006}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core.Tests", "tests\PRecAnnot.Core.Tests\PRecAnnot.Core.Tests.csproj", "{10000000-0000-0000-0000-000000000007}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+24
View File
@@ -0,0 +1,24 @@
# P_Rec_Annot Plant 3D 2026 foundation
Foundation for migration from the legacy P_Rec_Annot v1.0.4. Legacy sources remain read-only and are not copied here.
Current slice:
* `PRecAnnot.Abstractions` — Autodesk-free immutable contracts and DTOs;
* `PRecAnnot.Core` — deterministic annotation planning and duplicate filtering;
* `PRecAnnot.Application` — command registry and use-case orchestration;
* `PRecAnnot.Plant3D2026` — guarded adapter boundary; compilation against Plant 3D 2026 is blocked until the SDK/runtime is installed;
* `PRecAnnot.UI` — idempotent Ribbon registration model, without legacy `_netload`;
* `PRecAnnot.Telemetry` — local JSONL command-scope events, without project path/number;
* `PRecAnnot.Bundle.Tests` and `PRecAnnot.Core.Tests` — architecture/core/packaging checks.
The first vertical slice is intentionally a dry-run planning slice. It does not mutate DWG until the Plant 3D 2026 adapter is compiled and runtime-tested on a controlled project.
Build:
```powershell
dotnet build .\PRecAnnot2026.sln --configuration Release
dotnet test .\PRecAnnot2026.sln --configuration Release --no-build
```
When Plant 3D 2026 is installed, pass `/p:Plant3D2026InstallDir=...`. Autodesk assemblies are `Private=false`, `CopyLocal=false` and are never committed.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ApplicationPackage SchemaVersion="1.0" Name="P_Rec_Annot" Description="P_Rec_Annot Plant 3D 2026" AppVersion="0.2.19" ProductCode="b455ee6eaed79d76f084846a60ce3284" Author="NBIM">
<Components>
<!-- SeriesMin/SeriesMax and platform token are pending official/runtime verification. -->
<RuntimeRequirements OS="Win64" />
<ComponentEntry AppName="PRecAnnot2026" ModuleName="Contents\Windows\PRecAnnot.Plant3D2026.dll" AppDescription="P_Rec_Annot entry assembly" LoadReasons="LoadOnAutoCADStartup" />
</Components>
</ApplicationPackage>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Main>
<CopyMissingObjects>
<Object type="Block" name="BLOCK_ANNO_POSITION" />
<Object type="Block" name="BLOCK_ELEVATION" />
<Object type="Block" name="BLOCK_LINENUMBER_CONNECTION" />
<Object type="Layer" name="PXP_ANNOTATION" />
<Object type="Layer" name="PXP_AXIS" />
<Object type="TextStyle" name="GOSTC" />
</CopyMissingObjects>
<LayersSetup>
<LayerSetup filtertype="Status" filtervalue="New" color="" lineweight="60" linetype="Continuous" />
<LayerSetup filtertype="Status" filtervalue="Existing" color="" lineweight="-3" linetype="Continuous" />
<LayerSetup filtertype="Status" filtervalue="Reserved" color="" lineweight="-3" linetype="PXP_DASH" />
<LayerSetup filtertype="Status" filtervalue="Demolition" color="" lineweight="-3" linetype="PXP_DASH" />
<LayerSetup filtertype="Layer" filtervalue="CENTER" color="4" lineweight="-3" linetype="PXP_AXIS" />
</LayersSetup>
<Annotations layer="PXP_ANNOTATION" multileaderstyle="PXP_MLEADER" textstyle="GOSTC" />
<LinenumberConnections polylinelayer="PXP_AXIS" textlayer="PXP_TEXT" textstyle="GOSTC" />
<Elevations layer="PXP_THIN" textstyle="GOSTC" />
<SetLayer name="PXP_AXIS" />
</Main>
@@ -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,3 @@
# Target architecture
Core and Abstractions have no Autodesk, WinForms or WPF references. Only `PRecAnnot.Plant3D2026` may reference installed Autodesk assemblies. DWG mutation remains synchronous on AutoCAD's thread; no `Task.Run` is used for database operations. Ribbon registration is guarded and unloadable. The bundle has one entry assembly and no legacy `_netload` chain.
+35
View File
@@ -0,0 +1,35 @@
# Build
References resolve in this order: explicit `AutoCAD2026ManagedDir` and
`Plant3D2026ManagedDir`, an untracked `Local.Build.props`, then the validated
standard Plant 3D 2026 install directory. No Autodesk binary is copied into the
repository or bundle. If discovery does not find all required assemblies, pass
the explicit properties shown below.
The workstation has .NET SDK 8.0.423 and Autodesk AutoCAD Plant 3D 2026 installed. The paths are deliberately supplied at build time; no machine-specific Autodesk path is stored in project files.
Detected assembly inventory:
| Assembly | Assembly version | Build input |
|---|---:|---|
| AcMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
| AcDbMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
| AcCoreMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
| AdWindows.dll | 5.2.1.1 | `AutoCAD2026ManagedDir` |
| PnP3dObjectsMgd.dll | 17.0.60.1 | `Plant3D2026ManagedDir` |
| PnPProjectManagerMgd.dll | 17.0.60.1 | `Plant3D2026ManagedDir` |
The actual build used:
```powershell
dotnet restore .\Plant3D2026\P_Rec_Annot\PRecAnnot2026.sln
dotnet build .\Plant3D2026\P_Rec_Annot\PRecAnnot2026.sln --configuration Release `
/p:AutoCAD2026ManagedDir="<AutoCAD managed directory>" `
/p:Plant3D2026ManagedDir="<Plant 3D managed directory>"
```
`PRecAnnot.Plant3D2026` now compiles against the real AutoCAD/Plant 3D 2026 metadata. The adapter has real `IExtensionApplication`, `CommandMethod`, `PlantApplication.CurrentProject`, `Document`, `Editor`, and `Autodesk.Windows` Ribbon references. The previous conditional foundation-only adapter was removed. Autodesk references are `Private=false` and `CopyLocal=false`.
Release emits one non-fatal WindowsBase reference conflict caused by Autodesk's legacy reference surface versus the .NET 8 reference pack. It does not copy Autodesk assemblies to output. Autodesk DLLs are excluded from Git, NuGet, bundle, and publish output.
The local smoke bundle is created at `Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle`. Deploy is NOT PERFORMED.
@@ -0,0 +1,18 @@
# Command migration matrix
Initial active set is based on non-archive command registrations and Ribbon definitions in audit evidence. This matrix is deliberately explicit; commands remain blocked until Plant 3D 2026 runtime evidence exists.
| Legacy command | Legacy module | Target use case | Decision | Status | Blocker/test |
|---|---|---|---|---|---|
| PXP-AAV | AutoAnnotateView | automatic annotations in viewport | port after isolation | BLOCKED_WITH_REASON | 2026 assemblies/runtime |
| PXP-OA | OrthoAutoAnnotate | axis/support graphical annotations | port after isolation | BLOCKED_WITH_REASON | exact legacy rules/golden DWG |
| PXP-ALC | AutoLinenumberConnections | line-number connection annotations | reimplement | BLOCKED_WITH_REASON | DataLinks adapter |
| PXP-LN, PXP-ELN, PXP-ULN, PXP-ESN, PXP-RMP | AnnotateLineNumbers | line-number list/updates | reimplement | BLOCKED_WITH_REASON | golden cases |
| PXP-AE, PXP-IE | AutoElevation/InsertElevation | elevations | reimplement | BLOCKED_WITH_REASON | geometry/runtime |
| PXP-IOD | UpdateOrthoDrawings | batch update | defer to Wave 5 | BLOCKED_WITH_REASON | multi-DWG transaction |
| PXP-UOB | UpdateOrthoViewBlocks | viewport layers | adapter | BLOCKED_WITH_REASON | layer API |
| PXP-OSI, PXP-COV | OrthoAutoAnnotate/CheckOrthoView | locate/check objects | compare native | BLOCKED_WITH_REASON | runtime comparison |
| PXP-MA, CAC, BJ, mto-ma | PHPAnnotations/P3DNewMa | manual annotation blocks | port/review | BLOCKED_WITH_REASON | UI and block contract |
| PXP-AC | SwapAttributesMenuItem | swap annotation attributes | port after rules | BLOCKED_WITH_REASON | block schema |
Archive-only registrations are not published. No command is silently discarded.
@@ -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,19 @@
{
"candidate": "P_Rec_Annot 0.2.19",
"root": "<TEST_FIXTURE_ROOT>/controlled-fixtures-0.2.19",
"sourcePolicy": "copied only from approved controlled PRecAnnotOrthoDryRun/FilesRelease fixtures; no production data",
"families": [
{"id":"line-number","artifacts":["line-number/TestPipeOrtho.dwg","line-number/TestPipeModel.dwg","line-number/Project.xml","line-number/Piping.dcf"],"status":"MATERIALIZED; OWNED_HANDLE_DISCOVERY_REQUIRED","workflows":["PXP-LN","PXP-ULN","PXP-ESN","PXP-ELN","PXP-RMP"]},
{"id":"elevation","artifacts":["elevation/TestPipeOrtho.dwg","elevation/TestPipeModel.dwg","elevation/Project.xml","elevation/Piping.dcf"],"status":"MATERIALIZED; SOURCE_ELEVATION_CASES_REQUIRED","workflows":["PXP-AE","PXP-IE"]},
{"id":"ortho","artifacts":["ortho/TestPipeOrtho.dwg","ortho/TestPipeModel.dwg","ortho/Project.xml","ortho/Piping.dcf"],"status":"MATERIALIZED; EXACT_VIEWPORT_MAPPING_REQUIRED","workflows":["PXP-AAV","PXP-COV","PXP-UOB","PXP-DJ"]},
{"id":"manual","artifacts":["manual/Template.dwg"],"status":"MATERIALIZED; MANUAL_STATE_AND_OWNED_BLOCK_REQUIRED","workflows":["mto-ma","PXP-MA","CAC","CAC1","BJ","PXP-MAB","PXP-AC"]},
{"id":"batch","artifacts":["batch/FolderA/SameName.dwg","batch/FolderB/SameName.dwg"],"status":"MATERIALIZED; CONTROLLED_PROJECT_RELATIVE_IDENTITY_REQUIRED","workflows":["PXP-IOD"]}
],
"hashes": {
"TestPipeOrtho.dwg": "B372BB94B039AB70E0721F2403D36065588C099B101360BE916C74F55B4B7A39",
"TestPipeModel.dwg": "D702E6E1BF100F6BB2D5623128ECB8E8685398FEFD162A989A1574404C1EA8BF",
"Project.xml": "EC622CF25E31FDCFEA009581880DA74DC5213EC8427767AB1310E715E2C4F231",
"Piping.dcf": "F28A09832CDF647722E81F6CC38A632EC576E8B921E68B01D5335421FF20CE36",
"Template.dwg": "22CAAD35CE9C55891CF91042ED9B2C23BA4D5A0745AA40D69F41C1F609DAADB3"
}
}
@@ -0,0 +1,73 @@
{
"schemaVersion": 1,
"policy": "controlled-local-only; fail-closed on ambiguous identity; no production DWG/project writes",
"fixtures": [
{
"id": "LINE_NUMBER_ORTHO_OWNED",
"requiredDocument": "controlled Plant Ortho DWG copy",
"requiredObjects": ["Pipe with DataLinks line number", "owned line-number block", "legacy layers/text styles"],
"selection": "exact owned block handle discovered from drawing",
"preState": "owned annotation baseline and unchanged Plant source",
"mutation": "create/update/delete line annotation",
"repeat": "update or AlreadyExists according to command",
"undo": "native AutoCAD UNDO restores entity and ownership baseline",
"workflows": ["PXP-LN", "PXP-ULN", "PXP-ESN", "PXP-ELN", "PXP-RMP"]
},
{
"id": "CONNECTOR_TOPOLOGY",
"requiredDocument": "controlled Plant project and Ortho copy",
"requiredObjects": ["two connected Plant parts", "connector/port snapshot", "P3dPartConnection", "line-group relationships"],
"selection": "exact connector source identity; no arbitrary entity fallback",
"preState": "no owned connection annotation",
"mutation": "orthogonal connection leader and ownership XData",
"repeat": "recreate/update without duplicate ownership",
"undo": "native AutoCAD UNDO restores pre-annotation state",
"workflows": ["PXP-ALC"]
},
{
"id": "ELEVATION_GOLDEN",
"requiredDocument": "controlled Plant model plus paper/model context",
"requiredObjects": ["positive, negative and zero elevations", "BLOCK_ELEVATION definition", "attribute definitions"],
"selection": "exact Plant source object handle",
"preState": "source object and annotation baseline",
"mutation": "legacy /5, AwayFromZero, *5 elevation annotation",
"repeat": "update existing owned annotation",
"undo": "native AutoCAD UNDO restores baseline",
"workflows": ["PXP-AE", "PXP-IE"]
},
{
"id": "ORTHO_VIEWPORT_EXACT",
"requiredDocument": "controlled Plant Ortho DWG with one-to-one source view mapping",
"requiredObjects": ["exact viewport handle/ObjectId", "layout membership", "Ortho view identity", "visible Plant candidates"],
"selection": "validated AcDbViewport handle and expected Ortho mapping",
"preState": "viewport and annotation baseline",
"mutation": "view annotation/viewport maintenance operation",
"repeat": "deterministic update/no duplicate",
"undo": "native AutoCAD UNDO restores viewport/entity baseline",
"workflows": ["PXP-AAV", "PXP-COV", "PXP-UOB", "PXP-DJ"],
"status": "EXACT_VIEWPORT_FIXTURE_BLOCKED"
},
{
"id": "MANUAL_TEMPLATE_EDIT",
"requiredDocument": "controlled local Template-derived drawing",
"requiredObjects": ["manual annotation blocks", "attributes", "dynamic properties", "required layers/styles", "owned P_REC_ANNOT_MANUAL XData"],
"selection": "exact owned annotation handle for edit/reopen",
"preState": "palette state and owned annotation baseline",
"mutation": "manual insert/update/attribute persistence",
"repeat": "restore and update existing owned annotation",
"undo": "native AutoCAD UNDO restores entity and manual-state baseline",
"workflows": ["mto-ma", "PXP-MA", "CAC", "CAC1", "BJ", "PXP-MAB", "PXP-AC"]
},
{
"id": "ORTHO_BATCH_SAME_NAME",
"requiredDocument": "controlled project copy with two relative drawing paths",
"requiredObjects": ["same filename in different project-relative directories", "one failing controlled drawing"],
"selection": "project-relative identity, never filename-only",
"preState": "unchanged controlled copies",
"mutation": "per-drawing update with continue-on-error",
"repeat": "deterministic ordering and idempotent result",
"undo": "per-drawing recovery/cleanup",
"workflows": ["PXP-IOD"]
}
]
}
@@ -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,24 @@
{
"executionMode": "DryRun",
"runtimeExecuted": false,
"plantProjectDetected": null,
"orthoDetection": "NotExecuted",
"viewportCount": null,
"dataLinksAvailable": null,
"objectsRead": null,
"plan": {
"create": null,
"update": null,
"skip": null,
"conflict": null,
"unsupported": null
},
"planDeterministic": true,
"dbmodBefore": null,
"dbmodAfter": null,
"dwgModified": null,
"dcfModified": null,
"projectXmlModified": null,
"unhandledExceptions": null,
"blocker": "No controlled test Ortho DWG/project path was supplied or discovered; runtime DryRun was not claimed."
}
@@ -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,26 @@
{
"product": "Autodesk AutoCAD Plant 3D 2026",
"manualNetload": true,
"bundleLoaded": true,
"automaticAppLoaderChecked": false,
"smokeModeConfirmedByOperator": true,
"observedCommandLogRuntimeMode": "Normal",
"assemblyInitializeCountObserved": 1,
"commands": {
"PHP_REC_ANNOT": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
"PXP-AAV": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
"PXP-OA": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" }
},
"ribbon": { "created": true, "duplicateCheckedExhaustively": false },
"plantContext": { "projectDetected": true, "projectDisplayName": "DefaultProject", "observedOrthoDetected": false, "dataLinksAvailable": true },
"writesBlocked": true,
"dbmodChecked": false,
"dwgModified": false,
"dcfModified": false,
"projectXmlModified": false,
"telemetryEventsExpected": 3,
"telemetryEventsActual": 3,
"unhandledExceptions": 0,
"temporaryInstallationRemoved": true,
"productionDeploy": false
}
@@ -0,0 +1,3 @@
# Golden test plan
Capture a minimal 2019 Ortho fixture with input objects, output blocks/attributes, text, layer, style, coordinates, rotation, scale, DataLinks, before/after usage event and repeat-run behavior. Recreate the same fixture in 2026 only after the adapter is available. Do not commit production DWG/DCF.
@@ -0,0 +1,5 @@
RuleId,TargetClass,TargetMethod,LegacyFile,LegacyLines,Behavior,MigrationDecision,TestName
RULE-001,AnnotationRuleEngine,BuildPlan,AutoAnnotateView/Commands.cs,476-500,Remove prior annotations then insert calculated annotations,REIMPLEMENT_SAME_BEHAVIOR,ExistingAnnotation_IsUpdatedNotDuplicated
RULE-002,AnnotationRuleEngine,BuildPlan,OrthoAutoAnnotate/Commands.cs,6444-6488,Require Plant project and registered Ortho drawing,KEEP_AFTER_ISOLATION,PlantProjectAndOrthoDrawingRequired
RULE-003,PlantObjectReader,ReadObjects,README.md,96-101,Read EngineeringItems and line-group relationships through PnP API,REIMPLEMENT_SAME_BEHAVIOR,PlantAdapter_ReadsGoldenObjects
RULE-004,CommandRegistry,Foundation,OrthoLoader/Ribbon.cs,109-129,Expose annotation commands in annotation panel,REIMPLEMENT_SAME_BEHAVIOR,AllActiveCommands_AreMapped
1 RuleId TargetClass TargetMethod LegacyFile LegacyLines Behavior MigrationDecision TestName
2 RULE-001 AnnotationRuleEngine BuildPlan AutoAnnotateView/Commands.cs 476-500 Remove prior annotations then insert calculated annotations REIMPLEMENT_SAME_BEHAVIOR ExistingAnnotation_IsUpdatedNotDuplicated
3 RULE-002 AnnotationRuleEngine BuildPlan OrthoAutoAnnotate/Commands.cs 6444-6488 Require Plant project and registered Ortho drawing KEEP_AFTER_ISOLATION PlantProjectAndOrthoDrawingRequired
4 RULE-003 PlantObjectReader ReadObjects README.md 96-101 Read EngineeringItems and line-group relationships through PnP API REIMPLEMENT_SAME_BEHAVIOR PlantAdapter_ReadsGoldenObjects
5 RULE-004 CommandRegistry Foundation OrthoLoader/Ribbon.cs 109-129 Expose annotation commands in annotation panel REIMPLEMENT_SAME_BEHAVIOR AllActiveCommands_AreMapped
@@ -0,0 +1,18 @@
# Migration dashboard
The first real Plant 3D 2026 build is complete against the workstation assemblies (AutoCAD managed 25.1.0.0; Plant managed 17.0.60.1; AdWindows 5.2.1.1). Core remains Autodesk-free.
| Area | Status |
|---|---|
| `PHP_REC_ANNOT` | Registered real `CommandMethod`; primary diagnostic entry point |
| `PXP-AAV` | Registered real `CommandMethod`; diagnostic alias |
| `PXP-OA` | Registered real `CommandMethod`; diagnostic alias |
| Ribbon | Implemented with `Autodesk.Windows`; idempotent tab/panel guard |
| Adapter | Compiled against real 2026 assemblies |
| Bundle | Local smoke package built and checked for Autodesk DLL exclusion |
| Runtime smoke | Manual NETLOAD confirmed on Plant 3D 2026; automatic AppLoader and Ortho DryRun remain unverified |
| Ortho read path | Compiled against `OrthoDocUtils`, `OrthoProject`, `DwgSheetDefinitions`, `DwgViewDefs`, and `DataLinksManager`; runtime fixture pending |
| Dry-run plan | Deterministic Core builder with DTO boundary and Apply guard; no annotation writes |
| Production deploy | NOT PERFORMED |
Fixed API assumptions: foundation-only lifecycle became `IExtensionApplication`; command descriptors became actual `CommandMethod` registrations; Ortho detection uses `OrthoDocUtils.IsValidOrthoDrawing` plus the Ortho project part; viewport metadata uses `DwgSheetDefinitions`/`DwgViewDefs`; object properties use read-only `DataLinksManager.SelectAcPpRowIds` and `GetAllProperties`; Core receives only immutable DTOs. Remaining blockers are a controlled Ortho fixture, geometry/object-to-viewport mapping, legacy source provenance, and future Apply implementation.
@@ -0,0 +1,15 @@
# Module migration matrix
| Legacy module | Target project | Classification | Wave |
|---|---|---|---|
| AnnotateLineNumbers | PRecAnnot.Core + Plant3D2026 | KEEP_AFTER_ISOLATION | 3 |
| AutoAnnotateView | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
| OrthoAutoAnnotate | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
| AutoLinenumberConnections | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 2 |
| AutoElevation/InsertElevation | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 3 |
| UpdateOrthoDrawings | Plant3D2026/Application | DEFER_AND_RETEST | 5 |
| OrthoLoader | bundle/entry composition root | DO_NOT_REUSE | 1 |
| Ribbon | PRecAnnot.UI | REIMPLEMENT | 1 |
| GetLinenumberAnnotationText | Plant adapter + Core mapper | KEEP_AFTER_ISOLATION | 2 |
| PHPAnnotations/P3DNewMa | UI + block adapter | REVIEW_USAGE | 4 |
| telemetry | PRecAnnot.Telemetry | REIMPLEMENT | 1 |
@@ -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,9 @@
# PXP-AAV read-path analysis
Status: traced to the existing command registration and the shared dry-run plan path; annotation writes are not implemented in this slice.
| RuleId | LegacyCommand | LegacyProject | LegacyFile | ObservedBehavior | TargetUseCase | TargetClass | Test | Status |
|---|---|---|---|---|---|---|---|---|
| PXP-AAV-001 | PXP-AAV | legacy audit | `docs/legacy-rule-provenance.csv` | legacy provenance is recorded, but the requested legacy checkout is absent on this workstation | read objects visible to an Ortho view and build a dry-run plan | adapter DTO -> `AnnotationPlan` | `AnnotationPlan_InputOrderDoesNotChangeResult` | partial |
The implementation deliberately does not claim behavioral equivalence. The next required input is a read-only copy of the legacy source or a golden test Ortho drawing with expected diagnostics.
@@ -0,0 +1,9 @@
# PXP-OA read-path analysis
Status: command is a real `CommandMethod` and uses the same safe context/read-plan pipeline. No Ortho annotation is created, updated, or deleted.
| RuleId | LegacyCommand | LegacyProject | LegacyFile | ObservedBehavior | TargetUseCase | TargetClass | Test | Status |
|---|---|---|---|---|---|---|---|---|
| PXP-OA-001 | PXP-OA | legacy audit | `docs/legacy-rule-provenance.csv` | legacy provenance is available, but source checkout was not found locally | Ortho context and DataLinks read diagnostics | adapter DTO -> `AnnotationPlan` | `ApplyMode_IsBlocked` | partial |
Exact object filters, annotation text, placement rules, and existing-annotation detection remain unconfirmed until the legacy source or a controlled golden fixture is supplied.
@@ -0,0 +1,52 @@
# Release asset mapping
This is the release-to-composite contract for the owner-provided `P_Rec_Annot
v1.0.4.2` payload. It deliberately does not copy legacy binaries or a DWG into
source control. Autodesk assemblies, legacy plugin DLLs, Plant project data,
and the controlled `Template.dwg` fixture are excluded by repository policy.
## Executable payload
| Legacy release artifact | Modern functional replacement | Package disposition |
| --- | --- | --- |
| `AnnotateLineNumbers.dll`, `GetLinenumberAnnotationText.dll` | `PRecAnnot.Core`, `PRecAnnot.Application`, `PRecAnnot.Plant3D2026` line-number services | Replaced by composite assemblies |
| `AutoLinenumberConnections.dll` | connector snapshots, connection planner and Plant adapter | Replaced by composite assemblies |
| `AutoElevation.dll`, `InsertElevation.dll` | elevation formatter, planner and Plant mutation adapters | Replaced by composite assemblies |
| `AutoAnnotateView.dll`, `OrthoAutoAnnotate.dll` | modern Ortho annotation commands and adapters | Replaced by composite assemblies |
| `CheckOrthoView.dll`, `UpdateOrthoViewBlocks.dll`, `P3DCopyMissingObjectsToOrthoDrawing.dll`, `P3DSetLayerForViewport.dll`, `UpdateOrthoDrawings.dll` | modern Ortho maintenance commands and adapters | Replaced by composite assemblies |
| `GetComponentGeometry.dll`, `ExtentsIntersect.dll` | `PRecAnnot.Core` geometry contracts and Plant geometry reader | Replaced by composite assemblies |
| `P3DNewMa.dll`, `PHPAnnotations.dll`, `SwapAttributesMenuItem.dll` | `PRecAnnot.UI` palette and manual annotation commands | Replaced by composite assemblies |
| `GetPlantScriptFolderPath.dll`, `GetScriptParameters.dll` | typed portable settings/path contract | Replaced by composite assemblies |
| `OrthoLoader.dll`, `LoadedAssemblies.dll`, `EnablerLoader.dll` | composite bootstrap dependency order and capability checks | Replaced by the single entry assembly; legacy loaders never ship or execute |
The package file manifest generated by `tools/Build-Bundle.ps1` records SHA-256
for every package file. `tools/Validate-Bundle.ps1` verifies that manifest
before materialization.
## Configuration and named-object contract
| Artifact | Modern consumer / role | Missing-resource behavior |
| --- | --- | --- |
| `Settings.xml` | `bundle/P_Rec_Annot.bundle/Settings.xml`, parsed by `ProductionSettingsContract` and feature-specific settings readers | Command preflight reports configuration failure; no mutation occurs |
| `Template.dwg` | Controlled local fixture/template source for line-number, connection, elevation, Ortho and manual block/style contracts | Never silently depend on an arbitrary drawing. Commands report the required block/style as unavailable and make no mutation |
| block definitions and attribute tags | Resolved by the respective annotation/manual command before transaction | Preflight failure; no partial creation |
| layers, text styles, MLeader styles, dimension styles | Resolved by feature settings and the controlled template contract | Preflight failure; no partial mutation |
| dynamic block properties | Read and updated only by the manual annotation workflow when the selected block exposes them | Preserve existing values when absent; do not synthesize properties |
`Template.dwg` has release SHA-256
`22CAAD35CE9C55891CF91042ED9B2C23BA4D5A0745AA40D69F41C1F609DAADB3`.
It must be supplied through the controlled fixture provisioner, not committed or
bundled as a production drawing.
## User-facing resources
| Release artifacts | Consumer | Mapping state |
| --- | --- | --- |
| `ico/button_*.png` (20 files) | Legacy Ribbon/palette imagery | Source inventory retained; modern Ribbon currently uses command bindings without those binary images. Image packaging requires an approved non-binary resource import and explicit UI runtime evidence. |
| `Справка по работе с палитрой.docx` and `.pdf` | Manual annotation help surface | Documentation assets are retained in the owner release. The modern palette exposes no fabricated local copy; help links must resolve from a package-approved documentation asset before UI runtime verification. |
| `GetComponentGeometry.xml` | Legacy assembly XML documentation | Functionally replaced by modern source/API documentation; it is not a runtime dependency. |
The two rows marked as requiring approved resource import are intentionally not
classified as packaged. They remain explicit release-asset and UI-runtime gaps,
rather than being hidden by copying legacy content or claiming unverified
resource behavior.
@@ -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.
@@ -0,0 +1,31 @@
# Plant 3D 2026 runtime smoke results
## Scope
The smoke test used a temporary per-user bundle installation only. Production deploy was not performed. The temporary installation was removed after AutoCAD closed.
## Confirmed
- Manual `NETLOAD` loaded the P_Rec_Annot entry assembly in AutoCAD Plant 3D 2026.
- `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA` were accepted by the command line.
- The active Plant project was detected as `DefaultProject`.
- The user confirmed Smoke mode behavior and no DWG mutation during the manual smoke interaction.
- The runtime log contains one assembly initialization and one normal termination for the observed process.
- The runtime log records Ribbon creation with status `Created`.
- The runtime log records exactly one `CommandUsageEvent` for each of the three commands.
- The observed Plant context reported `DataLinksAvailable=true` and `IsOrthoDrawing=false` for `Floor Plan Sample.dwg`.
- No unhandled exception was reported in the captured runtime log.
## Not confirmed
- Automatic AppLoader loading without `NETLOAD` was not tested.
- DBMOD and byte-level DWG/DCF/Project.xml comparisons were not captured by the smoke evidence.
- Ribbon duplicate behavior across workspace/document lifecycle was not exhaustively tested.
- A command invocation logged as `RuntimeMode=Smoke` is not present in the captured JSONL; the three detailed command records have `RuntimeMode=Normal`.
## Evidence handling
The detailed runtime JSONL remains local under `%LOCALAPPDATA%\Promhimproekt\PRecAnnot2026\Logs\` and is not committed. No DWG, DCF, Project.xml, Autodesk DLL, user identity, project path, or full local runtime path is included in the repository.
Temporary installation: PERFORMED AND REMOVED.
Production deploy: NOT PERFORMED.
@@ -0,0 +1,19 @@
# Runtime test plan
Runtime smoke is ready but has not been executed in AutoCAD. The local package is `Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle`.
Next-step installation command (intentionally not executed):
```powershell
Copy-Item -Recurse -Force .\Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle `
"$env:APPDATA\Autodesk\ApplicationPlugins\P_Rec_Annot.bundle"
```
In a controlled Plant 3D 2026 session, verify that the entry assembly loads, the `Промхимпроект` tab and `Аннотации` panel occur once, and the `Аннотировать` button invokes `PHP_REC_ANNOT`. Open a Plant project and active DWG, run all three commands, verify the project guard and diagnostic-only behavior, and confirm no unhandled exception. Repeat initialization and confirm no Ribbon duplicate. DWG mutation, Ortho viewport/data-link behavior, undo, repeat-run idempotence, and golden-DWG comparison remain runtime blockers for the implementation wave.
Deploy status: NOT PERFORMED.
# Current slice: Ortho read / dry-run
`P_REC_ANNOT_RUNTIME_MODE=Smoke` reads context only. `DryRun` reads Ortho view metadata and DataLinks properties, builds an immutable deterministic `AnnotationPlan`, and never applies it. `Apply` is blocked with `APPLY_MODE_NOT_IMPLEMENTED`; unknown values resolve to `DryRun`.
The read adapter uses `OrthoDocUtils.IsValidOrthoDrawing`, `OrthoProject.DwgSheetDefinitions`, `IDwgViewDef`, and read-only `DataLinksManager` methods. The adapter does not expose AutoCAD or Plant objects to Core and contains no write calls. A runtime DryRun result is not published until a controlled test Ortho DWG is available; see `docs/evidence/ortho-dry-run-summary.json`.
@@ -0,0 +1,455 @@
namespace PRecAnnot.Abstractions;
public sealed record RuntimeDiagnostic
{
public required string Code { get; init; }
public required string Message { get; init; }
public string Severity { get; init; } = "Warning";
}
public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed }
public enum PlantProjectDetectionStatus { NotFound, FoundWithName, FoundWithoutDisplayName, Ambiguous, Failed }
public sealed record OrthoDetectionResult
{
public required OrthoDetectionStatus Status { get; init; }
public required IReadOnlyList<string> Evidence { get; init; }
public required IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; }
}
public readonly record struct Point2Dto(double X, double Y);
public readonly record struct Point3Dto(double X, double Y, double Z);
public readonly record struct Vector2Dto(double X, double Y);
public sealed record Bounds2Dto(Point2Dto Minimum, Point2Dto Maximum);
public sealed record Bounds3Dto(Point3Dto Minimum, Point3Dto Maximum);
public enum ExtentsIntersectionType { DoNotIntersect, Intersect, ExtentsMatch, FirstExtentsInsideSecondExtents, SecondExtentsInsideFirstExtents }
public sealed record LineNumberAnnotationSettings
{
public required string NullOrEmpty { get; init; }
public required IReadOnlyList<LineNumberAnnotationDefinition> Annotations { get; init; }
}
public sealed record LineNumberAnnotationDefinition
{
public required string Nickname { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; } = new Dictionary<string, string>();
public required IReadOnlyList<LineNumberAnnotationTag> Tags { get; init; }
}
public sealed record LineNumberAnnotationTag
{
public required string Field { get; init; }
public string Divider { get; init; } = string.Empty;
public bool SaveWhenEmpty { get; init; } = true;
public string IgnoreValue { get; init; } = string.Empty;
public string ProcessingXml { get; init; } = string.Empty;
}
public sealed record LineNumberFormattingResult(bool Success, string Text, string? ErrorCode = null);
public interface ILineNumberAnnotationFormatter
{
LineNumberFormattingResult Format(LineNumberAnnotationDefinition definition, IReadOnlyDictionary<string, string?> fields);
}
public sealed record DataLinksPropertyReadResult(bool Success, IReadOnlyDictionary<string, string?> Properties, string? FailureCode = null);
public interface IDataLinksPropertyReader { DataLinksPropertyReadResult ReadProperties(long rowId); }
public sealed record LineNumberFieldResolution(bool Success, IReadOnlyDictionary<string, string?> Values, string? FailureCode = null);
public sealed record LineNumberCandidate(string StableId, long RowId, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyDictionary<string, string?> Properties);
public sealed record ExistingLineNumberAnnotation(string StableId, string SourceStableId, string Text);
public sealed record LineNumberCommandOperation(string StableId, AnnotationOperationKind Kind, string? Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyList<string> Reasons);
public sealed record LineNumberCommandPlan(IReadOnlyList<LineNumberCommandOperation> Operations);
public sealed record LineNumberApplyPreflight(string StableId, int MarkerNumber, string Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle);
public sealed record LineNumberApplyResult(int Created, int Updated, int Unchanged);
public sealed record LineNumberListApplyPreflight(string StableId, AnnotationPoint Position, double Rotation, IReadOnlyList<LineNumberApplyPreflight> Rows);
public sealed record LineNumberPreviewResult(bool Success, LineNumberCommandPlan? Plan, IReadOnlyList<LineNumberApplyPreflight> Apply, string? FailureCode = null);
public sealed record LineNumberCandidateReadResult(bool Success, LineNumberCandidate? Candidate, string? FailureCode = null);
public sealed record ElevationCandidate(string StableId, double ModelElevation, double DatumOffset, AnnotationPoint Position, double Rotation, string Layer, string TextStyle);
public sealed record ExistingElevationAnnotation(string StableId, string SourceStableId, string Text, AnnotationPoint Position);
public sealed record ElevationAnnotationOperation(string StableId, AnnotationOperationKind Kind, string? Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyList<string> Reasons);
public sealed record ElevationAnnotationPlan(IReadOnlyList<ElevationAnnotationOperation> Operations);
public sealed record ElevationAssets(string Layer, string TextStyle, string BlockName = "BLOCK_ELEVATION");
public sealed record ElevationApplyResult(int Created, int Updated, int Unchanged);
public sealed record LineConnectionCandidate(string StableId, string LineNumber, Point3Dto FirstEndpoint, Point3Dto SecondEndpoint, Bounds3Dto Bounds, AnnotationPoint Position, double Rotation);
public sealed record LineConnectionGroup(string LineNumber, IReadOnlyList<LineConnectionCandidate> Members, AnnotationPoint Placement, double Rotation);
// A legacy PXP-ALC annotation belongs to a connector between two *different*
// line groups; it is not a duplicate marker for one line group.
public sealed record LineConnectionBoundaryCandidate(
string StableId,
string FirstLineNumber,
string SecondLineNumber,
Point3Dto FirstEndpoint,
Point3Dto SecondEndpoint,
Bounds3Dto Bounds,
AnnotationPoint Position,
double Rotation,
string? FirstLineGroupStableId = null,
string? SecondLineGroupStableId = null);
public sealed record LineConnectionBoundaryPlan(string StableId, string Text, AnnotationPoint Position, double Rotation, IReadOnlyList<string> Reasons, string? FirstText = null, string? SecondText = null, Point3Dto? ConnectorPosition = null);
public sealed record LineConnectionAssets(string PolylineLayer, string TextLayer, string TextStyle, string BlockName = "BLOCK_LINENUMBER_CONNECTION");
public sealed record LineConnectionApplyResult(int Deleted, int Created);
// The geometry adapter supplies the paper-space connector point and orientation.
// Keeping it separate from the PnP relation snapshot prevents Autodesk objects
// from crossing into the deterministic PXP-ALC planning layer.
public sealed record LineConnectionPlacement(
Point3Dto FirstEndpoint,
Point3Dto SecondEndpoint,
Bounds3Dto Bounds,
AnnotationPoint Position,
double Rotation);
public sealed record LineConnectionPreviewResult(
bool Success,
IReadOnlyList<LineConnectionBoundaryPlan> Plans,
string? FailureCode = null);
public sealed record PlantConnectorRelationshipSnapshot(
string StableId,
long ConnectorRowId,
long FirstPartRowId,
long SecondPartRowId,
long FirstLineGroupRowId,
long SecondLineGroupRowId,
IReadOnlyList<string> Diagnostics,
string? ConnectorEntityStableId = null);
public sealed record PlantConnectorRelationshipReadResult(bool Success, IReadOnlyList<PlantConnectorRelationshipSnapshot> Connectors, string? FailureCode = null);
public interface IPlantConnectorRelationshipReader
{
PlantConnectorRelationshipReadResult Read();
}
public sealed record PlantConnectorPortSnapshot(
string ConnectorStableId,
Point3Dto FirstPosition,
Point3Dto SecondPosition,
Point3Dto FirstDirection,
Point3Dto SecondDirection);
public sealed record PlantConnectorPortReadResult(bool Success, PlantConnectorPortSnapshot? Ports, string? FailureCode = null);
public interface IPlantConnectorPortReader
{
PlantConnectorPortReadResult Read(PlantConnectorRelationshipSnapshot connector);
}
public sealed record ConnectorViewportEligibility(bool Eligible, Point3Dto? PlacementPoint, string? FailureCode = null);
public sealed record LineNumberOwnedRow(string SourceStableId, int MarkerNumber, string Text, AnnotationPoint MarkerAttributePosition);
public sealed record LineNumberRowMutation(string SourceStableId, int PreviousMarkerNumber, int MarkerNumber, string Text, AnnotationPoint? MarkerAttributePosition);
public sealed record LineNumberTextAsset(string Layer, string TextStyle, double Size, string TextValue, Point2Dto Position);
public sealed record LineNumberLineAsset(string Layer, Point2Dto Start, Point2Dto End);
public sealed record LineNumberMarkerAsset(string Layer, double MaskScaleFactor, IReadOnlyList<LineNumberTextAsset> Texts, IReadOnlyList<LineNumberLineAsset> Lines);
public sealed record LineNumberListAsset(string Layer, double MaskScaleFactor, double LineHeight, double LineSpacingDistance, LineNumberTextAsset MarkerText, LineNumberTextAsset LineNumberText, LineNumberLineAsset Line);
public sealed record AnnotateLineNumbersAssets(LineNumberMarkerAsset Marker, LineNumberListAsset List);
public sealed record GeometrySnapshot
{
public Point3Dto? WorldPosition { get; init; }
public Point2Dto? PaperPosition { get; init; }
public Bounds2Dto? ProjectedBounds { get; init; }
public double? Rotation { get; init; }
public double? Elevation { get; init; }
public Vector2Dto? PaperDirection { get; init; }
}
// Platform-neutral result of the legacy GetComponentGeometry contract. The
// Plant adapter is responsible for reading Parts/Pipes and normalising units;
// consumers do not take a dependency on Autodesk managed assemblies.
public sealed record ComponentGeometryDefinition(
string Kind,
IReadOnlyDictionary<string, string> Parameters,
Bounds3Dto Bounds,
bool ImperialDimensions);
public sealed record ComponentGeometryReadResult(bool Success, ComponentGeometryDefinition? Geometry, string? FailureCode = null);
public sealed record ViewportLayerPlan(bool RequiresChange, string TargetLayer, string? FailureCode = null);
public enum OrthoAnnotationRepresentation { None, MultiLeader, BubbleBlock }
public sealed record OrthoAnnotationOptions(bool Supports, bool Valves, bool Instruments, bool Plugs, bool Boards);
public sealed record OrthoAnnotationClassification(string StableId, OrthoAnnotationRepresentation Representation, string Text, IReadOnlyList<string> Reasons);
public sealed record OrthoAnnotationAssets(string Layer, string TextStyle, string MultiLeaderStyle, string BubbleBlock = "BLOCK_ANNO_POSITION");
public sealed record OrthoBatchDrawing(string DrawingGuid, string DisplayName, string RelativePath, IReadOnlyList<string> Views);
public sealed record OrthoBatchSelection(string RelativePath, IReadOnlyList<string> Views);
public sealed record OrthoBatchOperation(string DrawingGuid, string DisplayName, string RelativePath, string ViewName);
public sealed record OrthoBatchPlan(IReadOnlyList<OrthoBatchOperation> Operations, IReadOnlyList<string> Diagnostics);
public enum OrthoLayerFilterKind { Status, Layer, Table }
public sealed record OrthoLayerRule(OrthoLayerFilterKind Kind, string Filter, int? LineWeight, string LineType, short? ColorIndex, byte? Red, byte? Green, byte? Blue)
{
public bool MatchesStatus(string status) => Kind == OrthoLayerFilterKind.Status && string.Equals(Filter, status, StringComparison.OrdinalIgnoreCase);
public bool MatchesLayer(string viewName, string layer) => Kind == OrthoLayerFilterKind.Layer && string.Equals(viewName + "-" + Filter, layer, StringComparison.OrdinalIgnoreCase);
}
public interface IPlantComponentGeometryReader
{
ComponentGeometryReadResult Read(string stableEntityId, bool convertToMetric);
}
public sealed record OrthoViewportInfo
{
public required string Id { get; init; }
public required string DisplayName { get; init; }
public double CenterX { get; init; }
public double CenterY { get; init; }
public double Width { get; init; }
public double Height { get; init; }
public double Scale { get; init; }
public double TwistAngle { get; init; }
public bool IsOn { get; init; }
public bool IsLocked { get; init; }
public string? SourceDrawingName { get; init; }
public string? ViewName { get; init; }
}
public sealed record PlantObjectSnapshot
{
public required string SnapshotId { get; init; }
public required string ObjectClass { get; init; }
public string? Tag { get; init; }
public string? LineNumber { get; init; }
public string? SupportNumber { get; init; }
public string? EquipmentNumber { get; init; }
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
{
public bool HasActiveDocument { get; init; }
public bool HasPlantProject { get; init; }
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>() };
}
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
{
public required string SourceSnapshotId { get; init; }
public required AnnotationOperationKind Kind { get; init; }
public string? AnnotationType { get; init; }
public string? Text { get; init; }
public Point2Dto? Position { get; init; }
public double? Rotation { get; init; }
public string? ExistingAnnotationId { get; init; }
public required IReadOnlyList<string> Reasons { get; init; }
}
public sealed record AnnotationPlanSummary
{
public int ObjectsExamined { get; init; }
public int CreateCount { get; init; }
public int UpdateCount { get; init; }
public int SkipCount { get; init; }
public int ConflictCount { get; init; }
public int UnsupportedCount { get; init; }
public int WarningCount { get; init; }
public int ErrorCount { get; init; }
}
public sealed record AnnotationPlanningRequest
{
public required string CommandName { get; init; }
public required OrthoViewportInfo Viewport { get; init; }
public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>();
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 enum PRecAnnotExecutionMode { Smoke, DryRun, Apply }
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 PlantObjectData
{
public required string ObjectId { get; init; }
public required string ClassName { get; init; }
public string? LineNumber { get; init; }
public string? Tag { get; init; }
public string? DisplayName { get; init; }
public required AnnotationPoint Position { get; init; }
public double Rotation { get; init; }
}
public readonly record struct AnnotationPoint(double X, double Y, double Z = 0);
public sealed record ExistingAnnotation(string StableKey, string? Text, AnnotationPoint Position);
public sealed record AnnotationRequest
{
public required OrthoViewportRef Viewport { get; init; }
public required IReadOnlyList<PlantObjectData> Objects { get; init; }
public required IReadOnlyList<ExistingAnnotation> Existing { get; init; }
}
public sealed record AnnotationIntent
{
public required string StableKey { get; init; }
public required string Text { get; init; }
public required AnnotationPoint Position { get; init; }
public required double Rotation { get; init; }
public required string ClassName { get; init; }
public required string Layer { get; init; }
}
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>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
public AnnotationPlanSummary Summary { get; init; } = new();
public required IReadOnlyList<AnnotationIntent> Create { get; init; }
public required IReadOnlyList<AnnotationIntent> Update { get; init; }
public required IReadOnlyList<string> Delete { get; init; }
public required IReadOnlyList<string> SkipReasons { get; init; }
public int ExaminedCount { get; init; }
public bool IsDeterministic { get; init; } = true;
}
public interface IPlantProjectContext
{
bool IsPlantProjectOpen { get; }
string? ProjectName { get; }
}
public interface IOrthoDrawingContext
{
bool IsOrthoDrawing { get; }
IReadOnlyList<OrthoViewportRef> GetViewports();
}
public interface IPlantObjectReader
{
IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken);
}
public interface IAnnotationRepository
{
IReadOnlyList<ExistingAnnotation> FindExisting(OrthoViewportRef viewport);
AnnotationWriteResult Apply(AnnotationPlan plan, CancellationToken cancellationToken);
}
public sealed record AnnotationWriteResult(int Created, int Updated, int Deleted, int Skipped);
public interface IAnnotationRuleEngine
{
AnnotationPlan BuildPlan(AnnotationRequest request);
}
public sealed record CommandUsageEvent
{
public required string CommandName { get; init; }
public required string ResultStatus { get; init; }
public int ObjectsExamined { get; init; }
public int AnnotationsCreated { get; init; }
public int AnnotationsUpdated { get; init; }
public int AnnotationsDeleted { get; init; }
public int AnnotationsSkipped { get; init; }
public int WarningCount { get; init; }
public int ErrorCount { get; init; }
public long DurationMilliseconds { get; init; }
public DateTimeOffset TimestampUtc { get; init; } = DateTimeOffset.UtcNow;
}
public interface ICommandUsageSink
{
void Record(CommandUsageEvent usageEvent);
}
@@ -0,0 +1,3 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
</Project>
@@ -0,0 +1,36 @@
namespace PRecAnnot.Application;
public sealed record PRecAnnotCommandDescriptor
{
public required string CommandName { get; init; }
public required string DisplayName { get; init; }
public required string UseCaseId { get; init; }
public required string RibbonPanel { get; init; }
public bool RequiresPlantProject { get; init; }
public bool RequiresOrthoDrawing { get; init; }
public bool RequiresSelection { get; init; }
public string Status { get; init; } = "BLOCKED_WITH_REASON";
}
public sealed class CommandRegistry
{
private readonly IReadOnlyDictionary<string, PRecAnnotCommandDescriptor> _commands;
public CommandRegistry(IEnumerable<PRecAnnotCommandDescriptor> commands)
{
var list = commands.ToList();
if (list.GroupBy(x => x.CommandName, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1))
throw new ArgumentException("Command names must be unique.", nameof(commands));
_commands = list.ToDictionary(x => x.CommandName, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyCollection<PRecAnnotCommandDescriptor> Commands => _commands.Values.ToArray();
public bool TryGet(string name, out PRecAnnotCommandDescriptor? descriptor) => _commands.TryGetValue(name, out descriptor);
public static CommandRegistry Foundation() => new(new[]
{
new PRecAnnotCommandDescriptor { CommandName = "PHP_REC_ANNOT", DisplayName = "Аннотировать", UseCaseId = "ortho-annotation", RibbonPanel = "Аннотации", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" },
new PRecAnnotCommandDescriptor { CommandName = "PXP-AAV", DisplayName = "Разместить аннотации автоматически", UseCaseId = "auto-annotate-view", RibbonPanel = "Аннотации", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" },
new PRecAnnotCommandDescriptor { CommandName = "PXP-OA", DisplayName = "Разместить осевые линии и опоры", UseCaseId = "ortho-axes-supports", RibbonPanel = "Осевые линии и опоры", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" }
});
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,35 @@
using System.Globalization;
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotateLineNumbersSettingsLoader
{
public AnnotateLineNumbersAssets Load(string xml)
{
var lineNumbers = XElement.Parse(xml).Element("LineNumbers") ?? throw Invalid("LineNumbers");
var marker = lineNumbers.Element("Marker") ?? throw Invalid("Marker");
var list = lineNumbers.Element("List") ?? throw Invalid("List");
var texts = marker.Elements("Text").Select(x => Text(x)).ToArray();
var lines = marker.Elements("Line").Select(Line).ToArray();
if (texts.Length == 0 || lines.Length == 0) throw Invalid("MarkerContent");
return new(
new(Required(marker, "layer"), Number(marker, "maskscalefactor"), texts, lines),
new(Required(list, "layer"), Number(list, "maskscalefactor"), Number(list, "lineheight"), Number(list, "linespacingdistance"), Text(RequiredElement(list, "MarkerText"), false), Text(RequiredElement(list, "LineNumberText"), false), Line(RequiredElement(list, "Line"))));
}
private static LineNumberTextAsset Text(XElement value, bool readValue = true) => new(Required(value, "layer"), Required(value, "textstyle"), Number(value, "size"), readValue ? Required(value, "textvalue", true) : string.Empty, new(Number(value, "positionx"), Number(value, "positiony")));
private static LineNumberLineAsset Line(XElement value) => new(Required(value, "layer"), new(Number(value, "startx"), Number(value, "starty")), new(OptionalNumber(value, "endx"), OptionalNumber(value, "endy")));
private static XElement RequiredElement(XElement parent, string name) => parent.Element(name) ?? throw Invalid(name);
private static string Required(XElement value, string name, bool emptyAllowed = false)
{
var attribute = value.Attribute(name) ?? throw Invalid(name);
if (!emptyAllowed && string.IsNullOrWhiteSpace(attribute.Value)) throw Invalid(name);
return attribute.Value;
}
private static double Number(XElement value, string name) => Parse(Required(value, name));
private static double OptionalNumber(XElement value, string name) => value.Attribute(name) is { } attribute ? Parse(attribute.Value) : 0;
private static double Parse(string value) => double.TryParse(value.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var result) ? result : throw Invalid("number");
private static InvalidDataException Invalid(string field) => new("LINE_NUMBER_SETTINGS_INVALID:" + field);
}
@@ -0,0 +1,193 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{
public const int CurrentPlanSchemaVersion = 2;
public AnnotationPlan Build(AnnotationPlanningRequest request)
{
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
var existing = request.Existing.ToDictionary(x => x.StableKey, StringComparer.OrdinalIgnoreCase);
var operations = new List<AnnotationOperationPlan>(objects.Length);
var creates = new List<AnnotationIntent>();
var updates = new List<AnnotationIntent>();
var diagnostics = new List<RuntimeDiagnostic>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in objects)
{
if (!seen.Add(item.SnapshotId))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Conflict, Reasons = new[] { "duplicate-snapshot-id" } });
continue;
}
if (string.IsNullOrWhiteSpace(item.ObjectClass))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "missing-object-class" } });
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();
if (string.IsNullOrWhiteSpace(text))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "missing-line-number-or-tag" } });
continue;
}
var position = item.Geometry.PaperPosition;
if (position is null)
{
diagnostics.Add(new RuntimeDiagnostic { Code = "MISSING_GEOMETRY", Message = $"No paper position for {item.SnapshotId}." });
}
var stableKey = $"{request.Viewport.SourceDrawingName ?? string.Empty}:{request.Viewport.Id}:{item.SnapshotId}";
var existingAnnotation = existing.TryGetValue(stableKey, out var match) ? match : existing.TryGetValue(item.SnapshotId, out var directMatch) ? directMatch : null;
var kind = existingAnnotation is null ? AnnotationOperationKind.Create : AnnotationOperationKind.Update;
operations.Add(new AnnotationOperationPlan
{
SourceSnapshotId = item.SnapshotId,
Kind = kind,
AnnotationType = "PlantObject",
Text = text,
Position = position,
Rotation = item.Geometry.Rotation,
ExistingAnnotationId = existingAnnotation?.StableKey,
Reasons = Array.Empty<string>()
});
var intent = new AnnotationIntent
{
StableKey = stableKey,
Text = text,
Position = position is { } point ? new AnnotationPoint(point.X, point.Y) : new AnnotationPoint(0, 0),
Rotation = item.Geometry.Rotation ?? 0,
ClassName = item.ObjectClass,
Layer = "PXP_ANNOTATION"
};
if (kind == AnnotationOperationKind.Create) creates.Add(intent); else updates.Add(intent);
}
var summary = new AnnotationPlanSummary
{
ObjectsExamined = objects.Length,
CreateCount = operations.Count(x => x.Kind == AnnotationOperationKind.Create),
UpdateCount = operations.Count(x => x.Kind == AnnotationOperationKind.Update),
SkipCount = operations.Count(x => x.Kind == AnnotationOperationKind.Skip),
ConflictCount = operations.Count(x => x.Kind == AnnotationOperationKind.Conflict),
UnsupportedCount = operations.Count(x => x.Kind == AnnotationOperationKind.Unsupported),
WarningCount = diagnostics.Count(x => !string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase)),
ErrorCount = diagnostics.Count(x => string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase))
};
return new AnnotationPlan
{
PlanSchemaVersion = CurrentPlanSchemaVersion,
PlanId = BuildPlanId(request, objects, operations),
CommandName = request.CommandName,
Operations = operations,
Diagnostics = diagnostics,
Summary = summary,
Create = creates,
Update = updates,
Delete = Array.Empty<string>(),
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, IReadOnlyList<AnnotationOperationPlan> operations)
{
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))
.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()));
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"
};
}
@@ -0,0 +1,27 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
{
public AnnotationPlan BuildPlan(AnnotationRequest request)
{
var objects = request.Objects.Select(item => new PlantObjectSnapshot
{
SnapshotId = item.ObjectId,
ObjectClass = item.ClassName,
Tag = item.Tag,
LineNumber = item.LineNumber,
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
{
CommandName = "PHP_REC_ANNOT",
Viewport = new OrthoViewportInfo { Id = request.Viewport.ViewportId, DisplayName = request.Viewport.ViewportId, SourceDrawingName = request.Viewport.DrawingId },
Objects = objects,
Existing = request.Existing
});
}
}
@@ -0,0 +1,15 @@
namespace PRecAnnot.Core;
public static class AttributeSwapPlanner
{
public static double ComputeShelfLength(double firstWidth, double firstOffset, double secondWidth, double secondOffset) =>
Math.Abs(Math.Max(firstWidth + firstOffset, secondWidth + secondOffset) + .9);
public static IReadOnlyDictionary<string, string> Swap(IReadOnlyDictionary<string, string> metadata, string first, string second)
{
var result = new Dictionary<string, string>(metadata, StringComparer.Ordinal);
if (result.TryGetValue(first, out var firstValue) && result.TryGetValue(second, out var secondValue))
{ result[first] = secondValue; result[second] = firstValue; }
return result;
}
}
@@ -0,0 +1,34 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Port of AutoLinenumberConnections.IsOKPrepareAnnotation's geometry gate.
// The caller performs model-to-paper transformation only after the connector
// is eligible, preserving legacy model-space containment semantics.
public sealed class ConnectorViewportEligibilityEvaluator
{
public ConnectorViewportEligibility Evaluate(PlantConnectorPortSnapshot ports, Point3Dto viewDirection, Bounds3Dto viewExtents, double parallelTolerance = 0.1)
{
if (parallelTolerance < 0) throw new ArgumentOutOfRangeException(nameof(parallelTolerance));
if (Parallel(ports.FirstDirection, viewDirection, parallelTolerance) || Parallel(ports.SecondDirection, viewDirection, parallelTolerance))
return new(false, null, "CONNECTOR_PORT_PARALLEL_TO_VIEW");
if (!Inside(ports.FirstPosition, viewExtents) || !Inside(ports.SecondPosition, viewExtents))
return new(false, null, "CONNECTOR_OUTSIDE_VIEW_EXTENTS");
// Legacy stores ports[1] as the insertion/model anchor.
return new(true, ports.SecondPosition);
}
private static bool Inside(Point3Dto point, Bounds3Dto bounds) =>
point.X >= bounds.Minimum.X && point.X <= bounds.Maximum.X &&
point.Y >= bounds.Minimum.Y && point.Y <= bounds.Maximum.Y &&
point.Z >= bounds.Minimum.Z && point.Z <= bounds.Maximum.Z;
private static bool Parallel(Point3Dto first, Point3Dto second, double tolerance)
{
var firstLength = Math.Sqrt(first.X * first.X + first.Y * first.Y + first.Z * first.Z);
var secondLength = Math.Sqrt(second.X * second.X + second.Y * second.Y + second.Z * second.Z);
if (firstLength == 0 || secondLength == 0) return true;
var cosine = Math.Abs((first.X * second.X + first.Y * second.Y + first.Z * second.Z) / (firstLength * secondLength));
return 1d - cosine <= tolerance;
}
}
@@ -0,0 +1,39 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ElevationAnnotationPlanner
{
public ElevationAnnotationPlan Build(IEnumerable<ElevationCandidate> candidates, IEnumerable<ExistingElevationAnnotation> existing)
{
var known = existing.GroupBy(value => value.SourceStableId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var operations = new List<ElevationAnnotationOperation>();
foreach (var candidate in candidates.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!seen.Add(candidate.StableId))
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_CANDIDATE"]));
continue;
}
if (string.IsNullOrWhiteSpace(candidate.Layer) || string.IsNullOrWhiteSpace(candidate.TextStyle) || double.IsNaN(candidate.ModelElevation) || double.IsInfinity(candidate.ModelElevation))
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ELEVATION_CANDIDATE_INVALID"]));
continue;
}
var text = LegacyElevationFormatter.Calculate(candidate.ModelElevation, candidate.DatumOffset);
var matches = known.GetValueOrDefault(candidate.StableId, []);
if (matches.Length > 1)
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_EXISTING_ANNOTATION"]));
continue;
}
if (matches.Length == 1 && string.Equals(matches[0].Text, text, StringComparison.Ordinal) && matches[0].Position == candidate.Position)
operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ALREADY_CURRENT"]));
else
operations.Add(new(candidate.StableId, matches.Length == 1 ? AnnotationOperationKind.Update : AnnotationOperationKind.Create, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, Array.Empty<string>()));
}
return new(operations);
}
}
@@ -0,0 +1,19 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ElevationSettingsLoader
{
public ElevationAssets Load(string xml)
{
var element = XElement.Parse(xml).Element("Elevations")
?? throw new InvalidDataException("ELEVATION_SETTINGS_INVALID:Elevations");
return new ElevationAssets(Required(element, "layer"), Required(element, "textstyle"));
}
private static string Required(XElement element, string name) =>
element.Attribute(name) is { Value: var value } && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidDataException("ELEVATION_SETTINGS_INVALID:" + name);
}
@@ -0,0 +1,8 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ExecutionModeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard
{
public void EnsureWriteAllowed(string operation) => throw new InvalidOperationException(mode == PRecAnnotExecutionMode.Apply ? "APPLY_MODE_NOT_IMPLEMENTED" : "SMOKE_WRITE_BLOCKED");
}
@@ -0,0 +1,18 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public static class ExtentsIntersectionClassifier
{
// Source-backed port of ExtentsIntersect.Commands without Autodesk types.
public static ExtentsIntersectionType Classify(Bounds3Dto first, Bounds3Dto second)
{
if (Same(first, second)) return ExtentsIntersectionType.ExtentsMatch;
if (Contains(first, second)) return ExtentsIntersectionType.SecondExtentsInsideFirstExtents;
if (Contains(second, first)) return ExtentsIntersectionType.FirstExtentsInsideSecondExtents;
return Overlaps(first, second) ? ExtentsIntersectionType.Intersect : ExtentsIntersectionType.DoNotIntersect;
}
private static bool Contains(Bounds3Dto outer, Bounds3Dto inner) => outer.Minimum.X <= inner.Minimum.X && outer.Minimum.Y <= inner.Minimum.Y && outer.Minimum.Z <= inner.Minimum.Z && outer.Maximum.X >= inner.Maximum.X && outer.Maximum.Y >= inner.Maximum.Y && outer.Maximum.Z >= inner.Maximum.Z;
private static bool Overlaps(Bounds3Dto a, Bounds3Dto b) => a.Minimum.X <= b.Maximum.X && a.Maximum.X >= b.Minimum.X && a.Minimum.Y <= b.Maximum.Y && a.Maximum.Y >= b.Minimum.Y && a.Minimum.Z <= b.Maximum.Z && a.Maximum.Z >= b.Minimum.Z;
private static bool Same(Bounds3Dto a, Bounds3Dto b) => a == b;
}
@@ -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];
}
@@ -0,0 +1,26 @@
using System.Globalization;
namespace PRecAnnot.Core;
public static class LegacyElevationFormatter
{
public static int RoundModelElevation(double modelZ, double datumOffset)
{
// InsertElevation.BlockJig divides the model-space ordinate by five,
// rounds that quotient away from zero and restores the five-unit grid
// before formatting the signed metres/millimetres value.
var grid = Math.Round((modelZ + datumOffset) / 5d, MidpointRounding.AwayFromZero) * 5d;
return checked((int)Math.Round(grid, MidpointRounding.AwayFromZero));
}
public static string FormatMillimetres(int millimetres)
{
if (millimetres == 0) return "0,000";
var magnitude = Math.Abs((long)millimetres).ToString(CultureInfo.InvariantCulture).PadLeft(4, '0');
var metres = magnitude[..^3];
var fraction = magnitude[^3..];
return $"{(millimetres > 0 ? "+" : "-")}{metres},{fraction}";
}
public static string Calculate(double modelZ, double datumOffset) => FormatMillimetres(RoundModelElevation(modelZ, datumOffset));
}
@@ -0,0 +1,48 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Source-backed PXP-ALC decision logic: only a connector whose related parts
// resolve to different line groups produces an annotation. The connector is
// the stable ownership identity, preserving repeat/update behaviour.
public sealed class LineConnectionBoundaryPlanner
{
public IReadOnlyList<LineConnectionBoundaryPlan> Build(IEnumerable<LineConnectionBoundaryCandidate> candidates)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<LineConnectionBoundaryPlan>();
foreach (var candidate in candidates.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!seen.Add(candidate.StableId))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["DUPLICATE_CONNECTOR_CANDIDATE"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
var first = candidate.FirstLineNumber?.Trim() ?? string.Empty;
var second = candidate.SecondLineNumber?.Trim() ?? string.Empty;
// The legacy command gates insertion on the two P3dLineGroup
// relationship IDs, not just on their rendered text. Equal text in
// distinct groups remains a real connector boundary; equal group IDs
// never does.
if (!string.IsNullOrWhiteSpace(candidate.FirstLineGroupStableId) &&
string.Equals(candidate.FirstLineGroupStableId, candidate.SecondLineGroupStableId, StringComparison.OrdinalIgnoreCase))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["SAME_LINE_GROUP"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
if (first.Length == 0 || second.Length == 0)
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["LINE_GROUP_NOT_RESOLVED"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
if (string.Equals(first, second, StringComparison.OrdinalIgnoreCase))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["SAME_LINE_GROUP"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
var ordered = new[] { first, second }.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray();
result.Add(new(candidate.StableId, string.Join(" / ", ordered), candidate.Position, candidate.Rotation, Array.Empty<string>(), first, second, candidate.FirstEndpoint));
}
return result;
}
}
@@ -0,0 +1,72 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Source-backed bridge for AutoLinenumberConnections: each endpoint uses the
// same GetLinenumberAnnotationText formatting contract as PXP-LN, while the
// connector itself remains the stable annotation ownership key.
public sealed class LineConnectionPreviewService(
LineNumberFieldResolver fieldResolver,
ILineNumberAnnotationFormatter formatter,
LineConnectionBoundaryPlanner boundaryPlanner)
{
public LineConnectionPreviewResult Preview(
LineNumberAnnotationDefinition definition,
IEnumerable<PlantConnectorRelationshipSnapshot> connectors,
IReadOnlyDictionary<string, LineConnectionPlacement> placements)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(connectors);
ArgumentNullException.ThrowIfNull(placements);
var candidates = new List<LineConnectionBoundaryCandidate>();
foreach (var connector in connectors.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!placements.TryGetValue(connector.StableId, out var placement))
{
candidates.Add(new(connector.StableId, string.Empty, string.Empty, new(), new(), new(new(), new()), new(), 0,
connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture),
connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
continue;
}
var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field));
var first = ResolveText(definition, connector.FirstPartRowId, fields);
var second = ResolveText(definition, connector.SecondPartRowId, fields);
candidates.Add(new(
connector.StableId,
first.Text,
second.Text,
placement.FirstEndpoint,
placement.SecondEndpoint,
placement.Bounds,
placement.Position,
placement.Rotation,
connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture),
connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
var plans = boundaryPlanner.Build(candidates);
// Per-connector property failures are represented as non-insertable
// plans, matching legacy's continue-on-one-bad-connector behaviour.
var repaired = plans.Select(plan =>
{
var connector = connectors.First(value => string.Equals(value.StableId, plan.StableId, StringComparison.Ordinal));
if (!placements.ContainsKey(plan.StableId)) return plan with { Reasons = plan.Reasons.Append("CONNECTOR_GEOMETRY_UNAVAILABLE").Distinct(StringComparer.Ordinal).ToArray() };
var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field));
var first = ResolveText(definition, connector.FirstPartRowId, fields);
var second = ResolveText(definition, connector.SecondPartRowId, fields);
var reasons = plan.Reasons.Concat(first.Error is null ? [] : [first.Error]).Concat(second.Error is null ? [] : [second.Error]).Distinct(StringComparer.Ordinal).ToArray();
return reasons.Length == plan.Reasons.Count ? plan : plan with { Text = reasons.Length == 0 ? plan.Text : string.Empty, Reasons = reasons };
}).ToArray();
return new(true, repaired);
}
private (string Text, string? Error) ResolveText(LineNumberAnnotationDefinition definition, long rowId, IEnumerable<string> fields)
{
var resolved = fieldResolver.Resolve(rowId, fields);
if (!resolved.Success) return (string.Empty, resolved.FailureCode ?? "DATALINKS_PROPERTY_READ_FAILED");
var formatted = formatter.Format(definition, resolved.Values);
return formatted.Success ? (formatted.Text, null) : (string.Empty, formatted.ErrorCode ?? "LINE_NUMBER_FORMAT_FAILED");
}
}
@@ -0,0 +1,20 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineConnectionSettingsLoader
{
public LineConnectionAssets Load(string xml)
{
try
{
var element = XDocument.Parse(xml).Root?.Element("LinenumberConnections") ?? throw new InvalidOperationException("LINE_CONNECTION_SETTINGS_MISSING");
string Required(string name) => string.IsNullOrWhiteSpace((string?)element.Attribute(name))
? throw new InvalidOperationException("LINE_CONNECTION_SETTING_MISSING:" + name)
: (string)element.Attribute(name)!;
return new(Required("polylinelayer"), Required("textlayer"), Required("textstyle"));
}
catch (System.Xml.XmlException exception) { throw new InvalidOperationException("LINE_CONNECTION_SETTINGS_XML_INVALID", exception); }
}
}
@@ -0,0 +1,78 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Source-backed port of GetLinenumberAnnotationText. Plant/DataLinks access
// stays at the adapter boundary; this service is deterministic and testable.
public sealed class LineNumberAnnotationFormatter : ILineNumberAnnotationFormatter
{
public LineNumberFormattingResult Format(LineNumberAnnotationDefinition definition, IReadOnlyDictionary<string, string?> fields)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(fields);
var text = new System.Text.StringBuilder();
foreach (var tag in definition.Tags)
{
if (!fields.TryGetValue(tag.Field, out var value)) return new(false, string.Empty, "FIELD_NOT_FOUND:" + tag.Field);
value ??= string.Empty;
if (!string.IsNullOrWhiteSpace(tag.ProcessingXml))
{
var processed = Process(value, tag.ProcessingXml, definition.Variables, fields, tag.Field);
if (!processed.Success) return processed;
value = processed.Text;
}
if (string.Equals(value, tag.IgnoreValue, StringComparison.Ordinal)) value = string.Empty;
if (string.IsNullOrEmpty(value) && !tag.SaveWhenEmpty) continue;
if (text.Length > 0) text.Append(tag.Divider == "space" ? " " : tag.Divider);
text.Append(string.IsNullOrEmpty(value) ? definition.Nickname : value);
}
return new(true, text.ToString());
}
private static LineNumberFormattingResult Process(string value, string processingXml, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields, string field)
{
try
{
var root = XElement.Parse(processingXml);
if (root.Name.LocalName != "processing" || !root.Elements("function").Any()) return new(false, string.Empty, "PROCESSING_INVALID");
foreach (var function in root.Elements("function"))
{
var result = Apply(function, value, variables, fields, field);
if (!result.Success) return result;
value = result.Text;
}
return new(true, value);
}
catch (System.Xml.XmlException) { return new(false, string.Empty, "PROCESSING_INVALID"); }
}
private static LineNumberFormattingResult Apply(XElement function, string value, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields, string field)
{
var name = function.Element("name")?.Value;
var mode = function.Element("mod")?.Value;
var aliases = function.Elements("aliases").ToDictionary(x => x.Element("key")?.Value ?? string.Empty, x => x.Element("value")?.Value ?? string.Empty, StringComparer.OrdinalIgnoreCase);
var attrs = function.Elements("attr").Select(a => Resolve(a.Element("value")?.Value, variables, fields)).ToArray();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mode) || attrs.Any(x => x is null)) return new(false, string.Empty, "PROCESSING_INVALID");
var found = false;
switch (name)
{
case "ToUpper": found = aliases.TryGetValue(attrs[0]!.ToUpperInvariant(), out value!); break;
case "NotContains":
if (attrs.Length < 2) return new(false, string.Empty, "PROCESSING_INVALID");
if (attrs[1]!.Contains(attrs[0]!, StringComparison.Ordinal)) { value = string.Empty; found = true; }
else found = aliases.TryGetValue(value, out value!);
break;
case "Empty": if (string.IsNullOrEmpty(attrs[0])) value = string.Empty; found = true; break;
default: return new(false, string.Empty, "PROCESSING_UNKNOWN_FUNCTION:" + name);
}
return mode == "interrupt" && !found ? new(false, string.Empty, "PROCESSING_ALIAS_NOT_FOUND:" + field) : new(true, value);
}
private static string? Resolve(string? value, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields)
{
if (value is null) return null;
if (value.Length >= 2 && value[0] == '%' && value[^1] == '%') return variables.TryGetValue(value[1..^1], out var resolved) ? resolved : null;
return fields.TryGetValue(value, out var field) ? field ?? string.Empty : value;
}
}
@@ -0,0 +1,46 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineNumberAnnotationSettingsLoader
{
public LineNumberAnnotationSettings Load(string xml)
{
ArgumentException.ThrowIfNullOrWhiteSpace(xml);
var root = XElement.Parse(xml);
if (root.Name.LocalName != "root") throw new InvalidDataException("SETTINGS_ROOT_INVALID");
var nullOrEmpty = root.Element("nullOrEmpty")?.Value;
if (nullOrEmpty is null) throw new InvalidDataException("SETTINGS_INVALID:10");
var annotations = root.Elements("annotation").Select(ParseAnnotation).ToArray();
if (annotations.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:11");
return new LineNumberAnnotationSettings { NullOrEmpty = nullOrEmpty, Annotations = annotations };
}
private static LineNumberAnnotationDefinition ParseAnnotation(XElement annotation)
{
var nickname = annotation.Element("nickname")?.Value;
if (string.IsNullOrWhiteSpace(nickname)) throw new InvalidDataException("SETTINGS_INVALID:20");
var tags = annotation.Elements("tag").Select(ParseTag).ToArray();
if (tags.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:21");
var variables = annotation.Elements("set").Select(x => new KeyValuePair<string, string>(x.Element("key")?.Value ?? string.Empty, x.Element("value")?.Value ?? string.Empty)).ToArray();
if (variables.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:22");
if (variables.Any(x => string.IsNullOrWhiteSpace(x.Key))) throw new InvalidDataException("SETTINGS_INVALID:40");
return new LineNumberAnnotationDefinition { Nickname = nickname, Variables = variables.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal), Tags = tags };
}
private static LineNumberAnnotationTag ParseTag(XElement tag)
{
var divider = tag.Element("devider")?.Value;
var saver = tag.Element("saver")?.Value;
var field = tag.Element("field")?.Value;
var ignore = tag.Element("ignore")?.Value;
var processing = tag.Element("processing")?.Value;
if (divider is null) throw new InvalidDataException("SETTINGS_INVALID:30");
if (saver is null) throw new InvalidDataException("SETTINGS_INVALID:31");
if (string.IsNullOrWhiteSpace(field)) throw new InvalidDataException("SETTINGS_INVALID:32");
if (ignore is null) throw new InvalidDataException("SETTINGS_INVALID:33");
if (processing is null) throw new InvalidDataException("SETTINGS_INVALID:34");
return new LineNumberAnnotationTag { Field = field, Divider = divider, SaveWhenEmpty = string.IsNullOrEmpty(saver), IgnoreValue = ignore, ProcessingXml = processing };
}
}
@@ -0,0 +1,25 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineNumberCommandPlanner(ILineNumberAnnotationFormatter formatter)
{
public LineNumberCommandPlan Build(LineNumberAnnotationDefinition definition, IEnumerable<LineNumberCandidate> candidates, IEnumerable<ExistingLineNumberAnnotation> existing)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var indexed = existing.GroupBy(x => x.SourceStableId, StringComparer.OrdinalIgnoreCase).ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase);
var result = new List<LineNumberCommandOperation>();
foreach (var candidate in candidates.OrderBy(x => x.StableId, StringComparer.Ordinal))
{
if (!seen.Add(candidate.StableId)) { result.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_CANDIDATE"])); continue; }
var text = formatter.Format(definition, candidate.Properties);
if (!text.Success) { result.Add(new(candidate.StableId, AnnotationOperationKind.Skip, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, [text.ErrorCode ?? "FORMAT_FAILED"])); continue; }
result.Add(new(candidate.StableId, indexed.ContainsKey(candidate.StableId) ? AnnotationOperationKind.Update : AnnotationOperationKind.Create, text.Text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, Array.Empty<string>()));
}
return new(result);
}
public static IReadOnlyList<LineNumberApplyPreflight> PrepareApply(LineNumberCommandPlan plan) => plan.Operations
.Where(x => x.Kind is AnnotationOperationKind.Create or AnnotationOperationKind.Update)
.OrderBy(x => x.StableId, StringComparer.Ordinal)
.Select((x, index) => new LineNumberApplyPreflight(x.StableId, index + 1, x.Text!, x.Position, x.Rotation, x.Layer, x.TextStyle)).ToArray();
}
@@ -0,0 +1,20 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineNumberCommandPreviewService(LineNumberCommandPlanner planner, LineNumberFieldResolver fieldResolver)
{
public LineNumberPreviewResult Preview(LineNumberAnnotationDefinition definition, IEnumerable<LineNumberCandidate> candidates, IEnumerable<ExistingLineNumberAnnotation> existing)
{
var resolved = new List<LineNumberCandidate>();
foreach (var candidate in candidates.OrderBy(item => item.StableId, StringComparer.Ordinal))
{
var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field));
var properties = fieldResolver.Resolve(candidate.RowId, fields);
if (!properties.Success) return new(false, null, Array.Empty<LineNumberApplyPreflight>(), properties.FailureCode);
resolved.Add(candidate with { Properties = properties.Values });
}
var plan = planner.Build(definition, resolved, existing);
return new(true, plan, LineNumberCommandPlanner.PrepareApply(plan));
}
}
@@ -0,0 +1,42 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineNumberConnectionPlanner
{
public IReadOnlyList<LineConnectionGroup> Build(IEnumerable<LineConnectionCandidate> candidates, double tolerance)
{
if (tolerance < 0 || double.IsNaN(tolerance) || double.IsInfinity(tolerance)) throw new ArgumentOutOfRangeException(nameof(tolerance));
return candidates
.Where(value => !string.IsNullOrWhiteSpace(value.LineNumber))
.OrderBy(value => value.StableId, StringComparer.Ordinal)
.GroupBy(value => value.LineNumber, StringComparer.OrdinalIgnoreCase)
.Select(group => BuildGroup(group.Key, group.ToArray(), tolerance))
.Where(group => group is not null)
.Cast<LineConnectionGroup>()
.OrderBy(group => group.LineNumber, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static LineConnectionGroup? BuildGroup(string number, LineConnectionCandidate[] members, double tolerance)
{
if (members.Length < 2) return null;
var connected = members.Where(first => members.Any(second => !ReferenceEquals(first, second) && Connected(first, second, tolerance))).OrderBy(value => value.StableId, StringComparer.Ordinal).ToArray();
if (connected.Length < 2) return null;
var anchor = connected[0];
return new(number, connected, anchor.Position, anchor.Rotation);
}
private static bool Connected(LineConnectionCandidate first, LineConnectionCandidate second, double tolerance)
{
if (Distance(first.FirstEndpoint, second.FirstEndpoint) <= tolerance || Distance(first.FirstEndpoint, second.SecondEndpoint) <= tolerance || Distance(first.SecondEndpoint, second.FirstEndpoint) <= tolerance || Distance(first.SecondEndpoint, second.SecondEndpoint) <= tolerance)
return true;
return ExtentsIntersectionClassifier.Classify(first.Bounds, second.Bounds) is not ExtentsIntersectionType.DoNotIntersect;
}
private static double Distance(Point3Dto first, Point3Dto second)
{
var x = first.X - second.X; var y = first.Y - second.Y; var z = first.Z - second.Z;
return Math.Sqrt(x * x + y * y + z * z);
}
}
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
/// <summary>
/// Produces the canonical, fixture-bound payload for the legacy line-number
/// editing commands. The command-specific Apply permit signs this value so a
/// controlled session cannot turn an arbitrary selected block or prompted
/// value into a write merely by switching runtime mode to Apply.
/// </summary>
public static class LineNumberEditApplyPlan
{
public static string ComputeMutationHash(string commandName, string selectedObjectHandle, IReadOnlyList<LineNumberRowMutation> mutations)
{
if (string.IsNullOrWhiteSpace(commandName)) throw new ArgumentException("LINE_NUMBER_EDIT_COMMAND_REQUIRED", nameof(commandName));
if (string.IsNullOrWhiteSpace(selectedObjectHandle)) throw new ArgumentException("LINE_NUMBER_EDIT_SELECTION_REQUIRED", nameof(selectedObjectHandle));
ArgumentNullException.ThrowIfNull(mutations);
if (mutations.Count == 0) throw new ArgumentException("LINE_NUMBER_EDIT_MUTATIONS_REQUIRED", nameof(mutations));
var canonical = new StringBuilder()
.Append(commandName).Append('|')
.Append(selectedObjectHandle).Append('\n');
foreach (var mutation in mutations.OrderBy(value => value.SourceStableId, StringComparer.Ordinal)
.ThenBy(value => value.PreviousMarkerNumber)
.ThenBy(value => value.MarkerNumber))
{
canonical.Append(mutation.SourceStableId).Append('|')
.Append(mutation.PreviousMarkerNumber).Append('|')
.Append(mutation.MarkerNumber).Append('|')
.Append(mutation.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal)).Append('|');
if (mutation.MarkerAttributePosition is { } position)
canonical.Append(position.X.ToString("R", System.Globalization.CultureInfo.InvariantCulture)).Append(',')
.Append(position.Y.ToString("R", System.Globalization.CultureInfo.InvariantCulture)).Append(',')
.Append(position.Z.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
canonical.Append('\n');
}
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())));
}
}
@@ -0,0 +1,24 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public static class LineNumberEditPlanner
{
public static IReadOnlyList<LineNumberRowMutation> Renumber(IEnumerable<LineNumberOwnedRow> rows, int newStart)
{
if (newStart <= 0) throw new ArgumentOutOfRangeException(nameof(newStart), "LINE_NUMBER_START_MUST_BE_POSITIVE");
var ordered = rows.OrderBy(value => value.MarkerNumber).ThenBy(value => value.SourceStableId, StringComparer.Ordinal).ToArray();
if (ordered.Length == 0) throw new ArgumentException("LINE_NUMBER_LIST_EMPTY", nameof(rows));
if (ordered.Select(value => value.MarkerNumber).Distinct().Count() != ordered.Length) throw new InvalidOperationException("LINE_NUMBER_LIST_DUPLICATE_MARKER_NUMBER");
return ordered.Select((value, index) => new LineNumberRowMutation(value.SourceStableId, value.MarkerNumber, checked(newStart + index), value.Text, null)).ToArray();
}
public static LineNumberRowMutation EditText(LineNumberOwnedRow row, string text)
{
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("LINE_NUMBER_TEXT_EMPTY", nameof(text));
return new(row.SourceStableId, row.MarkerNumber, row.MarkerNumber, text, null);
}
public static LineNumberRowMutation ResetMarkerAttributePosition(LineNumberOwnedRow row, Point2Dto configuredPosition) =>
new(row.SourceStableId, row.MarkerNumber, row.MarkerNumber, row.Text, new(configuredPosition.X, configuredPosition.Y));
}
@@ -0,0 +1,23 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineNumberFieldResolver(IDataLinksPropertyReader reader)
{
// Source-backed Convert_field_to_value port. The adapter supplies a
// deterministic, read-only flattened key set: Table.Column and Column.
public LineNumberFieldResolution Resolve(long rowId, IEnumerable<string> fields)
{
var read = reader.ReadProperties(rowId);
if (!read.Success) return new(false, new Dictionary<string, string?>(), read.FailureCode ?? "DATALINKS_READ_FAILED");
var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var field in fields.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(field)) return new(false, values, "LINE_NUMBER_FIELD_EMPTY");
var dot = field.IndexOf('.', StringComparison.Ordinal);
if (dot <= 0 || dot == field.Length - 1) return new(false, values, "LINE_NUMBER_FIELD_INVALID:" + field);
if (!read.Properties.TryGetValue(field, out var value) && !read.Properties.TryGetValue(field[(dot + 1)..], out value)) return new(false, values, "LINE_NUMBER_COLUMN_MISSING:" + field);
values[field] = value ?? string.Empty;
}
return new(true, values);
}
}
@@ -0,0 +1,19 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public static class LineNumberListPlanner
{
public static LineNumberListApplyPreflight Build(AnnotationPoint position, double rotation, IEnumerable<LineNumberApplyPreflight> rows)
{
var ordered = rows.OrderBy(row => row.MarkerNumber).ToArray();
if (ordered.Length == 0) throw new ArgumentException("LINE_NUMBER_LIST_EMPTY", nameof(rows));
if (ordered.Any(row => string.IsNullOrWhiteSpace(row.StableId) || string.IsNullOrWhiteSpace(row.Text))) throw new ArgumentException("LINE_NUMBER_LIST_ROW_INVALID", nameof(rows));
if (ordered.Select(row => row.MarkerNumber).Distinct().Count() != ordered.Length) throw new ArgumentException("LINE_NUMBER_LIST_DUPLICATE_MARKER_NUMBER", nameof(rows));
var key = string.Join("|", ordered.Select(row => $"{row.MarkerNumber}:{row.StableId}"));
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))).ToLowerInvariant();
return new($"ln-list:{digest}", position, rotation, ordered);
}
}
@@ -0,0 +1,27 @@
namespace PRecAnnot.Core;
public enum ManualAnnotationKind
{
LineNumber,
ValveOrNozzle,
FlowDirection,
InstrumentPosition,
SectionElevation,
InstrumentConnection,
AutoCadBlock,
PlatformElevation,
OtherElevation
}
public static class ManualAnnotationCommandRouter
{
public static string Resolve(ManualAnnotationKind kind) => kind switch
{
ManualAnnotationKind.LineNumber => "PXP-LN",
ManualAnnotationKind.FlowDirection => "PXP-OA",
ManualAnnotationKind.SectionElevation or ManualAnnotationKind.PlatformElevation or ManualAnnotationKind.OtherElevation => "PXP-IE",
ManualAnnotationKind.AutoCadBlock => "PXP-MAB",
ManualAnnotationKind.ValveOrNozzle or ManualAnnotationKind.InstrumentPosition or ManualAnnotationKind.InstrumentConnection => "PXP-AAV",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown manual annotation kind.")
};
}
@@ -0,0 +1,15 @@
namespace PRecAnnot.Core;
public sealed record ManualAnnotationState(string Kind, ManualDatumMode DatumMode, double DatumOffset)
{
public string Serialize() => string.Join("|", Kind, DatumMode, DatumOffset.ToString(System.Globalization.CultureInfo.InvariantCulture));
public static ManualAnnotationState? Deserialize(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var fields = value.Split('|');
if (fields.Length != 3 || !Enum.TryParse<ManualDatumMode>(fields[1], true, out var mode) ||
!double.TryParse(fields[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var offset)) return null;
return new ManualAnnotationState(fields[0], mode, offset);
}
}
@@ -0,0 +1,9 @@
namespace PRecAnnot.Core;
public enum ManualDatumMode { Absolute, Relative }
public static class ManualDatumResolver
{
public static double Resolve(double sourceElevation, ManualDatumMode mode, double offset) =>
mode == ManualDatumMode.Absolute ? sourceElevation : sourceElevation + offset;
}
@@ -0,0 +1,44 @@
using System.Text.RegularExpressions;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoAnnotationClassifier
{
public OrthoAnnotationClassification Classify(PlantObjectSnapshot source, OrthoAnnotationOptions options)
{
string Property(params string[] names) => source.Properties.FirstOrDefault(x => names.Any(n => string.Equals(n, x.Key, StringComparison.OrdinalIgnoreCase))).Value?.Trim() ?? string.Empty;
var className = source.ObjectClass.Trim();
if (className.Equals("Support", StringComparison.OrdinalIgnoreCase))
{
var title = Property("Title"); var text = Normalize(Property("Annotation"));
return options.Supports && title.StartsWith("НЕСТАНДАРТНЫЕ", StringComparison.OrdinalIgnoreCase) && text.Length > 0
? Ready(source, OrthoAnnotationRepresentation.MultiLeader, text)
: Skip(source, "SUPPORT_NOT_NKR_OR_DISABLED");
}
if (className.Equals("Valve", StringComparison.OrdinalIgnoreCase) || className.Equals("InlineInstrument", StringComparison.OrdinalIgnoreCase))
{
var value = Normalize(className.Equals("Valve", StringComparison.OrdinalIgnoreCase) ? Property("Annotation") : Property("Tag"));
if (value.Contains('$')) return options.Instruments ? Ready(source, OrthoAnnotationRepresentation.BubbleBlock, value.Replace("$", "\\P", StringComparison.Ordinal)) : Skip(source, "INSTRUMENT_ANNOTATION_DISABLED");
return HasAlphaNumeric(value) && options.Valves ? Ready(source, OrthoAnnotationRepresentation.MultiLeader, value) : Skip(source, "VALVE_ANNOTATION_EMPTY_OR_DISABLED");
}
if (className.Equals("Plug", StringComparison.OrdinalIgnoreCase))
{
var value = Normalize(Property("TieInNumber"));
if (!options.Plugs || !HasAlphaNumeric(value)) return Skip(source, "PLUG_ANNOTATION_EMPTY_OR_DISABLED");
var representation = value.Contains('$') ? OrthoAnnotationRepresentation.BubbleBlock : OrthoAnnotationRepresentation.MultiLeader;
return Ready(source, representation, "Для подключения\\Pпоз. " + value.Replace("$", "\\P", StringComparison.Ordinal));
}
if (className.Equals("Board", StringComparison.OrdinalIgnoreCase))
{
var value = Normalize(Property("Tag", "Annotation"));
return options.Boards && value.Length > 0 ? Ready(source, value.Contains('$') ? OrthoAnnotationRepresentation.BubbleBlock : OrthoAnnotationRepresentation.MultiLeader, "Шкаф КИП для прибора\\Pпоз. " + value.Replace("$", "\\P", StringComparison.Ordinal)) : Skip(source, "BOARD_ANNOTATION_EMPTY_OR_DISABLED");
}
return Skip(source, "ORTHO_ANNOTATION_CLASS_UNSUPPORTED");
}
private static bool HasAlphaNumeric(string value) => Regex.IsMatch(value, "[a-zA-Z0-9]");
private static string Normalize(string value) => value.Replace("{nl}", "\\P", StringComparison.Ordinal);
private static OrthoAnnotationClassification Ready(PlantObjectSnapshot source, OrthoAnnotationRepresentation representation, string text) => new(source.SnapshotId, representation, text, Array.Empty<string>());
private static OrthoAnnotationClassification Skip(PlantObjectSnapshot source, string reason) => new(source.SnapshotId, OrthoAnnotationRepresentation.None, string.Empty, [reason]);
}
@@ -0,0 +1,15 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoAnnotationSettingsLoader
{
public OrthoAnnotationAssets Load(string xml)
{
var value = XElement.Parse(xml).Element("Annotations") ?? throw Invalid("Annotations");
return new(Required(value, "layer"), Required(value, "textstyle"), Required(value, "multileaderstyle"));
}
private static string Required(XElement value, string name) => value.Attribute(name) is { Value: var result } && !string.IsNullOrWhiteSpace(result) ? result : throw Invalid(name);
private static InvalidDataException Invalid(string value) => new("ORTHO_ANNOTATION_SETTINGS_INVALID:" + value);
}
@@ -0,0 +1,34 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// v1.0.4.2 identity rule: a selected drawing is matched by its normalized
// project-relative path, never by the display/file name alone.
public sealed class OrthoBatchPlanner
{
public OrthoBatchPlan Build(IEnumerable<OrthoBatchDrawing> drawings, IEnumerable<OrthoBatchSelection> selections)
{
var diagnostics = new List<string>(); var operations = new List<OrthoBatchOperation>();
var source = drawings.OrderBy(x => Normalize(x.RelativePath), StringComparer.OrdinalIgnoreCase).ThenBy(x => x.DrawingGuid, StringComparer.Ordinal).ToArray();
var duplicateIds = source.GroupBy(x => Normalize(x.RelativePath), StringComparer.OrdinalIgnoreCase).Where(x => x.Count() > 1).Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var selection in selections.OrderBy(x => Normalize(x.RelativePath), StringComparer.OrdinalIgnoreCase))
{
var relative = Normalize(selection.RelativePath);
if (duplicateIds.Contains(relative)) { diagnostics.Add("ORTHO_DRAWING_RELATIVE_PATH_AMBIGUOUS:" + relative); continue; }
var drawing = source.SingleOrDefault(x => string.Equals(Normalize(x.RelativePath), relative, StringComparison.OrdinalIgnoreCase));
if (drawing is null) { diagnostics.Add("ORTHO_DRAWING_NOT_FOUND:" + relative); continue; }
foreach (var view in selection.Views.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
if (drawing.Views.Contains(view, StringComparer.OrdinalIgnoreCase)) operations.Add(new(drawing.DrawingGuid, drawing.DisplayName, relative, view));
else diagnostics.Add($"ORTHO_VIEW_NOT_FOUND:{relative}:{view}");
}
return new(operations.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.ViewName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.DrawingGuid, StringComparer.Ordinal).ToArray(), diagnostics.OrderBy(x => x, StringComparer.Ordinal).ToArray());
}
public static string Normalize(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) throw new ArgumentException("ORTHO_RELATIVE_PATH_REQUIRED", nameof(relativePath));
var parts = relativePath.Replace('/', '\\').Split('\\', StringSplitOptions.RemoveEmptyEntries);
if (parts.Any(x => x == "..")) throw new ArgumentException("ORTHO_RELATIVE_PATH_ESCAPES_PROJECT", nameof(relativePath));
return string.Join('\\', parts.Where(x => x != "."));
}
}
@@ -0,0 +1,23 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public static class OrthoDetectionClassifier
{
public static OrthoDetectionResult Classify(bool hasPlantProject, bool hasOrthoPart, bool apiConfirmed, bool metadataAmbiguous = false)
{
if (!hasPlantProject) return Result(OrthoDetectionStatus.NotOrtho, "NO_PLANT_PROJECT", "Active Plant project is absent.");
if (metadataAmbiguous) return Result(OrthoDetectionStatus.Ambiguous, "AMBIGUOUS_ORTHO_METADATA", "Plant metadata is insufficient to confirm Ortho.");
if (!hasOrthoPart) return Result(OrthoDetectionStatus.Unsupported, "ORTHO_PART_UNAVAILABLE", "Ortho project part is unavailable.");
return apiConfirmed
? Result(OrthoDetectionStatus.Confirmed, "ORTHO_API_CONFIRMED", "OrthoDocUtils confirmed the active drawing.")
: Result(OrthoDetectionStatus.NotOrtho, "ORTHO_API_NEGATIVE", "OrthoDocUtils did not confirm the active drawing.");
}
private static OrthoDetectionResult Result(OrthoDetectionStatus status, string code, string message) => new()
{
Status = status,
Evidence = new[] { code },
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } }
};
}
@@ -0,0 +1,25 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoLayerSettingsLoader
{
public IReadOnlyList<OrthoLayerRule> Load(string xml)
{
var root = XElement.Parse(xml).Element("LayersSetup") ?? throw Invalid("LayersSetup");
var result = root.Elements("LayerSetup").Select(Parse).ToArray();
return result.Length > 0 ? result : throw Invalid("LayerSetup");
}
private static OrthoLayerRule Parse(XElement value)
{
var kind = Required(value, "filtertype").ToUpperInvariant() switch { "STATUS" => OrthoLayerFilterKind.Status, "LAYER" => OrthoLayerFilterKind.Layer, _ => OrthoLayerFilterKind.Table };
var filter = Required(value, "filtervalue", true); if (filter.Length == 0) filter = "(Undefined)";
int? weight = int.TryParse(Required(value, "lineweight", true), out var parsedWeight) ? parsedWeight : null;
var color = Required(value, "color", true); short? index = short.TryParse(color, out var parsedIndex) ? parsedIndex : null; byte? red = null, green = null, blue = null;
var rgb = color.Split(','); if (rgb.Length == 3 && byte.TryParse(rgb[0], out var r) && byte.TryParse(rgb[1], out var g) && byte.TryParse(rgb[2], out var b)) { red = r; green = g; blue = b; index = null; }
return new(kind, filter, weight, Required(value, "linetype", true), index, red, green, blue);
}
private static string Required(XElement value, string name, bool empty = false) => value.Attribute(name) is { Value: var result } && (empty || !string.IsNullOrWhiteSpace(result)) ? result : throw Invalid(name);
private static InvalidDataException Invalid(string field) => new("ORTHO_LAYER_SETTINGS_INVALID:" + field);
}
@@ -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];
}
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,129 @@
using System.Globalization;
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
/// <summary>Typed projection of the release Settings.xml sections consumed by production workflows.</summary>
public sealed record ProductionSettingsContract(
IReadOnlyList<NamedDrawingObject> CopyMissingObjects,
IReadOnlyList<LayerSetupRule> LayerSetup,
string AnnotationLayer,
string AnnotationTextStyle,
string AnnotationLeaderStyle,
string ConnectionPolylineLayer,
string ConnectionTextLayer,
string ConnectionTextStyle,
string ElevationLayer,
string ElevationTextStyle,
string SetLayer,
double? FixedSupportPlusLength,
string? ScriptFolder,
LineNumberMarkerSettings? LineNumberMarker,
SupportTopViewSettings? SupportTopView)
{
public LineNumberMarkerAsset? ToLineNumberMarkerAsset()
{
if (LineNumberMarker is null) return null;
var texts = LineNumberMarker.Text is { } text
? new[] { new LineNumberTextAsset(text.Layer ?? string.Empty, text.TextStyle ?? string.Empty, text.Size ?? 0, text.TextValue ?? string.Empty, new(text.PositionX ?? 0, text.PositionY ?? 0)) }
: Array.Empty<LineNumberTextAsset>();
var lines = LineNumberMarker.Line is { } line
? new[] { new LineNumberLineAsset(line.Layer ?? string.Empty, new(line.StartX ?? 0, line.StartY ?? 0), new(line.EndX ?? 0, line.EndY ?? 0)) }
: Array.Empty<LineNumberLineAsset>();
return new(LineNumberMarker.Layer, LineNumberMarker.MaskScaleFactor ?? 0, texts, lines);
}
public static ProductionSettingsContract Parse(string xml)
{
var root = XElement.Parse(xml, LoadOptions.PreserveWhitespace);
var copy = root.Element("CopyMissingObjects")?.Elements("Object")
.Select(value => new NamedDrawingObject(Required(value, "type"), Required(value, "name")))
.ToArray() ?? Array.Empty<NamedDrawingObject>();
var layers = root.Element("LayersSetup")?.Elements("LayerSetup")
.Select(value => new LayerSetupRule(
Required(value, "filtertype"), Required(value, "filtervalue", allowEmpty: true),
ParseInt(value, "lineweight"), Required(value, "linetype", allowEmpty: true),
Required(value, "color", allowEmpty: true)))
.ToArray() ?? Array.Empty<LayerSetupRule>();
var annotations = root.Element("Annotations");
var connections = root.Element("LinenumberConnections");
var elevations = root.Element("Elevations");
var setLayer = root.Element("SetLayer")?.Attribute("name")?.Value;
if (string.IsNullOrWhiteSpace(setLayer)) throw Invalid("SetLayer.name");
var plusLength = root.Element("PlusLengthNO")?.Attribute("value")?.Value;
double? parsedPlusLength = TryParseLegacyNumber(plusLength, out var plus) ? plus : null;
var marker = root.Element("LineNumbers")?.Element("Marker");
LineNumberMarkerSettings? parsedMarker = marker is null ? null : new(
Required(marker, "layer"),
TryParseLegacyNumber(marker.Attribute("maskscalefactor")?.Value, out var mask) ? mask : null,
ParseLine(marker.Element("Line")),
ParseText(marker.Element("Text")));
var supports = root.Element("SupportsInTopView");
SupportTopViewSettings? parsedSupports = supports is null ? null : new(
supports.Attribute("undefinedsize")?.Value,
supports.Elements("Support").Select(value => new SupportRule(
Required(value, "scriptname"),
TryParseLegacyNumber(value.Attribute("nominaldiameter")?.Value, out var diameter) ? diameter : null,
value.Attribute("nominalumit")?.Value,
int.TryParse(value.Attribute("lastdigit")?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lastDigit) ? lastDigit : null,
TryParseLegacyNumber(value.Attribute("scale")?.Value, out var scale) ? scale : null,
value.Attribute("selectionvalue")?.Value)).ToArray());
return new(
copy,
layers,
Required(annotations, "layer"),
Required(annotations, "textstyle"),
Required(annotations, "multileaderstyle"),
Required(connections, "polylinelayer"),
Required(connections, "textlayer"),
Required(connections, "textstyle"),
Required(elevations, "layer"),
Required(elevations, "textstyle"),
setLayer,
parsedPlusLength,
root.Element("ScriptFolder")?.Attribute("path")?.Value,
parsedMarker,
parsedSupports);
}
private static LineGeometrySettings? ParseLine(XElement? value) => value is null ? null : new(
TryParseLegacyNumber(value.Attribute("startx")?.Value, out var startX) ? startX : null,
TryParseLegacyNumber(value.Attribute("starty")?.Value, out var startY) ? startY : null,
TryParseLegacyNumber(value.Attribute("endx")?.Value, out var endX) ? endX : null,
TryParseLegacyNumber(value.Attribute("endy")?.Value, out var endY) ? endY : null,
value.Attribute("layer")?.Value);
private static TextAnnotationSettings? ParseText(XElement? value) => value is null ? null : new(
value.Attribute("layer")?.Value,
value.Attribute("textstyle")?.Value,
TryParseLegacyNumber(value.Attribute("size")?.Value, out var size) ? size : null,
value.Attribute("textvalue")?.Value,
TryParseLegacyNumber(value.Attribute("positionx")?.Value, out var positionX) ? positionX : null,
TryParseLegacyNumber(value.Attribute("positiony")?.Value, out var positionY) ? positionY : null);
private static int? ParseInt(XElement value, string name) => int.TryParse(value.Attribute(name)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
private static bool TryParseLegacyNumber(string? value, out double parsed)
{
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed)) return true;
return double.TryParse(value?.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out parsed);
}
private static string Required(XElement? value, string name, bool allowEmpty = false) => value?.Attribute(name) is { Value: var result } && (allowEmpty || !string.IsNullOrWhiteSpace(result)) ? result : throw Invalid(name);
private static InvalidDataException Invalid(string field) => new("PRODUCTION_SETTINGS_INVALID:" + field);
}
public sealed record NamedDrawingObject(string Type, string Name);
public sealed record LayerSetupRule(string FilterType, string FilterValue, int? LineWeight, string LineType, string Color);
public sealed record LineNumberMarkerSettings(string Layer, double? MaskScaleFactor, LineGeometrySettings? Line, TextAnnotationSettings? Text);
public sealed record LineGeometrySettings(double? StartX, double? StartY, double? EndX, double? EndY, string? Layer);
public sealed record TextAnnotationSettings(string? Layer, string? TextStyle, double? Size, string? TextValue, double? PositionX, double? PositionY);
public sealed record SupportTopViewSettings(string? UndefinedSize, IReadOnlyList<SupportRule> Rules)
{
public SupportRule? Resolve(double nominalDiameter, string nominalUnit, string? selectionValue = null) => Rules
.Where(rule => rule.NominalDiameter is not null && Math.Abs(rule.NominalDiameter.Value - nominalDiameter) < 0.0001)
.Where(rule => string.Equals(rule.NominalUnit, nominalUnit, StringComparison.OrdinalIgnoreCase))
.Where(rule => selectionValue is null || string.Equals(rule.SelectionValue, selectionValue, StringComparison.Ordinal))
.OrderBy(rule => rule.LastDigit ?? int.MaxValue)
.FirstOrDefault();
}
public sealed record SupportRule(string ScriptName, double? NominalDiameter, string? NominalUnit, int? LastDigit, double? Scale, string? SelectionValue);
@@ -0,0 +1,13 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ViewportLayerPlanner
{
public ViewportLayerPlan Build(string? currentLayer, bool targetLayerExists, string targetLayer = "Defpoints")
{
if (string.IsNullOrWhiteSpace(targetLayer)) return new(false, string.Empty, "VIEWPORT_TARGET_LAYER_INVALID");
if (!targetLayerExists) return new(false, targetLayer, "VIEWPORT_TARGET_LAYER_MISSING");
return new(!string.Equals(currentLayer, targetLayer, StringComparison.OrdinalIgnoreCase), targetLayer);
}
}
@@ -0,0 +1,68 @@
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Core.Application;
namespace PRecAnnot.Plant3D2026;
internal static class AutoAnnotateViewCommand
{
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var options = new PromptEntityOptions("\nВыберите видовой экран для автоматической аннотации: ");
options.SetRejectMessage("\nДолжен быть видовой экран."); options.AddAllowedClass(typeof(Viewport), true);
var selected = document.Editor.GetEntity(options); if (selected.Status != PromptStatus.OK) return;
var context = new PlantRuntimeContextReader().ReadCurrentContext(document);
if (!context.HasPlantProject || !context.IsOrthoDrawing) throw new InvalidOperationException("AUTO_ANNOTATE_VIEW_ORTHO_CONTEXT_REQUIRED");
OrthoViewportInfo viewport;
using (var transaction = document.Database.TransactionManager.StartOpenCloseTransaction())
{
var entity = (Viewport)transaction.GetObject(selected.ObjectId, OpenMode.ForRead);
var matches = context.Viewports.Where(x => Math.Abs(x.CenterX - entity.CenterPoint.X) < .001 && Math.Abs(x.CenterY - entity.CenterPoint.Y) < .001 && Math.Abs(x.Width - entity.Width) < .001 && Math.Abs(x.Height - entity.Height) < .001).ToArray();
viewport = matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "AUTO_ANNOTATE_VIEW_NOT_FOUND" : "AUTO_ANNOTATE_VIEW_AMBIGUOUS");
}
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
var classifications = objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal)
.Select(x => new OrthoAnnotationClassifier().Classify(x, new(true, true, true, true, true))).ToArray();
var permit = string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase)
? FirstLimitedApplyPermitReader.ReadFor(document) : null;
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
{
CommandName = "PXP-AAV", Viewport = viewport, Objects = objects,
LimitedApplyTargetSnapshotId = permit?.TargetSnapshotId,
LimitedApplyRuleId = permit is null ? null : FirstLimitedApplyPlanner.RuleId
});
var leaders = classifications.Count(x => x.Representation == OrthoAnnotationRepresentation.MultiLeader);
var bubbles = classifications.Count(x => x.Representation == OrthoAnnotationRepresentation.BubbleBlock);
document.Editor.WriteMessage($"\nPXP-AAV preview: examined={plan.Summary.ObjectsExamined}; create={plan.Summary.CreateCount}; update={plan.Summary.UpdateCount}; leaders={leaders}; bubbles={bubbles}; skip={plan.Summary.SkipCount}; conflicts={plan.Summary.ConflictCount}; PlanId={plan.PlanId}. DWG не изменён.");
RuntimeDiagnostics.Write("AutoAnnotateViewPreviewCompleted", commandName: "PXP-AAV", resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["PlanId"] = plan.PlanId, ["ObjectsExamined"] = plan.Summary.ObjectsExamined, ["MultiLeaderCandidates"] = leaders, ["BubbleCandidates"] = bubbles });
ScriptDrivenFirstApplyAutomation.RecordPxpAav(plan.PlanId, objects.Select(value => value.SnapshotId).OrderBy(value => value, StringComparer.Ordinal).ToArray(), viewport.Id, plan.Summary.CreateCount, plan.Summary.SkipCount, permit is not null, plan.Summary.ObjectsExamined, objects.Count(value => value.MembershipStatus == ViewMembershipStatus.Resolved));
if (permit is null) return;
if (!string.Equals(permit.ExpectedPlanId, plan.PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("APPLY_PLAN_ID_MISMATCH");
var selectedSource = objects.SingleOrDefault(x => string.Equals(x.SnapshotId, permit.TargetSnapshotId, StringComparison.Ordinal)) ?? throw new InvalidOperationException("APPLY_TARGET_NOT_FOUND");
var selectedClassification = classifications.Single(x => string.Equals(x.StableId, permit.TargetSnapshotId, StringComparison.Ordinal));
if (selectedClassification.Representation != OrthoAnnotationRepresentation.None)
{
var settingsPath = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_NUMBER_DRAWING_SETTINGS_PATH");
if (string.IsNullOrWhiteSpace(settingsPath) || !File.Exists(settingsPath)) throw new InvalidOperationException("ORTHO_ANNOTATION_CONFIGURATION_REQUIRED");
var assets = new OrthoAnnotationSettingsLoader().Load(File.ReadAllText(settingsPath));
var result = new OrthoAnnotationWriter().Replace(document, viewport, selectedSource, selectedClassification, assets);
document.Editor.WriteMessage("\nPXP-AAV Apply: " + result + "; AutoCAD UNDO is available.");
RuntimeDiagnostics.Write("AutoAnnotateViewApplyCompleted", commandName: "PXP-AAV", resultStatus: result, document: document, details: new Dictionary<string, object?> { ["PlanId"] = plan.PlanId, ["Representation"] = selectedClassification.Representation.ToString() });
return;
}
var corePermit = new FirstLimitedApplyPermit { FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, AllowApply = permit.AllowApply, TargetSnapshotId = permit.TargetSnapshotId, ExpectedPlanId = permit.ExpectedPlanId };
var preflight = new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, plan, viewport, objects, corePermit);
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document, preflight.Operation, permit.VisibilityState, permit.AnnotationTemplateDwgPath);
document.Editor.WriteMessage("\nPXP-AAV Apply: " + outcome + "; AutoCAD UNDO is available.");
RuntimeDiagnostics.Write("AutoAnnotateViewApplyCompleted", commandName: "PXP-AAV", resultStatus: outcome.ToString(), document: document, details: new Dictionary<string, object?> { ["PlanId"] = plan.PlanId });
ScriptDrivenFirstApplyAutomation.RecordApplyOutcome(outcome);
}
catch (Exception exception) { RuntimeDiagnostics.Write("AutoAnnotateViewFailed", "ERROR", commandName: "PXP-AAV", resultStatus: exception.Message, exception: exception, document: document); document.Editor.WriteMessage("\nPXP-AAV: " + exception.Message); }
}
}
@@ -0,0 +1,3 @@
using Autodesk.AutoCAD.Runtime;
[assembly: CommandClass(typeof(PRecAnnot.Plant3D2026.EntryPoint))]
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace PRecAnnot.Plant3D2026;
internal sealed record CompositeAssemblyManifest(string Name, string Sha256, bool Required);
internal sealed record CompositeManifest(int SchemaVersion, string Package, string Version, IReadOnlyList<string> Modules, IReadOnlyList<CompositeAssemblyManifest> Assemblies);
internal static class CompositeModuleManifest
{
public static CompositeManifest Validate()
{
var assemblyPath = typeof(EntryPoint).Assembly.Location;
var path = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(assemblyPath)!, "..", "module-manifest.json"));
if (!File.Exists(path)) throw new InvalidOperationException("COMPOSITE_MODULE_MANIFEST_MISSING");
var manifest = JsonSerializer.Deserialize<CompositeManifest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("COMPOSITE_MODULE_MANIFEST_INVALID");
// Schema 2 adds the package-wide file manifest while preserving the
// required composite module/assembly contract consumed here.
if (manifest.SchemaVersion is not (1 or 2) || !string.Equals(manifest.Package, "P_Rec_Annot", StringComparison.Ordinal) || manifest.Modules.Count == 0) throw new InvalidOperationException("COMPOSITE_MODULE_MANIFEST_INVALID");
foreach (var item in manifest.Assemblies.Where(value => value.Required))
{
var file = Path.Combine(Path.GetDirectoryName(assemblyPath)!, item.Name);
if (!File.Exists(file)) throw new InvalidOperationException($"COMPOSITE_MODULE_ASSEMBLY_MISSING:{item.Name}");
var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))).ToLowerInvariant();
if (!string.Equals(hash, item.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"COMPOSITE_MODULE_HASH_MISMATCH:{item.Name}");
}
return manifest;
}
}
@@ -0,0 +1,104 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed class ElevationAnnotationWriter
{
private const string ApplicationName = "PXP-AE";
public IReadOnlyList<ExistingElevationAnnotation> ReadExisting(Document document)
{
var result = new List<ExistingElevationAnnotation>();
using var transaction = document.Database.TransactionManager.StartOpenCloseTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForRead);
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue;
var data = reference.GetXDataForApplication(ApplicationName)?.AsArray();
if (data is null) continue;
var strings = data.Where(x => x.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Select(x => x.Value?.ToString() ?? string.Empty).ToArray();
var source = Value(strings, "SOURCE"); var text = Value(strings, "ANNOTATIONTEXT");
if (source is null || text is null) continue;
result.Add(new ExistingElevationAnnotation(reference.Handle.ToString(), source, text, new AnnotationPoint(reference.Position.X, reference.Position.Y, reference.Position.Z)));
}
return result.OrderBy(x => x.SourceStableId, StringComparer.Ordinal).ToArray();
}
public ElevationApplyResult Apply(Document document, IReadOnlyList<ElevationAnnotationOperation> operations, ElevationAssets assets, bool manual = false)
{
using var documentLock = document.LockDocument();
using var transaction = document.Database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var layers = (LayerTable)transaction.GetObject(document.Database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(document.Database.TextStyleTableId, OpenMode.ForRead);
if (!blocks.Has(assets.BlockName)) throw new InvalidOperationException("ELEVATION_BLOCK_MISSING:" + assets.BlockName);
if (!layers.Has(assets.Layer)) throw new InvalidOperationException("ELEVATION_LAYER_MISSING:" + assets.Layer);
if (!styles.Has(assets.TextStyle)) throw new InvalidOperationException("ELEVATION_TEXT_STYLE_MISSING:" + assets.TextStyle);
EnsureRegApp(transaction, document.Database);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
var definition = (BlockTableRecord)transaction.GetObject(blocks[assets.BlockName], OpenMode.ForRead);
var owned = paper.Cast<ObjectId>().Select(id => transaction.GetObject(id, OpenMode.ForRead)).OfType<BlockReference>()
.Select(reference => (reference, source: Source(reference))).Where(x => x.source is not null)
.GroupBy(x => x.source!, StringComparer.OrdinalIgnoreCase).ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase);
var created = 0; var updated = 0; var unchanged = 0;
foreach (var operation in operations.Where(x => x.Kind is AnnotationOperationKind.Create or AnnotationOperationKind.Update))
{
if (operation.Text is null) continue;
var matches = owned.GetValueOrDefault(operation.StableId, []);
BlockReference reference;
if (matches.Length == 0)
{
reference = new BlockReference(ToPoint(operation.Position), definition.ObjectId) { LayerId = layers[assets.Layer], Rotation = operation.Rotation };
paper.AppendEntity(reference); transaction.AddNewlyCreatedDBObject(reference, true); created++;
AddAttributes(transaction, definition, reference, operation.Text, styles[assets.TextStyle]);
}
else
{
reference = matches[0].reference; reference.UpgradeOpen();
reference.Position = ToPoint(operation.Position); reference.Rotation = operation.Rotation; reference.LayerId = layers[assets.Layer]; updated++;
foreach (ObjectId attributeId in reference.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(attributeId, OpenMode.ForWrite);
attribute.TextString = operation.Text + (attribute.Tag.Contains('*') ? "*" : string.Empty); attribute.TextStyleId = styles[assets.TextStyle]; attribute.UpdateMTextAttribute();
}
}
reference.XData = Ownership(operation.StableId, operation.Text, manual);
ApplyLegacyDynamicProperties(reference);
}
unchanged += operations.Count(x => x.Kind == AnnotationOperationKind.Skip && x.Reasons.Contains("ALREADY_CURRENT"));
transaction.Commit();
return new(created, updated, unchanged);
}
private static void AddAttributes(Transaction transaction, BlockTableRecord definition, BlockReference reference, string text, ObjectId style)
{
foreach (ObjectId id in definition)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not AttributeDefinition source || source.Constant) continue;
using var attribute = new AttributeReference(); attribute.SetAttributeFromBlock(source, reference.BlockTransform);
attribute.TextString = text + (attribute.Tag.Contains('*') ? "*" : string.Empty); attribute.TextStyleId = style;
attribute.UpdateMTextAttribute(); reference.AttributeCollection.AppendAttribute(attribute); transaction.AddNewlyCreatedDBObject(attribute, true);
}
}
private static void ApplyLegacyDynamicProperties(BlockReference reference)
{
if (!reference.IsDynamicBlock) return;
foreach (DynamicBlockReferenceProperty property in reference.DynamicBlockReferencePropertyCollection)
if (string.Equals(property.PropertyName, "V1", StringComparison.OrdinalIgnoreCase) && !property.ReadOnly) property.Value = "От объекта";
}
private static string? Source(BlockReference reference)
{
var strings = reference.GetXDataForApplication(ApplicationName)?.AsArray().Where(x => x.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Select(x => x.Value?.ToString() ?? string.Empty).ToArray();
return strings is null ? null : Value(strings, "SOURCE");
}
private static string? Value(string[] values, string key) { var index = Array.FindIndex(values, x => string.Equals(x, key, StringComparison.Ordinal)); return index >= 0 && index + 1 < values.Length ? values[index + 1] : null; }
private static Point3d ToPoint(AnnotationPoint point) => new(point.X, point.Y, point.Z);
private static ResultBuffer Ownership(string source, string text, bool manual) => new(new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName), new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCE"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, source), new TypedValue((int)DxfCode.ExtendedDataAsciiString, "ANNOTATIONTEXT"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, text), new TypedValue((int)DxfCode.ExtendedDataAsciiString, manual ? "MANUAL" : "AUTO"));
private static void EnsureRegApp(Transaction transaction, Database database) { var table = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead); if (table.Has(ApplicationName)) return; table.UpgradeOpen(); using var record = new RegAppTableRecord { Name = ApplicationName }; table.Add(record); transaction.AddNewlyCreatedDBObject(record, true); }
}
@@ -0,0 +1,29 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
namespace PRecAnnot.Plant3D2026;
internal sealed record ElevationApplyPermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
}
internal static class ElevationApplyPermitReader
{
private const string Marker = "P_REC_ANNOT_TEST_FIXTURE=ELEVATION_APPLY";
public static ElevationApplyPermit ReadAndValidate(Document document)
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_ELEVATION_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException("ELEVATION_APPLY_PERMIT_REQUIRED");
var permit = JsonSerializer.Deserialize<ElevationApplyPermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("ELEVATION_APPLY_PERMIT_INVALID");
if (!permit.AllowApply || !Path.IsPathFullyQualified(permit.FixtureMarkerPath) || !File.Exists(permit.FixtureMarkerPath) || !string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), Marker, StringComparison.Ordinal))
throw new InvalidOperationException("ELEVATION_APPLY_TEST_FIXTURE_REQUIRED");
var root = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))! + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(document.Name).StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("ELEVATION_APPLY_TEST_FIXTURE_REQUIRED");
return permit;
}
}
@@ -0,0 +1,122 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.P3dProjectParts;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Core.Application;
namespace PRecAnnot.Plant3D2026;
internal static class ElevationCommands
{
public static void RunAutomatic() => Run("PXP-AE", manual: false);
public static void RunManual() => Run("PXP-IE", manual: true);
private static void Run(string command, bool manual)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var assets = ElevationRuntimeConfiguration.Read();
var datum = PromptDatum(document.Editor);
if (datum is null) return;
if (manual) RunManual(document, assets, datum.Value);
else RunAutomatic(document, assets, datum.Value);
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("ElevationCommandFailed", "ERROR", commandName: command, resultStatus: exception.Message, exception: exception, document: document);
document.Editor.WriteMessage($"\n{command}: {exception.Message}");
}
}
private static void RunAutomatic(Document document, ElevationAssets assets, double datum)
{
var selected = SelectViewport(document.Editor);
if (selected.Status != PromptStatus.OK) return;
OrthoViewportInfo viewport;
using (var transaction = document.Database.TransactionManager.StartOpenCloseTransaction())
viewport = ResolveViewport(document, (Viewport)transaction.GetObject(selected.ObjectId, OpenMode.ForRead));
var candidates = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None)
.Where(value => value.MembershipStatus == ViewMembershipStatus.Resolved && value.Geometry.PaperPosition is not null && value.Geometry.Elevation.HasValue)
.Select(value => new ElevationCandidate(value.SnapshotId, value.Geometry.Elevation!.Value, datum,
new AnnotationPoint(value.Geometry.PaperPosition!.Value.X, value.Geometry.PaperPosition.Value.Y), value.Geometry.Rotation ?? 0, assets.Layer, assets.TextStyle))
.ToArray();
var writer = new ElevationAnnotationWriter();
var plan = new ElevationAnnotationPlanner().Build(candidates, writer.ReadExisting(document));
WritePreview(document, "PXP-AE", plan);
if (!IsApply()) return;
ElevationApplyPermitReader.ReadAndValidate(document);
var result = writer.Apply(document, plan.Operations, assets);
WriteApply(document, "PXP-AE", result);
}
private static void RunManual(Document document, ElevationAssets assets, double datum)
{
while (true)
{
var point = document.Editor.GetPoint("\nУкажите положение высотной отметки: ");
if (point.Status != PromptStatus.OK) return;
Viewport? viewport = null;
Point3d modelPoint = default;
using (var transaction = document.Database.TransactionManager.StartOpenCloseTransaction())
{
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForRead);
viewport = paper.Cast<ObjectId>().Select(id => transaction.GetObject(id, OpenMode.ForRead)).OfType<Viewport>()
.Where(value => value.Number > 1 && !value.PerspectiveOn && Contains(value, point.Value))
.OrderBy(value => value.Number).FirstOrDefault();
if (viewport is not null) modelPoint = point.Value.TransformBy(ModelToPaper(viewport).Inverse());
}
if (viewport is null) { document.Editor.WriteMessage("\nТочка должна находиться внутри ортографического видового экрана."); continue; }
var stableId = $"manual:{viewport.Handle}:{point.Value.X:R}:{point.Value.Y:R}";
var candidate = new ElevationCandidate(stableId, modelPoint.Z, datum, new AnnotationPoint(point.Value.X, point.Value.Y), 0, assets.Layer, assets.TextStyle);
var plan = new ElevationAnnotationPlanner().Build([candidate], Array.Empty<ExistingElevationAnnotation>());
WritePreview(document, "PXP-IE", plan);
if (!IsApply()) return;
ElevationApplyPermitReader.ReadAndValidate(document);
var result = new ElevationAnnotationWriter().Apply(document, plan.Operations, assets, manual: true);
WriteApply(document, "PXP-IE", result);
// Legacy PXP-IE repeats until Escape; every committed iteration is
// a native AutoCAD transaction and therefore participates in UNDO.
}
}
private static double? PromptDatum(Editor editor)
{
var options = new PromptDoubleOptions("\nСмещение нуля МСК (в мм) <0>: ") { AllowNone = true, DefaultValue = 0, UseDefaultValue = true };
var result = editor.GetDouble(options);
return result.Status == PromptStatus.OK ? result.Value : result.Status == PromptStatus.None ? 0 : null;
}
private static PromptEntityResult SelectViewport(Editor editor)
{
var options = new PromptEntityOptions("\nВыберите видовой экран: "); options.SetRejectMessage("\nДолжен быть видовой экран."); options.AddAllowedClass(typeof(Viewport), true);
return editor.GetEntity(options);
}
private static OrthoViewportInfo ResolveViewport(Document document, Viewport selected)
{
var project = PlantApplication.ProjectPart("Ortho") as OrthoProject ?? throw new InvalidOperationException("ELEVATION_ORTHO_PROJECT_UNAVAILABLE");
var matches = new PlantOrthoViewportReader(project, document.Name).ReadViewports(CancellationToken.None)
.Where(value => Math.Abs(value.CenterX - selected.CenterPoint.X) < 0.001 && Math.Abs(value.CenterY - selected.CenterPoint.Y) < 0.001 && Math.Abs(value.Width - selected.Width) < 0.001 && Math.Abs(value.Height - selected.Height) < 0.001).ToArray();
return matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "ELEVATION_ORTHO_VIEW_NOT_FOUND" : "ELEVATION_ORTHO_VIEW_AMBIGUOUS");
}
private static Matrix3d ModelToPaper(Viewport viewport)
{
var center = new Point3d(viewport.ViewCenter.X, viewport.ViewCenter.Y, 0);
return Matrix3d.Displacement(new Vector3d(viewport.CenterPoint.X - center.X, viewport.CenterPoint.Y - center.Y, 0))
* Matrix3d.Scaling(viewport.CustomScale, center)
* Matrix3d.Rotation(viewport.TwistAngle, Vector3d.ZAxis, Point3d.Origin)
* Matrix3d.WorldToPlane(new Plane(viewport.ViewTarget, viewport.ViewDirection));
}
private static bool Contains(Viewport viewport, Point3d point) => viewport.GeometricExtents.MinPoint.X <= point.X && viewport.GeometricExtents.MaxPoint.X >= point.X && viewport.GeometricExtents.MinPoint.Y <= point.Y && viewport.GeometricExtents.MaxPoint.Y >= point.Y;
private static bool IsApply() => string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase);
private static void WritePreview(Document document, string command, ElevationAnnotationPlan plan) { document.Editor.WriteMessage($"\n{command} preview: create={plan.Operations.Count(x => x.Kind == AnnotationOperationKind.Create)}; update={plan.Operations.Count(x => x.Kind == AnnotationOperationKind.Update)}; skip={plan.Operations.Count(x => x.Kind == AnnotationOperationKind.Skip)}; conflict={plan.Operations.Count(x => x.Kind == AnnotationOperationKind.Conflict)}. DWG не изменён."); RuntimeDiagnostics.Write("ElevationPreviewCompleted", commandName: command, resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["OperationCount"] = plan.Operations.Count }); }
private static void WriteApply(Document document, string command, ElevationApplyResult result) { document.Editor.WriteMessage($"\n{command} Apply: created={result.Created}; updated={result.Updated}; already-current={result.Unchanged}."); RuntimeDiagnostics.Write("ElevationApplyCompleted", commandName: command, resultStatus: "Applied", document: document, details: new Dictionary<string, object?> { ["Created"] = result.Created, ["Updated"] = result.Updated, ["AlreadyCurrent"] = result.Unchanged }); }
}
@@ -0,0 +1,15 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Plant3D2026;
internal static class ElevationRuntimeConfiguration
{
public static ElevationAssets Read()
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_NUMBER_DRAWING_SETTINGS_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException("ELEVATION_CONFIGURATION_REQUIRED:P_REC_ANNOT_LINE_NUMBER_DRAWING_SETTINGS_PATH");
return new ElevationSettingsLoader().Load(File.ReadAllText(path));
}
}
@@ -0,0 +1,413 @@
using System.Diagnostics;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using Autodesk.Windows;
using Autodesk.ProcessPower.PlantInstance;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
public sealed class EntryPoint : IExtensionApplication
{
public void Initialize()
{
RuntimeDiagnostics.Write("AssemblyInitializeStarted");
var manifest = CompositeModuleManifest.Validate();
RuntimeDiagnostics.Write("CompositeModulesValidated", resultStatus: "Validated", details: new Dictionary<string, object?> { ["PackageVersion"] = manifest.Version, ["ModuleCount"] = manifest.Modules.Count });
RibbonIntegration.Register();
SwapAttributesContextMenu.Attach();
ManualAnnotationPalette.Initialize();
// Test automation is driven only by the explicitly generated /b script.
// Initialize remains production-equivalent and must not orchestrate commands.
RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
}
public void Terminate()
{
RuntimeDiagnostics.Write("AssemblyTerminateStarted");
RibbonIntegration.Terminate();
SwapAttributesContextMenu.Detach();
ManualAnnotationPalette.Terminate();
RuntimeDiagnostics.Write("AssemblyTerminateCompleted");
}
[CommandMethod("PHP_REC_ANNOT")]
public void PrimaryCommand() => RunDiagnostic("PHP_REC_ANNOT");
[CommandMethod("PXP-AAV")]
public void AutoAnnotateViewCommand() => PRecAnnot.Plant3D2026.AutoAnnotateViewCommand.Run();
[CommandMethod("PXP-OA")]
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
[CommandMethod("PXP-LN")] public void AnnotateLineNumbersCommand() => RunLineNumberPreview();
[CommandMethod("PXP-ULN")] public void UpdateLineNumbersCommand() => LineNumberRefreshCommand.Run();
[CommandMethod("PXP-ESN")] public void EditLineNumberStartCommand() => LineNumberEditCommands.Renumber();
[CommandMethod("PXP-ELN")] public void EditLineNumbersCommand() => LineNumberEditCommands.EditText();
[CommandMethod("PXP-RMP")] public void ResetMarkerPositionCommand() => LineNumberEditCommands.ResetMarkerPosition();
[CommandMethod("PXP-ALC")] public void AutoLineNumberConnectionsCommand() => LineConnectionCommand.Run();
[CommandMethod("PXP-AE")] public void AutoElevationCommand() => ElevationCommands.RunAutomatic();
[CommandMethod("PXP-IE")] public void InsertElevationCommand() => ElevationCommands.RunManual();
[CommandMethod("PXP-COV")] public void CheckOrthoViewportCommand() => OrthoMaintenanceCommands.CheckViewport();
[CommandMethod("PXP-UOB")] public void UpdateOrthoViewBlocksCommand() => PRecAnnot.Plant3D2026.UpdateOrthoViewBlocksCommand.Run();
[CommandMethod("PXP-RA", CommandFlags.Session)] public void RemoveOrthoAnnotationsCommand() => OrthoAnnotationRemovalCommand.Run();
[CommandMethod("PXP-OSI", CommandFlags.Session)] public void ShowOrthoSourceInModelCommand() => OrthoSourceNavigationCommand.Run();
[CommandMethod("PXP-DJ")] public void DimensionOrthoAnnotationsCommand() => OrthoDimensionCommand.Run();
[CommandMethod("PXP-AC", CommandFlags.UsePickSet)] public void SwapAttributesCommand() => PRecAnnot.Plant3D2026.SwapAttributesCommand.Run();
[CommandMethod("PXP-IOD", CommandFlags.Session)] public void UpdateOrthoDrawingsCommand() => PRecAnnot.Plant3D2026.UpdateOrthoDrawingsCommand.Run();
[CommandMethod("mto-ma")] public void ShowManualAnnotationPaletteCommand() => ManualAnnotationPalette.Show();
[CommandMethod("PXP-MA")] public void ShowManualAnnotationPaletteAliasCommand() => ManualAnnotationPalette.Show();
[CommandMethod("CAC1")] public void PlaceManualAnnotationCommand() => ManualAnnotationEditCommand.Run();
[CommandMethod("PXP-MAB")] public void InsertManualBlockCommand() => ManualBlockInsertionCommand.Run(ManualAnnotationPalette.CurrentState());
[CommandMethod("CAC")] public void CreateManualAnnotationCommand() => ManualBlockInsertionCommand.Run(ManualAnnotationPalette.CurrentState());
[CommandMethod("BJ")] public void ManualBlockJigCommand() => ManualBlockInsertionCommand.Run(ManualAnnotationPalette.CurrentState());
[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_BEFORE_PXP_1", CommandFlags.Session)]
public void AutomationBeforePxp1Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("BeforePxp1");
[CommandMethod("PHP_REC_AUTOMATION_AFTER_PXP_1", CommandFlags.Session)]
public void AutomationAfterPxp1Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("AfterPxp1");
[CommandMethod("PHP_REC_AUTOMATION_BEFORE_PXP_2", CommandFlags.Session)]
public void AutomationBeforePxp2Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("BeforePxp2");
[CommandMethod("PHP_REC_AUTOMATION_AFTER_PXP_2", CommandFlags.Session)]
public void AutomationAfterPxp2Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("AfterPxp2");
[CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)]
public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizeAutomation();
private static void RunLineNumberPreview()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
var editor = document.Editor;
try
{
var configuration = LineNumberRuntimeConfigurationReader.Read();
var profileDefault = configuration.Annotations.Annotations.OrderBy(value => value.Nickname, StringComparer.Ordinal).First();
var profilePrompt = new PromptStringOptions($"\nПрофиль номера линии <{profileDefault.Nickname}>: ") { AllowSpaces = false, DefaultValue = profileDefault.Nickname, UseDefaultValue = true };
var profileInput = editor.GetString(profilePrompt);
if (profileInput.Status != PromptStatus.OK) return;
var profile = configuration.Annotations.Annotations.SingleOrDefault(value => string.Equals(value.Nickname, profileInput.StringResult, StringComparison.OrdinalIgnoreCase));
if (profile is null) throw new InvalidOperationException("LINE_NUMBER_PROFILE_NOT_FOUND");
var selection = editor.GetEntity("\nВыберите компонент Plant 3D для аннотации номера линии: ");
if (selection.Status != PromptStatus.OK) return;
var piping = PlantApplication.ProjectPart("Piping")?.DataLinksManager ?? throw new InvalidOperationException("LINE_NUMBER_PIPING_DATALINKS_UNAVAILABLE");
var candidate = new PlantLineNumberCandidateReader(piping).Read(selection.ObjectId, configuration.Assets.Marker.Layer, configuration.Assets.Marker.Texts[0].TextStyle);
if (!candidate.Success) throw new InvalidOperationException(candidate.FailureCode);
var preview = new LineNumberCommandPreviewService(
new LineNumberCommandPlanner(new LineNumberAnnotationFormatter()),
new LineNumberFieldResolver(new PlantDataLinksPropertyReader(piping)))
.Preview(profile, [candidate.Candidate!], Array.Empty<ExistingLineNumberAnnotation>());
if (!preview.Success) throw new InvalidOperationException(preview.FailureCode);
var operations = string.Join(", ", preview.Plan!.Operations.Select(value => $"{value.Kind}:{value.Text}"));
editor.WriteMessage($"\nPXP-LN preview: {operations}. DWG не изменён.");
RuntimeDiagnostics.Write("LineNumberPreviewCompleted", commandName: "PXP-LN", resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["OperationCount"] = preview.Apply.Count });
if (ParseMode(RuntimeDiagnostics.RuntimeMode) != PRecAnnotExecutionMode.Apply) return;
var operation = preview.Apply.SingleOrDefault() ?? throw new InvalidOperationException("LINE_NUMBER_APPLY_REQUIRES_EXACTLY_ONE_OPERATION");
var placement = editor.GetPoint("\nУкажите положение маркера номера линии: ");
if (placement.Status != PromptStatus.OK) return;
operation = operation with { Position = new AnnotationPoint(placement.Value.X, placement.Value.Y, placement.Value.Z) };
var permit = LineNumberApplyPermitReader.Read();
LineNumberApplyPermitReader.Validate(document, permit, operation);
// AutoCAD wraps a command's committed transaction in its native
// UNDO record. No compensating-delete path is used here.
var result = new LineNumberAnnotationWriter().Apply(document, [operation], configuration.Assets);
RuntimeDiagnostics.Write("LineNumberApplyCompleted", commandName: "PXP-LN", resultStatus: result.Created > 0 ? "Created" : result.Updated > 0 ? "Updated" : "AlreadyCurrent", document: document, details: new Dictionary<string, object?> { ["Created"] = result.Created, ["Updated"] = result.Updated, ["AlreadyCurrent"] = result.Unchanged });
editor.WriteMessage($"\nPXP-LN Apply: created={result.Created}; updated={result.Updated}; already-current={result.Unchanged}.");
}
catch (System.Exception exception)
{
RuntimeDiagnostics.Write("LineNumberPreviewFailed", "ERROR", commandName: "PXP-LN", resultStatus: exception.Message, exception: exception, document: document);
WriteEditor(document, $"\nPXP-LN: {exception.Message}");
}
}
private static void RunDiagnostic(string commandName)
{
var operationId = Guid.NewGuid().ToString("N");
var stopwatch = Stopwatch.StartNew();
Document? document = null;
RuntimeDiagnostics.Write("CommandStarted", operationId: operationId, commandName: commandName);
try
{
document = AcadApplication.DocumentManager.MdiActiveDocument;
var mode = ParseMode(RuntimeDiagnostics.RuntimeMode);
// Script-driven commands can run while Plant's implicit current
// document lags behind MDI activation. Bind the planner to the
// command document captured above, which is also the observer's
// fixture identity.
var context = ScriptDrivenFirstApplyAutomation.TryGetPreparedContext(document, out var scriptContext)
? scriptContext
: new PlantRuntimeContextReader().ReadCurrentContext(document);
var details = new Dictionary<string, object?>
{
["ExecutionMode"] = mode.ToString(),
["IsPlantProjectDetected"] = context.HasPlantProject,
["IsPlantProjectOpen"] = context.HasPlantProject,
["IsOrthoDrawing"] = context.IsOrthoDrawing,
["ViewportCount"] = context.Viewports.Count,
["DataLinksAvailable"] = DataLinksAvailable(context.HasPlantProject)
};
if (document is null || !context.HasPlantProject)
{
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "PreflightRejected", document: document, details: details);
WriteEditor(document, "\nP_Rec_Annot: Активный Plant 3D-проект не найден; DWG не изменён.");
WriteUsage(operationId, commandName, "PreflightRejected", document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "PreflightRejected", document: document, details: details);
return;
}
if (mode == PRecAnnotExecutionMode.Smoke || !context.IsOrthoDrawing)
{
var status = context.IsOrthoDrawing ? "PreflightAccepted" : "NotOrtho";
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: status, document: document, details: details);
WriteEditor(document, $"\nP_Rec_Annot {commandName}: проект '{context.ProjectDisplayName}' найден; тип чертежа: {(context.IsOrthoDrawing ? "Ortho" : "NotOrtho")}; DWG не изменён.");
WriteUsage(operationId, commandName, status, document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: status, document: document, details: details);
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 = ScriptDrivenFirstApplyAutomation.SelectedTargetSnapshotId
?? 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 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,
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
{
ObjectsExamined = plans.Sum(x => x.Summary.ObjectsExamined),
CreateCount = plans.Sum(x => x.Summary.CreateCount),
UpdateCount = plans.Sum(x => x.Summary.UpdateCount),
SkipCount = plans.Sum(x => x.Summary.SkipCount),
ConflictCount = plans.Sum(x => x.Summary.ConflictCount),
UnsupportedCount = plans.Sum(x => x.Summary.UnsupportedCount),
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 orthoClassifications = objectsByViewport.SelectMany(x => x).OrderBy(x => x.SnapshotId, StringComparer.Ordinal)
.Select(x => new OrthoAnnotationClassifier().Classify(x, new(true, true, true, true, true))).ToArray();
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["MultiLeaderCandidates"] = orthoClassifications.Count(x => x.Representation == OrthoAnnotationRepresentation.MultiLeader);
details["BubbleCandidates"] = orthoClassifications.Count(x => x.Representation == OrthoAnnotationRepresentation.BubbleBlock);
details["PlanCreateCount"] = summary.CreateCount;
details["PlanUpdateCount"] = summary.UpdateCount;
details["PlanSkipCount"] = summary.SkipCount;
details["PlanConflictCount"] = summary.ConflictCount;
details["PlanUnsupportedCount"] = summary.UnsupportedCount;
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, applyPermit.AnnotationTemplateDwgPath);
ScriptDrivenFirstApplyAutomation.RecordApplyOutcome(outcome);
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);
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}.");
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)
{
if (string.Equals(commandName, "PXP-OA", StringComparison.OrdinalIgnoreCase) && string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase))
ScriptDrivenFirstApplyAutomation.RecordApplyFailure(exception);
RuntimeDiagnostics.Write("CommandFailed", "ERROR", operationId, commandName, exception.Message, exception, document);
WriteUsage(operationId, commandName, exception.Message, document, null, stopwatch.ElapsedMilliseconds, exception);
WriteEditor(document, $"\nP_Rec_Annot: безопасная диагностика завершилась ошибкой: {exception.Message}");
}
}
private static PRecAnnotExecutionMode ParseMode(string value) =>
value.Equals("Smoke", StringComparison.OrdinalIgnoreCase) ? PRecAnnotExecutionMode.Smoke :
value.Equals("Apply", StringComparison.OrdinalIgnoreCase) ? PRecAnnotExecutionMode.Apply : PRecAnnotExecutionMode.DryRun;
private static bool DataLinksAvailable(bool hasPlantProject)
{
try { return hasPlantProject && Autodesk.ProcessPower.PlantInstance.PlantApplication.ProjectPart("Piping")?.DataLinksManager is not null; }
catch { return false; }
}
private static void WriteUsage(string operationId, string commandName, string status, Document? document, IReadOnlyDictionary<string, object?>? details, long duration, System.Exception? exception = null)
{
var eventDetails = new Dictionary<string, object?>
{
["ObjectsExamined"] = 0, ["AnnotationsCreated"] = 0, ["AnnotationsUpdated"] = 0,
["AnnotationsDeleted"] = 0, ["AnnotationsSkipped"] = 0, ["WarningCount"] = 0,
["ErrorCount"] = exception is null ? 0 : 1, ["DurationMilliseconds"] = duration
};
if (details is not null) foreach (var item in details) eventDetails[item.Key] = item.Value;
RuntimeDiagnostics.Write("CommandUsageEvent", exception is null ? "INFO" : "ERROR", operationId, commandName, status, exception, document, eventDetails);
}
private static void WriteEditor(Document? document, string message)
{
try { document?.Editor.WriteMessage(message); } catch { }
}
}
internal static class RibbonIntegration
{
private const string TabId = "PRecAnnot.Plant3D2026";
private const string PanelId = "PRecAnnot.Plant3D2026.Annotations";
private const string ButtonId = "PRecAnnot.Plant3D2026.PHP_REC_ANNOT";
private static bool deferred;
public static void Register()
{
RuntimeDiagnostics.Write("RibbonInitializationStarted");
try
{
if (ComponentManager.Ribbon is null)
{
if (!deferred) { ComponentManager.ItemInitialized += OnItemInitialized; deferred = true; }
RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "Deferred");
return;
}
RegisterNow();
}
catch (System.Exception exception) { RuntimeDiagnostics.Write("RibbonInitializationCompleted", "ERROR", exception: exception, resultStatus: "Failed"); }
}
private static void OnItemInitialized(object? sender, EventArgs args)
{
if (ComponentManager.Ribbon is null) return;
ComponentManager.ItemInitialized -= OnItemInitialized;
deferred = false;
Register();
}
private static void RegisterNow()
{
var ribbon = ComponentManager.Ribbon!;
var tab = ribbon.Tabs.FirstOrDefault(x => string.Equals(x.Id, TabId, StringComparison.OrdinalIgnoreCase));
if (tab is null) { tab = new RibbonTab { Id = TabId, Title = "Промхимпроект" }; ribbon.Tabs.Add(tab); }
var panel = tab.Panels.FirstOrDefault(x => string.Equals(x.Source?.Id, PanelId, StringComparison.OrdinalIgnoreCase));
if (panel is not null) { RuntimeDiagnostics.Write("RibbonAlreadyExists", resultStatus: "AlreadyExists"); RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "AlreadyExists"); return; }
// Keep the existing legacy-compatible PHP_REC_ANNOT action and expose the
// production command surface from the same composite panel.
var source = new RibbonPanelSource { Id = PanelId, Title = "Аннотации" };
source.Items.Add(new RibbonButton { Id = ButtonId, Text = "Аннотировать", ShowText = true, CommandParameter = "PHP_REC_ANNOT " });
AddButton(source, "LineNumber", "Line number", "PXP-LN");
AddButton(source, "LineConnections", "Line connections", "PXP-ALC");
AddButton(source, "AutoElevation", "Auto elevation", "PXP-AE");
AddButton(source, "InsertElevation", "Insert elevation", "PXP-IE");
AddButton(source, "OrthoAnnotate", "Ortho annotate", "PXP-OA");
AddButton(source, "AnnotateView", "Annotate view", "PXP-AAV");
AddButton(source, "ShowOrthoSource", "Show source in model", "PXP-OSI");
AddButton(source, "RemoveOrthoAnnotations", "Remove Ortho annotations", "PXP-RA");
AddButton(source, "DimensionOrthoAnnotations", "Dimension Ortho annotations", "PXP-DJ");
AddButton(source, "UpdateOrthoViewBlocks", "Update Ortho view blocks", "PXP-UOB");
AddButton(source, "UpdateOrthoDrawings", "Update Ortho drawings", "PXP-IOD");
AddButton(source, "SwapAttributes", "Swap attributes", "PXP-AC");
AddButton(source, "CreateManualAnnotation", "Create manual annotation", "CAC");
AddButton(source, "ManualBlockJig", "Manual block jig", "BJ");
AddButton(source, "ManualPalette", "Manual annotations", "mto-ma");
tab.Panels.Add(new RibbonPanel { Source = source });
RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "Created");
}
private static void AddButton(RibbonPanelSource? source, string suffix, string text, string command)
{
if (source is null) return;
source.Items.Add(new RibbonButton { Id = $"{PanelId}.{suffix}", Text = text, ShowText = true, CommandParameter = command + " " });
}
public static void Terminate()
{
if (!deferred) return;
ComponentManager.ItemInitialized -= OnItemInitialized;
deferred = false;
}
}
@@ -0,0 +1,504 @@
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? TargetCommand { 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 sealed record PxpOaDiscoveryEvidence(bool Found, string? GlobalName, string? GroupName, string? Flags, string? DeclaringType, string? DeclaringAssembly, string? AssemblyLocation, string? AssemblySha256, bool AssemblyLoaded, bool CommandClassIncluded, bool BundleHashMatched, int DeclarationCount);
internal sealed record AutomationSentinelEvidence(string Stage, string? DocumentIdentity, string? DatabaseIdentity, DateTimeOffset TimestampUtc);
internal sealed record AutomationCommandCorrelationEvidence(string EventKind, string RawCommandName, string NormalizedCommandName, bool CommandNameMatched, bool PreparedStateMatched, bool DocumentMatched, bool DatabaseMatched, bool FixturePathMatched, bool SequenceAccepted, string? RejectionReason, string? SenderType, string? SenderDocumentPath, string? ExpectedDocumentPath, string? ActiveDocumentPath, int RunCount, DateTimeOffset TimestampUtc);
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 string? TargetCommand { 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>();
public PxpOaDiscoveryEvidence? PxpOaDiscovery { get; init; }
public IReadOnlyList<AutomationSentinelEvidence> Sentinels { get; init; } = Array.Empty<AutomationSentinelEvidence>();
public IReadOnlyList<string> CommandEvents { get; init; } = Array.Empty<string>();
public IReadOnlyList<AutomationCommandCorrelationEvidence> CommandCorrelations { get; init; } = Array.Empty<AutomationCommandCorrelationEvidence>();
}
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,140 @@
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; }
// Test-only local copy of the legacy v1.0.4 Template.dwg. The package
// deliberately carries no DWG assets; the per-run permit binds this source.
public required string AnnotationTemplateDwgPath { 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");
if (string.IsNullOrWhiteSpace(permit.AnnotationTemplateDwgPath) || !Path.IsPathFullyQualified(permit.AnnotationTemplateDwgPath) || permit.AnnotationTemplateDwgPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.AnnotationTemplateDwgPath))
throw new InvalidOperationException("ANNOTATION_TEMPLATE_ASSET_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, string annotationTemplateDwgPath)
{
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var table = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
var blockId = EnsureLegacyPipeBlock(transaction, database, table, annotationTemplateDwgPath);
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),
blockId);
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 ObjectId EnsureLegacyPipeBlock(Transaction destinationTransaction, Database destination, BlockTable destinationTable, string templatePath)
{
if (destinationTable.Has(BlockName)) return destinationTable[BlockName];
if (!File.Exists(templatePath)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_ASSET_MISSING");
using var source = new Database(false, true);
source.ReadDwgFile(templatePath, FileShare.Read, true, string.Empty);
source.CloseInput(true);
using var sourceTransaction = source.TransactionManager.StartTransaction();
var sourceTable = (BlockTable)sourceTransaction.GetObject(source.BlockTableId, OpenMode.ForRead);
if (!sourceTable.Has(BlockName)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_PIPE_BLOCK_MISSING");
using var sourceIds = new ObjectIdCollection { sourceTable[BlockName] };
using var mapping = new IdMapping();
destination.WblockCloneObjects(sourceIds, destination.BlockTableId, mapping, DuplicateRecordCloning.Ignore, false);
sourceTransaction.Commit();
var refreshed = (BlockTable)destinationTransaction.GetObject(destination.BlockTableId, OpenMode.ForRead);
if (!refreshed.Has(BlockName)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_BLOCK_IMPORT_FAILED");
return refreshed[BlockName];
}
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));
var allowed = property?.GetAllowedValues().OfType<string>().ToArray() ?? [];
if (property is null || !allowed.Contains(visibilityState, StringComparer.Ordinal))
throw new InvalidOperationException($"ANNOTATION_VISIBILITY_STATE_UNSUPPORTED:Allowed={string.Join('|', allowed)}");
property.Value = visibilityState;
}
}
@@ -0,0 +1,82 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed class LineConnectionAnnotationWriter
{
private const string ApplicationName = "PXP-ALC";
public LineConnectionApplyResult Replace(Document document, string viewportStableId, IReadOnlyList<LineConnectionBoundaryPlan> plans, LineConnectionAssets assets)
{
var insertable = plans.Where(plan => plan.Reasons.Count == 0).OrderBy(plan => plan.Position.X).ThenByDescending(plan => plan.Position.Y).ToArray();
using var documentLock = document.LockDocument();
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
var layers = (LayerTable)transaction.GetObject(database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(database.TextStyleTableId, OpenMode.ForRead);
if (!blocks.Has(assets.BlockName)) throw new InvalidOperationException("LINE_CONNECTION_BLOCK_MISSING:" + assets.BlockName);
if (!layers.Has(assets.PolylineLayer)) throw new InvalidOperationException("LINE_CONNECTION_POLYLINE_LAYER_MISSING:" + assets.PolylineLayer);
if (!layers.Has(assets.TextLayer)) throw new InvalidOperationException("LINE_CONNECTION_TEXT_LAYER_MISSING:" + assets.TextLayer);
if (!styles.Has(assets.TextStyle)) throw new InvalidOperationException("LINE_CONNECTION_TEXT_STYLE_MISSING:" + assets.TextStyle);
EnsureRegApp(transaction, database);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
var deleted = 0;
foreach (ObjectId id in paper)
{
var value = transaction.GetObject(id, OpenMode.ForRead);
var data = value.GetXDataForApplication(ApplicationName)?.AsArray();
if (data is null || !data.Any(item => item.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(item.Value?.ToString(), viewportStableId, StringComparison.Ordinal))) continue;
value.UpgradeOpen(); value.Erase(); deleted++;
}
var definition = (BlockTableRecord)transaction.GetObject(blocks[assets.BlockName], OpenMode.ForRead);
foreach (var plan in insertable)
{
using var reference = new BlockReference(new Point3d(plan.Position.X, plan.Position.Y, plan.Position.Z), definition.ObjectId) { LayerId = layers[assets.PolylineLayer], Rotation = plan.Rotation };
reference.XData = Ownership(viewportStableId, plan);
paper.AppendEntity(reference); transaction.AddNewlyCreatedDBObject(reference, true);
var values = new[] { plan.FirstText ?? string.Empty, plan.SecondText ?? string.Empty };
var index = 0;
foreach (ObjectId entityId in definition)
{
if (transaction.GetObject(entityId, OpenMode.ForRead) is not AttributeDefinition source || source.Constant) continue;
using var attribute = new AttributeReference();
attribute.SetAttributeFromBlock(source, reference.BlockTransform);
attribute.TextString = values[Math.Min(index++, 1)].Replace("\n", "\\P", StringComparison.Ordinal);
attribute.LayerId = layers[assets.TextLayer]; attribute.TextStyleId = styles[assets.TextStyle];
attribute.UpdateMTextAttribute(); reference.AttributeCollection.AppendAttribute(attribute); transaction.AddNewlyCreatedDBObject(attribute, true);
}
if (plan.ConnectorPosition is { } connector)
{
using var leader = new Polyline(3) { LayerId = layers[assets.PolylineLayer], XData = Ownership(viewportStableId, plan) };
leader.AddVertexAt(0, new Point2d(connector.X, connector.Y), 0, 0, 0);
// Legacy PXP-ALC uses an orthogonal two-segment leader. Its
// elbow shares the annotation Y coordinate for a stable route.
leader.AddVertexAt(1, new Point2d(connector.X, plan.Position.Y), 0, 0, 0);
leader.AddVertexAt(2, new Point2d(plan.Position.X, plan.Position.Y), 0, 0, 0);
paper.AppendEntity(leader); transaction.AddNewlyCreatedDBObject(leader, true);
}
}
transaction.Commit();
return new(deleted, insertable.Length);
}
private static ResultBuffer Ownership(string viewport, LineConnectionBoundaryPlan plan) => new(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, viewport),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, plan.StableId),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "LINENUMBER1"),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, plan.FirstText ?? string.Empty),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "LINENUMBER2"),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, plan.SecondText ?? string.Empty));
private static void EnsureRegApp(Transaction transaction, Database database)
{
var table = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead);
if (table.Has(ApplicationName)) return;
table.UpgradeOpen(); using var record = new RegAppTableRecord { Name = ApplicationName }; table.Add(record); transaction.AddNewlyCreatedDBObject(record, true);
}
}
@@ -0,0 +1,39 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed record LineConnectionApplyPermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
public required string ExpectedViewportStableId { get; init; }
public required IReadOnlyList<string> ExpectedConnectorStableIds { get; init; }
}
internal static class LineConnectionApplyPermitReader
{
private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=LINE_CONNECTION_APPLY";
public static LineConnectionApplyPermit Read()
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_CONNECTION_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException("LINE_CONNECTION_APPLY_PERMIT_REQUIRED");
return JsonSerializer.Deserialize<LineConnectionApplyPermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("LINE_CONNECTION_APPLY_PERMIT_INVALID");
}
public static void Validate(Document document, LineConnectionApplyPermit permit, string viewportId, IReadOnlyList<LineConnectionBoundaryPlan> plans)
{
var actual = plans.Where(plan => plan.Reasons.Count == 0).Select(plan => plan.StableId).OrderBy(value => value, StringComparer.Ordinal).ToArray();
var expected = permit.ExpectedConnectorStableIds.OrderBy(value => value, StringComparer.Ordinal).ToArray();
if (!permit.AllowApply || !string.Equals(permit.ExpectedViewportStableId, viewportId, StringComparison.Ordinal) || !actual.SequenceEqual(expected, StringComparer.Ordinal))
throw new InvalidOperationException("LINE_CONNECTION_APPLY_PERMIT_PLAN_MISMATCH");
if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath) || !string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal))
throw new InvalidOperationException("LINE_CONNECTION_APPLY_TEST_FIXTURE_REQUIRED");
var root = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))! + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(document.Name).StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LINE_CONNECTION_APPLY_TEST_FIXTURE_REQUIRED");
}
}
@@ -0,0 +1,78 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.P3dProjectParts;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Core.Application;
namespace PRecAnnot.Plant3D2026;
internal static class LineConnectionCommand
{
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var options = new PromptEntityOptions("\nВыберите видовой экран для аннотаций стыков номеров линий: ");
options.SetRejectMessage("\nТребуется видовой экран."); options.AddAllowedClass(typeof(Viewport), true);
var selected = document.Editor.GetEntity(options);
if (selected.Status != PromptStatus.OK) return;
OrthoViewportInfo viewport;
using (var transaction = document.Database.TransactionManager.StartOpenCloseTransaction())
{
var entity = (Viewport)transaction.GetObject(selected.ObjectId, OpenMode.ForRead);
viewport = ResolveViewport(document, entity);
}
var manager = PlantApplication.ProjectPart("Piping")?.DataLinksManager ?? throw new InvalidOperationException("LINE_CONNECTION_PIPING_DATALINKS_UNAVAILABLE");
var relationships = new PlantConnectorRelationshipReader(manager).Read();
if (!relationships.Success) throw new InvalidOperationException(relationships.FailureCode);
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
var byRow = objects.Where(value => value.MembershipStatus == ViewMembershipStatus.Resolved && value.Geometry.PaperPosition is not null)
.Select(value => (Object: value, Row: ReadRowId(value)))
.Where(value => value.Row.HasValue).ToDictionary(value => value.Row!.Value, value => value.Object);
var placements = new Dictionary<string, LineConnectionPlacement>(StringComparer.Ordinal);
foreach (var relationship in relationships.Connectors)
{
if (!byRow.TryGetValue(relationship.ConnectorRowId, out var connector) || connector.Geometry.PaperPosition is not { } point) continue;
var direction = connector.Geometry.PaperDirection ?? new Vector2Dto(1, 0);
var horizontalLeader = Math.Abs(direction.Y) >= Math.Abs(direction.X);
var block = horizontalLeader ? new AnnotationPoint(viewport.CenterX, point.Y) : new AnnotationPoint(point.X, viewport.CenterY);
var endpoint = new Point3Dto(point.X, point.Y, 0);
placements[relationship.StableId] = new(endpoint, endpoint, new(endpoint, endpoint), block, 0);
}
var configuration = LineNumberRuntimeConfigurationReader.Read();
var definition = configuration.Annotations.Annotations.OrderBy(value => value.Nickname, StringComparer.Ordinal).First();
var preview = new LineConnectionPreviewService(new LineNumberFieldResolver(new PlantDataLinksPropertyReader(manager)), new LineNumberAnnotationFormatter(), new LineConnectionBoundaryPlanner())
.Preview(definition, relationships.Connectors, placements);
var ready = preview.Plans.Count(plan => plan.Reasons.Count == 0);
document.Editor.WriteMessage($"\nPXP-ALC preview: connectors={relationships.Connectors.Count}; eligible={ready}; skipped={preview.Plans.Count - ready}. DWG не изменён.");
RuntimeDiagnostics.Write("LineConnectionPreviewCompleted", commandName: "PXP-ALC", resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["ConnectorCount"] = relationships.Connectors.Count, ["EligibleCount"] = ready });
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase)) return;
var permit = LineConnectionApplyPermitReader.Read();
LineConnectionApplyPermitReader.Validate(document, permit, viewport.Id, preview.Plans);
var result = new LineConnectionAnnotationWriter().Replace(document, viewport.Id, preview.Plans, configuration.Connections);
document.Editor.WriteMessage($"\nPXP-ALC Apply: deleted={result.Deleted}; created={result.Created}.");
RuntimeDiagnostics.Write("LineConnectionApplyCompleted", commandName: "PXP-ALC", resultStatus: "Applied", document: document, details: new Dictionary<string, object?> { ["Deleted"] = result.Deleted, ["Created"] = result.Created });
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("LineConnectionCommandFailed", "ERROR", commandName: "PXP-ALC", resultStatus: exception.Message, exception: exception, document: document);
document.Editor.WriteMessage("\nPXP-ALC: " + exception.Message);
}
}
private static OrthoViewportInfo ResolveViewport(Document document, Viewport selected)
{
var project = PlantApplication.ProjectPart("Ortho") as OrthoProject ?? throw new InvalidOperationException("LINE_CONNECTION_ORTHO_PROJECT_UNAVAILABLE");
var matches = new PlantOrthoViewportReader(project, document.Name).ReadViewports(CancellationToken.None)
.Where(value => Math.Abs(value.CenterX - selected.CenterPoint.X) < 0.001 && Math.Abs(value.CenterY - selected.CenterPoint.Y) < 0.001 && Math.Abs(value.Width - selected.Width) < 0.001 && Math.Abs(value.Height - selected.Height) < 0.001)
.ToArray();
return matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "LINE_CONNECTION_ORTHO_VIEW_NOT_FOUND" : "LINE_CONNECTION_ORTHO_VIEW_AMBIGUOUS");
}
private static long? ReadRowId(PlantObjectSnapshot value) => value.Properties.TryGetValue("PRecAnnot.RowId", out var text) && long.TryParse(text, out var row) ? row : null;
}
@@ -0,0 +1,366 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed class LineNumberAnnotationWriter
{
private const string ApplicationName = "PXP-LN";
private const string MarkerBlockName = "LN_MARKER";
private const string MarkerAttributeTag = "NUMBER";
public LineNumberApplyResult Apply(Document document, IReadOnlyList<LineNumberApplyPreflight> plan, AnnotateLineNumbersAssets assets)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(assets);
if (plan.Select(item => item.StableId).Distinct(StringComparer.Ordinal).Count() != plan.Count)
throw new InvalidOperationException("LINE_NUMBER_APPLY_DUPLICATE_STABLE_ID");
var created = 0;
var updated = 0;
var unchanged = 0;
var database = document.Database;
using var documentLock = document.LockDocument();
using var transaction = database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
ValidateAssets(transaction, database, assets.Marker);
EnsureRegApp(transaction, database);
var markerDefinition = EnsureMarkerDefinition(transaction, database, blocks, assets.Marker);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
foreach (var item in plan.OrderBy(value => value.MarkerNumber))
{
var existing = FindOwnedMarker(transaction, paper, item.StableId);
if (existing is null)
{
CreateMarker(transaction, paper, markerDefinition, item, assets.Marker);
created++;
}
else if (UpdateMarker(transaction, existing, item, assets.Marker.Layer)) updated++;
else unchanged++;
}
transaction.Commit();
return new(created, updated, unchanged);
}
// Legacy PXP-LN materialises its selected marker/text pairs as a paper-space
// LN_LIST block. The list ownership key makes repeat Apply update that
// aggregate rather than append another list.
public LineNumberApplyResult ApplyList(Document document, LineNumberListApplyPreflight plan, AnnotateLineNumbersAssets assets)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(plan);
if (plan.Rows.Count == 0) return new(0, 0, 1);
if (plan.Rows.Select(row => row.MarkerNumber).Distinct().Count() != plan.Rows.Count)
throw new InvalidOperationException("LINE_NUMBER_LIST_DUPLICATE_MARKER_NUMBER");
using var documentLock = document.LockDocument();
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
ValidateListAssets(transaction, database, assets.List);
EnsureRegApp(transaction, database);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
var existing = FindOwnedMarker(transaction, paper, plan.StableId);
if (existing is not null)
{
var changed = UpdateList(transaction, existing, plan);
transaction.Commit();
return new(0, changed ? 1 : 0, changed ? 0 : 1);
}
var definition = CreateListDefinition(transaction, database, blocks, plan, assets.List);
using var reference = new BlockReference(new Point3d(plan.Position.X, plan.Position.Y, plan.Position.Z), definition)
{
Rotation = plan.Rotation,
Layer = assets.List.Layer,
XData = ListOwnership(plan.StableId)
};
paper.AppendEntity(reference);
transaction.AddNewlyCreatedDBObject(reference, true);
AppendListAttributes(transaction, definition, reference, plan.Rows);
transaction.Commit();
return new(1, 0, 0);
}
public LineNumberApplyResult ApplyEdits(Document document, IReadOnlyList<LineNumberRowMutation> mutations)
{
if (mutations.Count == 0) return new(0, 0, 1);
using var documentLock = document.LockDocument();
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForRead);
var updated = 0;
foreach (var mutation in mutations)
{
var marker = FindOwnedMarker(transaction, paper, mutation.SourceStableId) ?? throw new InvalidOperationException("LINE_NUMBER_OWNED_MARKER_NOT_FOUND");
foreach (ObjectId id in marker.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(id, OpenMode.ForWrite);
if (!string.Equals(attribute.Tag, MarkerAttributeTag, StringComparison.OrdinalIgnoreCase)) continue;
attribute.TextString = mutation.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (mutation.MarkerAttributePosition is { } reset) attribute.Position = new Point3d(reset.X, reset.Y, reset.Z).TransformBy(marker.BlockTransform);
}
foreach (ObjectId entityId in paper)
{
if (transaction.GetObject(entityId, OpenMode.ForRead) is not BlockReference list || !list.Name.StartsWith("LN_LIST", StringComparison.OrdinalIgnoreCase)) continue;
foreach (ObjectId attributeId in list.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(attributeId, OpenMode.ForWrite);
if (string.Equals(attribute.Tag, $"NUMBER{mutation.PreviousMarkerNumber}", StringComparison.OrdinalIgnoreCase))
{
attribute.Tag = $"NUMBER{mutation.MarkerNumber}";
attribute.TextString = mutation.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
else if (string.Equals(attribute.Tag, $"LINENUMBER{mutation.PreviousMarkerNumber}", StringComparison.OrdinalIgnoreCase))
{
attribute.Tag = $"LINENUMBER{mutation.MarkerNumber}";
attribute.TextString = mutation.Text.Replace("\n", "\\P", StringComparison.Ordinal);
attribute.UpdateMTextAttribute();
}
}
}
updated++;
}
transaction.Commit();
return new(0, updated, 0);
}
private static void ValidateAssets(Transaction transaction, Database database, LineNumberMarkerAsset marker)
{
var layers = (LayerTable)transaction.GetObject(database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(database.TextStyleTableId, OpenMode.ForRead);
foreach (var layer in marker.Texts.Select(value => value.Layer).Append(marker.Layer).Concat(marker.Lines.Select(value => value.Layer)).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase))
if (!layers.Has(layer)) throw new InvalidOperationException($"LINE_NUMBER_LAYER_MISSING:{layer}");
foreach (var style in marker.Texts.Select(value => value.TextStyle).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase))
if (!styles.Has(style)) throw new InvalidOperationException($"LINE_NUMBER_TEXT_STYLE_MISSING:{style}");
if (marker.Texts.Count == 0) throw new InvalidOperationException("LINE_NUMBER_MARKER_TEXT_ASSET_MISSING");
}
private static void ValidateListAssets(Transaction transaction, Database database, LineNumberListAsset list)
{
var layers = (LayerTable)transaction.GetObject(database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(database.TextStyleTableId, OpenMode.ForRead);
foreach (var layer in new[] { list.Layer, list.MarkerText.Layer, list.LineNumberText.Layer, list.Line.Layer }.Distinct(StringComparer.OrdinalIgnoreCase))
if (!layers.Has(layer)) throw new InvalidOperationException($"LINE_NUMBER_LAYER_MISSING:{layer}");
foreach (var style in new[] { list.MarkerText.TextStyle, list.LineNumberText.TextStyle }.Distinct(StringComparer.OrdinalIgnoreCase))
if (!styles.Has(style)) throw new InvalidOperationException($"LINE_NUMBER_TEXT_STYLE_MISSING:{style}");
if (list.LineHeight <= 0) throw new InvalidOperationException("LINE_NUMBER_LIST_HEIGHT_INVALID");
}
private static ObjectId CreateListDefinition(Transaction transaction, Database database, BlockTable blocks, LineNumberListApplyPreflight plan, LineNumberListAsset list)
{
blocks.UpgradeOpen();
var baseName = $"LN_LIST{plan.Rows.Count}";
var name = baseName;
for (var suffix = 1; blocks.Has(name); suffix++) name = $"{baseName}_{suffix}";
using var definition = new BlockTableRecord { Name = name, Origin = Point3d.Origin };
var id = blocks.Add(definition);
transaction.AddNewlyCreatedDBObject(definition, true);
var layers = (LayerTable)transaction.GetObject(database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(database.TextStyleTableId, OpenMode.ForRead);
for (var index = 0; index < plan.Rows.Count; index++)
{
var row = plan.Rows[index];
AppendListAttribute(transaction, definition, row.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), $"NUMBER{row.MarkerNumber}", "Номер маркера:", list.MarkerText, index, list.LineHeight, list.MaskScaleFactor, layers, styles);
AppendListAttribute(transaction, definition, row.Text.Replace("\n", "(|)", StringComparison.Ordinal), $"LINENUMBER{row.MarkerNumber}", "Номер линии:", list.LineNumberText, index, list.LineHeight, list.MaskScaleFactor, layers, styles);
using var horizontal = new Line(
Shift(list.Line.Start, index, list.LineHeight),
Shift(list.Line.End, index, list.LineHeight)) { LayerId = layers[list.Line.Layer] };
definition.AppendEntity(horizontal);
transaction.AddNewlyCreatedDBObject(horizontal, true);
}
using var vertical = new Line(
new Point3d(list.Line.Start.X, list.Line.Start.Y + list.LineHeight, 0),
Shift(list.Line.Start, plan.Rows.Count - 1, list.LineHeight)) { LayerId = layers[list.Line.Layer] };
definition.AppendEntity(vertical);
transaction.AddNewlyCreatedDBObject(vertical, true);
return id;
}
private static void AppendListAttribute(Transaction transaction, BlockTableRecord definition, string text, string tag, string prompt, LineNumberTextAsset asset, int row, double lineHeight, double maskScale, LayerTable layers, TextStyleTable styles)
{
var position = new Point3d(asset.Position.X, asset.Position.Y - lineHeight * row, 0);
using var attribute = new AttributeDefinition(position, text, tag, prompt, styles[asset.TextStyle]) { Height = asset.Size, IsMTextAttributeDefinition = true, LayerId = layers[asset.Layer] };
var mtext = attribute.MTextAttributeDefinition;
mtext.BackgroundFill = true;
mtext.BackgroundScaleFactor = maskScale;
mtext.UseBackgroundColor = true;
attribute.MTextAttributeDefinition = mtext;
definition.AppendEntity(attribute);
transaction.AddNewlyCreatedDBObject(attribute, true);
}
private static Point3d Shift(Point2Dto source, int row, double height) => new(source.X, source.Y - height * row, 0);
private static void AppendListAttributes(Transaction transaction, ObjectId definitionId, BlockReference reference, IReadOnlyList<LineNumberApplyPreflight> rows)
{
var values = rows.SelectMany(row => new[]
{
new KeyValuePair<string, string>($"NUMBER{row.MarkerNumber}", row.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>($"LINENUMBER{row.MarkerNumber}", row.Text.Replace("\n", "\\P", StringComparison.Ordinal))
}).ToDictionary(value => value.Key, value => value.Value, StringComparer.OrdinalIgnoreCase);
var definition = (BlockTableRecord)transaction.GetObject(definitionId, OpenMode.ForRead);
foreach (ObjectId id in definition)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not AttributeDefinition source || source.Constant) continue;
using var attribute = new AttributeReference();
attribute.SetAttributeFromBlock(source, reference.BlockTransform);
attribute.TextString = values[source.Tag];
attribute.UpdateMTextAttribute();
var row = rows.Single(value => source.Tag.EndsWith(value.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal));
attribute.XData = new ResultBuffer(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, row.StableId),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, source.Tag.StartsWith("NUMBER", StringComparison.OrdinalIgnoreCase) ? "MARKER" : "LINENUMBER"),
new TypedValue((int)DxfCode.ExtendedDataInteger32, row.MarkerNumber));
reference.AttributeCollection.AppendAttribute(attribute);
transaction.AddNewlyCreatedDBObject(attribute, true);
}
}
private static bool UpdateList(Transaction transaction, BlockReference reference, LineNumberListApplyPreflight plan)
{
var position = new Point3d(plan.Position.X, plan.Position.Y, plan.Position.Z);
var changed = reference.Position != position || reference.Rotation != plan.Rotation;
reference.UpgradeOpen();
reference.Position = position;
reference.Rotation = plan.Rotation;
var values = plan.Rows.SelectMany(row => new[]
{
new KeyValuePair<string, string>($"NUMBER{row.MarkerNumber}", row.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>($"LINENUMBER{row.MarkerNumber}", row.Text.Replace("\n", "\\P", StringComparison.Ordinal))
}).ToDictionary(value => value.Key, value => value.Value, StringComparer.OrdinalIgnoreCase);
foreach (ObjectId id in reference.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(id, OpenMode.ForWrite);
if (!values.TryGetValue(attribute.Tag, out var value)) continue;
changed |= !string.Equals(attribute.TextString, value, StringComparison.Ordinal);
attribute.TextString = value;
attribute.UpdateMTextAttribute();
}
return changed;
}
private static ObjectId EnsureMarkerDefinition(Transaction transaction, Database database, BlockTable blocks, LineNumberMarkerAsset marker)
{
if (blocks.Has(MarkerBlockName)) return blocks[MarkerBlockName];
blocks.UpgradeOpen();
using var definition = new BlockTableRecord { Name = MarkerBlockName, Origin = Point3d.Origin };
var definitionId = blocks.Add(definition);
transaction.AddNewlyCreatedDBObject(definition, true);
var layers = (LayerTable)transaction.GetObject(database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(database.TextStyleTableId, OpenMode.ForRead);
foreach (var text in marker.Texts)
{
using var attribute = new AttributeDefinition(
new Point3d(text.Position.X, text.Position.Y, 0), text.TextValue, MarkerAttributeTag, "Номер маркера:", styles[text.TextStyle])
{
Height = text.Size,
IsMTextAttributeDefinition = true,
LayerId = layers[text.Layer]
};
definition.AppendEntity(attribute);
transaction.AddNewlyCreatedDBObject(attribute, true);
}
foreach (var source in marker.Lines)
{
using var line = new Line(new Point3d(source.Start.X, source.Start.Y, 0), new Point3d(source.End.X, source.End.Y, 0)) { LayerId = layers[source.Layer] };
definition.AppendEntity(line);
transaction.AddNewlyCreatedDBObject(line, true);
}
return definitionId;
}
private static void CreateMarker(Transaction transaction, BlockTableRecord paper, ObjectId definitionId, LineNumberApplyPreflight item, LineNumberMarkerAsset marker)
{
using var reference = new BlockReference(new Point3d(item.Position.X, item.Position.Y, item.Position.Z), definitionId)
{
Rotation = item.Rotation,
Layer = marker.Layer
};
paper.AppendEntity(reference);
transaction.AddNewlyCreatedDBObject(reference, true);
reference.XData = Ownership(item);
var definition = (BlockTableRecord)transaction.GetObject(definitionId, OpenMode.ForRead);
foreach (ObjectId id in definition)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not AttributeDefinition source || source.Constant) continue;
using var attribute = new AttributeReference();
attribute.SetAttributeFromBlock(source, reference.BlockTransform);
attribute.TextString = item.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
attribute.XData = new ResultBuffer(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "MARKER"),
new TypedValue((int)DxfCode.ExtendedDataInteger32, item.MarkerNumber));
reference.AttributeCollection.AppendAttribute(attribute);
transaction.AddNewlyCreatedDBObject(attribute, true);
}
}
private static BlockReference? FindOwnedMarker(Transaction transaction, BlockTableRecord paper, string stableId)
{
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue;
using var data = reference.GetXDataForApplication(ApplicationName);
var values = data?.AsArray();
if (values?.Any(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(value.Value as string, stableId, StringComparison.Ordinal)) == true)
return reference;
}
return null;
}
private static ResultBuffer ListOwnership(string stableId) => new(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, stableId),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "LIST"));
private static bool UpdateMarker(Transaction transaction, BlockReference marker, LineNumberApplyPreflight item, string layer)
{
var position = new Point3d(item.Position.X, item.Position.Y, item.Position.Z);
using var existingOwnership = marker.GetXDataForApplication(ApplicationName);
var ownership = existingOwnership?.AsArray() ?? [];
var changed = marker.Position != position || marker.Rotation != item.Rotation ||
!string.Equals(marker.Layer, layer, StringComparison.OrdinalIgnoreCase) ||
!ownership.Any(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(value.Value as string, item.Text, StringComparison.Ordinal));
marker.UpgradeOpen();
marker.Position = position;
marker.Rotation = item.Rotation;
marker.Layer = layer;
marker.XData = Ownership(item);
foreach (ObjectId id in marker.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(id, OpenMode.ForWrite);
if (!string.Equals(attribute.Tag, MarkerAttributeTag, StringComparison.OrdinalIgnoreCase)) continue;
var value = item.MarkerNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
changed |= !string.Equals(attribute.TextString, value, StringComparison.Ordinal);
attribute.TextString = value;
}
return changed;
}
private static ResultBuffer Ownership(LineNumberApplyPreflight item) => new(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, item.StableId),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "MARKER"),
new TypedValue((int)DxfCode.ExtendedDataInteger32, item.MarkerNumber),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, item.Text));
private static void EnsureRegApp(Transaction transaction, Database database)
{
var table = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead);
if (table.Has(ApplicationName)) return;
table.UpgradeOpen();
using var record = new RegAppTableRecord { Name = ApplicationName };
table.Add(record);
transaction.AddNewlyCreatedDBObject(record, true);
}
}
@@ -0,0 +1,75 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Plant3D2026;
internal sealed record LineNumberApplyPermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
public required string ExpectedStableId { get; init; }
public required string ExpectedText { get; init; }
}
internal sealed record LineNumberEditApplyPermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
public required string ExpectedCommandName { get; init; }
public required string ExpectedSelectedObjectHandle { get; init; }
public required string ExpectedMutationHash { get; init; }
}
internal static class LineNumberApplyPermitReader
{
private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=LINE_NUMBER_APPLY";
public static void Validate(Document document, LineNumberApplyPermit permit, LineNumberApplyPreflight operation)
{
if (!permit.AllowApply || !string.Equals(permit.ExpectedStableId, operation.StableId, StringComparison.Ordinal) || !string.Equals(permit.ExpectedText, operation.Text, StringComparison.Ordinal))
throw new InvalidOperationException("LINE_NUMBER_APPLY_PERMIT_PLAN_MISMATCH");
if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath) || !string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal))
throw new InvalidOperationException("LINE_NUMBER_APPLY_TEST_FIXTURE_REQUIRED");
var fixtureRoot = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))! + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(document.Name).StartsWith(fixtureRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LINE_NUMBER_APPLY_TEST_FIXTURE_REQUIRED");
}
public static LineNumberApplyPermit Read()
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_NUMBER_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException("LINE_NUMBER_APPLY_PERMIT_REQUIRED");
return JsonSerializer.Deserialize<LineNumberApplyPermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("LINE_NUMBER_APPLY_PERMIT_INVALID");
}
}
internal static class LineNumberEditApplyPermitReader
{
private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=LINE_NUMBER_EDIT_APPLY";
public static LineNumberEditApplyPermit Read()
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_NUMBER_EDIT_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_REQUIRED");
return JsonSerializer.Deserialize<LineNumberEditApplyPermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_INVALID");
}
public static void Validate(Document document, LineNumberEditApplyPermit permit, string commandName, string selectedObjectHandle, IReadOnlyList<LineNumberRowMutation> mutations)
{
var mutationHash = LineNumberEditApplyPlan.ComputeMutationHash(commandName, selectedObjectHandle, mutations);
if (!permit.AllowApply ||
!string.Equals(permit.ExpectedCommandName, commandName, StringComparison.Ordinal) ||
!string.Equals(permit.ExpectedSelectedObjectHandle, selectedObjectHandle, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(permit.ExpectedMutationHash, mutationHash, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_PLAN_MISMATCH");
if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath) || !string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal))
throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_TEST_FIXTURE_REQUIRED");
var fixtureRoot = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))! + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(document.Name).StartsWith(fixtureRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_TEST_FIXTURE_REQUIRED");
}
}
@@ -0,0 +1,85 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
internal static class LineNumberEditCommands
{
public static void Renumber()
{
Run("PXP-ESN", (document, editor, selected) =>
{
var start = editor.GetInteger(new PromptIntegerOptions("\nВведите новое начальное значение маркера: ") { LowerLimit = 1 });
if (start.Status != PromptStatus.OK) return null;
var rows = new LineNumberOwnedAnnotationReader().ReadList(document, selected);
return LineNumberEditPlanner.Renumber(rows, start.Value);
}, "Выберите блок списка номеров линий: ");
}
public static void EditText()
{
Run("PXP-ELN", (document, editor, selected) =>
{
var rows = new LineNumberOwnedAnnotationReader().ReadList(document, selected);
var marker = editor.GetInteger(new PromptIntegerOptions("\nВведите номер редактируемого маркера: ") { LowerLimit = 1 });
if (marker.Status != PromptStatus.OK) return null;
var row = rows.SingleOrDefault(value => value.MarkerNumber == marker.Value) ?? throw new InvalidOperationException("LINE_NUMBER_ROW_NOT_FOUND");
var text = editor.GetString(new PromptStringOptions("\nВведите новый текст номера линии: ") { AllowSpaces = true });
if (text.Status != PromptStatus.OK) return null;
return [LineNumberEditPlanner.EditText(row, text.StringResult)];
}, "Выберите блок списка номеров линий: ");
}
public static void ResetMarkerPosition()
{
Run("PXP-RMP", (document, _, selected) =>
{
var row = new LineNumberOwnedAnnotationReader().ReadMarker(document, selected);
var configured = LineNumberRuntimeConfigurationReader.Read().Assets.Marker.Texts[0].Position;
return [LineNumberEditPlanner.ResetMarkerAttributePosition(row, configured)];
}, "Выберите маркер номера линии: ");
}
private static void Run(string command, Func<Document, Editor, ObjectId, IReadOnlyList<LineNumberRowMutation>?> operation, string prompt)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
// These legacy editing paths are mutating commands. Until each
// command has a permit that binds its selected owned block and
// requested edit, DryRun must remain read-only rather than letting
// an editor prompt accidentally commit a transaction.
if (!IsApply())
{
document.Editor.WriteMessage($"\n{command} preview boundary: Apply permit is required; DWG не изменён.");
RuntimeDiagnostics.Write("LineNumberEditPreviewCompleted", commandName: command, resultStatus: "ApplyPermitRequired", document: document);
return;
}
var options = new PromptEntityOptions($"\n{prompt}");
options.SetRejectMessage("\nТребуется вхождение блока PXP-LN.");
options.AddAllowedClass(typeof(BlockReference), true);
var selected = document.Editor.GetEntity(options);
if (selected.Status != PromptStatus.OK) return;
var mutations = operation(document, document.Editor, selected.ObjectId);
if (mutations is null) return;
var selectedHandle = selected.ObjectId.Handle.ToString();
LineNumberEditApplyPermitReader.Validate(document, LineNumberEditApplyPermitReader.Read(), command, selectedHandle, mutations);
var result = new LineNumberAnnotationWriter().ApplyEdits(document, mutations);
document.Editor.WriteMessage($"\n{command}: updated={result.Updated}; unchanged={result.Unchanged}.");
RuntimeDiagnostics.Write("LineNumberEditCompleted", commandName: command, resultStatus: result.Updated > 0 ? "Updated" : "AlreadyCurrent", document: document,
details: new Dictionary<string, object?> { ["Updated"] = result.Updated, ["AlreadyCurrent"] = result.Unchanged });
}
catch (System.Exception exception)
{
RuntimeDiagnostics.Write("LineNumberEditFailed", "ERROR", commandName: command, resultStatus: exception.Message, exception: exception, document: document);
document.Editor.WriteMessage($"\n{command}: {exception.Message}");
}
}
private static bool IsApply() => string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,96 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed class LineNumberOwnedAnnotationReader
{
private const string ApplicationName = "PXP-LN";
public IReadOnlyList<LineNumberOwnedRow> ReadList(Document document, ObjectId listId)
{
using var transaction = document.Database.TransactionManager.StartOpenCloseTransaction();
if (transaction.GetObject(listId, OpenMode.ForRead) is not BlockReference list || !HasRole(list, "LIST"))
throw new InvalidOperationException("LINE_NUMBER_LIST_NOT_SELECTED");
var rows = new Dictionary<int, (string? StableId, string? Text)>();
foreach (ObjectId id in list.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(id, OpenMode.ForRead);
var ownership = ReadOwnership(attribute);
if (ownership is null) continue;
rows.TryGetValue(ownership.Value.MarkerNumber, out var row);
rows[ownership.Value.MarkerNumber] = ownership.Value.Role == "LINENUMBER"
? (ownership.Value.StableId, attribute.TextString.Replace("\\P", "\n", StringComparison.Ordinal))
: (ownership.Value.StableId ?? row.StableId, row.Text);
}
var result = rows.OrderBy(value => value.Key)
.Select(value => new LineNumberOwnedRow(
value.Value.StableId ?? throw new InvalidOperationException("LINE_NUMBER_ROW_STABLE_ID_MISSING"),
value.Key,
value.Value.Text ?? throw new InvalidOperationException("LINE_NUMBER_ROW_TEXT_MISSING"),
default))
.ToArray();
if (result.Length == 0) throw new InvalidOperationException("LINE_NUMBER_LIST_EMPTY");
return result;
}
public LineNumberOwnedRow ReadMarker(Document document, ObjectId markerId)
{
using var transaction = document.Database.TransactionManager.StartOpenCloseTransaction();
if (transaction.GetObject(markerId, OpenMode.ForRead) is not BlockReference marker)
throw new InvalidOperationException("LINE_NUMBER_MARKER_NOT_SELECTED");
var values = marker.GetXDataForApplication(ApplicationName)?.AsArray() ?? [];
var stableId = values.Where(value => value.TypeCode == 1000).Select(value => value.Value as string)
.FirstOrDefault(value => !string.Equals(value, "MARKER", StringComparison.OrdinalIgnoreCase));
var markerNumber = values.Where(value => value.TypeCode == 1071).Select(value => Convert.ToInt32(value.Value)).FirstOrDefault();
var text = values.Where(value => value.TypeCode == 1000).Select(value => value.Value as string).LastOrDefault() ?? string.Empty;
if (string.IsNullOrWhiteSpace(stableId) || markerNumber <= 0) throw new InvalidOperationException("LINE_NUMBER_MARKER_OWNERSHIP_MISSING");
var localPosition = Point3d.Origin;
foreach (ObjectId id in marker.AttributeCollection)
{
var attribute = (AttributeReference)transaction.GetObject(id, OpenMode.ForRead);
if (!string.Equals(attribute.Tag, "NUMBER", StringComparison.OrdinalIgnoreCase)) continue;
localPosition = attribute.Position.TransformBy(marker.BlockTransform.Inverse());
break;
}
return new(stableId, markerNumber, text, new(localPosition.X, localPosition.Y, localPosition.Z));
}
public IReadOnlyList<LineNumberOwnedRow> ReadMarkers(Document document)
{
using var transaction = document.Database.TransactionManager.StartOpenCloseTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForRead);
var rows = new List<LineNumberOwnedRow>();
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference marker || !HasRole(marker, "MARKER")) continue;
var values = marker.GetXDataForApplication(ApplicationName)?.AsArray() ?? [];
var strings = values.Where(value => value.TypeCode == 1000).Select(value => value.Value as string).ToArray();
var stableId = strings.FirstOrDefault(value => !string.Equals(value, "MARKER", StringComparison.OrdinalIgnoreCase));
var markerNumber = values.Where(value => value.TypeCode == 1071).Select(value => Convert.ToInt32(value.Value)).FirstOrDefault();
var text = strings.LastOrDefault() ?? string.Empty;
if (string.IsNullOrWhiteSpace(stableId) || markerNumber <= 0) continue;
rows.Add(new(stableId, markerNumber, text, default));
}
return rows.OrderBy(value => value.MarkerNumber).ThenBy(value => value.SourceStableId, StringComparer.Ordinal).ToArray();
}
private static bool HasRole(BlockReference reference, string role) =>
reference.GetXDataForApplication(ApplicationName)?.AsArray().Any(value =>
value.TypeCode == 1000 && string.Equals(value.Value as string, role, StringComparison.OrdinalIgnoreCase)) == true;
private static (string? StableId, string Role, int MarkerNumber)? ReadOwnership(AttributeReference attribute)
{
var values = attribute.GetXDataForApplication(ApplicationName)?.AsArray();
if (values is null) return null;
var strings = values.Where(value => value.TypeCode == 1000).Select(value => value.Value as string).ToArray();
var role = strings.FirstOrDefault(value => value is "MARKER" or "LINENUMBER");
var number = values.Where(value => value.TypeCode == 1071).Select(value => Convert.ToInt32(value.Value)).FirstOrDefault();
return role is null || number <= 0 ? null : (strings.FirstOrDefault(value => value != role), role, number);
}
}
@@ -0,0 +1,79 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.PlantInstance;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
// PXP-ULN refreshes the source-backed text of already-owned annotations. The
// legacy command drives the same operation across selected Ortho drawings; the
// modern command keeps the mutation pipeline scoped to the active controlled
// drawing until the Ortho drawing enumerator is ported.
internal static class LineNumberRefreshCommand
{
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var configuration = LineNumberRuntimeConfigurationReader.Read();
var definition = configuration.Annotations.Annotations.OrderBy(value => value.Nickname, StringComparer.Ordinal).First();
var manager = PlantApplication.ProjectPart("Piping")?.DataLinksManager ?? throw new InvalidOperationException("LINE_NUMBER_PIPING_DATALINKS_UNAVAILABLE");
var owned = new LineNumberOwnedAnnotationReader().ReadMarkers(document);
if (owned.Count == 0) throw new InvalidOperationException("LINE_NUMBER_OWNED_MARKERS_NOT_FOUND");
var candidates = owned.Select(row => ReadCandidate(document.Database, row, manager, configuration.Assets)).ToArray();
var preview = new LineNumberCommandPreviewService(
new LineNumberCommandPlanner(new LineNumberAnnotationFormatter()),
new LineNumberFieldResolver(new PlantDataLinksPropertyReader(manager)))
.Preview(definition, candidates, owned.Select(row => new ExistingLineNumberAnnotation(row.SourceStableId, row.SourceStableId, row.Text)));
if (!preview.Success) throw new InvalidOperationException(preview.FailureCode);
var rows = owned.ToDictionary(row => row.SourceStableId, StringComparer.OrdinalIgnoreCase);
var apply = preview.Plan!.Operations.Where(operation => operation.Kind is AnnotationOperationKind.Create or AnnotationOperationKind.Update)
.Select(operation => new LineNumberApplyPreflight(operation.StableId, rows[operation.StableId].MarkerNumber, operation.Text!, operation.Position, operation.Rotation, operation.Layer, operation.TextStyle))
.OrderBy(operation => operation.MarkerNumber).ToArray();
if (!IsApply())
{
document.Editor.WriteMessage($"\nPXP-ULN preview: changes={apply.Length}; DWG не изменён.");
RuntimeDiagnostics.Write("LineNumberRefreshPreviewCompleted", commandName: "PXP-ULN", resultStatus: "PreviewCompleted", document: document,
details: new Dictionary<string, object?> { ["OperationCount"] = apply.Length, ["Scope"] = "ActiveDrawing" });
return;
}
// The controlled Apply contract is deliberately narrow: a permit
// binds one resolved source/text pair, preventing a refresh from
// widening into an unreviewed drawing-wide write.
if (apply.Length != 1) throw new InvalidOperationException("LINE_NUMBER_REFRESH_APPLY_REQUIRES_EXACTLY_ONE_OPERATION");
LineNumberApplyPermitReader.Validate(document, LineNumberApplyPermitReader.Read(), apply[0]);
var writer = new LineNumberAnnotationWriter();
var markerResult = writer.Apply(document, apply, configuration.Assets);
var mutations = apply.Select(item => new LineNumberRowMutation(item.StableId, rows[item.StableId].MarkerNumber, item.MarkerNumber, item.Text, null)).ToArray();
var listResult = writer.ApplyEdits(document, mutations);
document.Editor.WriteMessage($"\nPXP-ULN: updated markers={markerResult.Updated}; lists={listResult.Updated}; already-current={markerResult.Unchanged}.");
RuntimeDiagnostics.Write("LineNumberRefreshCompleted", commandName: "PXP-ULN", resultStatus: markerResult.Updated > 0 ? "Updated" : "AlreadyCurrent", document: document,
details: new Dictionary<string, object?> { ["UpdatedMarkers"] = markerResult.Updated, ["UpdatedLists"] = listResult.Updated, ["AlreadyCurrent"] = markerResult.Unchanged, ["Scope"] = "ActiveDrawing" });
}
catch (System.Exception exception)
{
RuntimeDiagnostics.Write("LineNumberRefreshFailed", "ERROR", commandName: "PXP-ULN", resultStatus: exception.Message, exception: exception, document: document);
document.Editor.WriteMessage($"\nPXP-ULN: {exception.Message}");
}
}
private static bool IsApply() => string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase);
private static LineNumberCandidate ReadCandidate(Database database, LineNumberOwnedRow row, Autodesk.ProcessPower.DataLinks.DataLinksManager manager, AnnotateLineNumbersAssets assets)
{
const string prefix = "acad:";
if (!row.SourceStableId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LINE_NUMBER_SOURCE_ID_UNSUPPORTED");
if (!long.TryParse(row.SourceStableId[prefix.Length..], System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var handle))
throw new InvalidOperationException("LINE_NUMBER_SOURCE_HANDLE_INVALID");
var id = database.GetObjectId(false, new Handle(handle), 0);
var read = new PlantLineNumberCandidateReader(manager).Read(id, assets.Marker.Layer, assets.Marker.Texts[0].TextStyle);
if (!read.Success) throw new InvalidOperationException(read.FailureCode);
return read.Candidate!;
}
}
@@ -0,0 +1,25 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Plant3D2026;
internal sealed record LineNumberRuntimeConfiguration(LineNumberAnnotationSettings Annotations, AnnotateLineNumbersAssets Assets, LineConnectionAssets Connections);
internal static class LineNumberRuntimeConfigurationReader
{
public static LineNumberRuntimeConfiguration Read()
{
var annotationPath = RequiredLocalFile("P_REC_ANNOT_LINE_NUMBER_ANNOTATION_SETTINGS_PATH");
var drawingPath = RequiredLocalFile("P_REC_ANNOT_LINE_NUMBER_DRAWING_SETTINGS_PATH");
var drawingXml = File.ReadAllText(drawingPath);
return new(new LineNumberAnnotationSettingsLoader().Load(File.ReadAllText(annotationPath)), new AnnotateLineNumbersSettingsLoader().Load(drawingXml), new LineConnectionSettingsLoader().Load(drawingXml));
}
private static string RequiredLocalFile(string variable)
{
var path = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path))
throw new InvalidOperationException($"LINE_NUMBER_CONFIGURATION_REQUIRED:{variable}");
return path;
}
}
@@ -0,0 +1,56 @@
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.ApplicationServices;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
internal static class ManualAnnotationEditCommand
{
private const string AppName = "P_REC_ANNOT_MANUAL";
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
var options = new PromptEntityOptions("\nSelect owned manual annotation to edit: ");
options.SetRejectMessage("\nSelect a block reference.");
options.AddAllowedClass(typeof(BlockReference), true);
var selection = document.Editor.GetEntity(options);
if (selection.Status != PromptStatus.OK) return;
using var documentLock = document.LockDocument();
using var transaction = document.Database.TransactionManager.StartTransaction();
var reference = transaction.GetObject(selection.ObjectId, OpenMode.ForWrite) as BlockReference;
if (reference is null) return;
var state = ReadState(reference);
if (state is null)
{
document.Editor.WriteMessage("\nManual annotation state is missing or invalid; no migration was performed.");
return;
}
document.Editor.WriteMessage($"\nRestored {state.Kind}; datum={state.DatumMode}; offset={state.DatumOffset:0.###}.");
foreach (ObjectId attributeId in reference.AttributeCollection)
{
var attribute = transaction.GetObject(attributeId, OpenMode.ForWrite) as AttributeReference;
if (attribute is null) continue;
var prompt = new PromptStringOptions($"\n{attribute.Tag} <{attribute.TextString}>: ") { AllowSpaces = true, UseDefaultValue = true, DefaultValue = attribute.TextString };
var value = document.Editor.GetString(prompt);
if (value.Status == PromptStatus.OK) attribute.TextString = value.StringResult;
else if (value.Status is PromptStatus.Cancel or PromptStatus.Error) return;
}
reference.XData = new ResultBuffer(new TypedValue((int)DxfCode.ExtendedDataRegAppName, AppName), new TypedValue((int)DxfCode.ExtendedDataAsciiString, state.Serialize()));
transaction.Commit();
document.Editor.WriteMessage("\nManual annotation updated; native AutoCAD UNDO is available.");
RuntimeDiagnostics.Write("ManualAnnotationEditCompleted", commandName: "CAC1", resultStatus: "Updated", document: document);
}
private static ManualAnnotationState? ReadState(BlockReference reference)
{
using var data = reference.GetXDataForApplication(AppName);
var serialized = data?.AsArray().FirstOrDefault(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Value as string;
return ManualAnnotationState.Deserialize(serialized);
}
}
@@ -0,0 +1,115 @@
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.Windows;
using Autodesk.ProcessPower.PlantInstance;
using PRecAnnot.Core;
using WinForms = System.Windows.Forms;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
internal static class ManualAnnotationPalette
{
private static readonly Guid PaletteId = new("dc74a715-60cc-6f6b-8dc7-ccf6a3e52d60");
private static PaletteSet? palette;
private static ManualAnnotationControl? control;
public static void Initialize()
{
AcadApplication.DocumentManager.DocumentActivated -= DocumentActivated;
AcadApplication.DocumentManager.DocumentActivated += DocumentActivated;
}
public static void Terminate()
{
AcadApplication.DocumentManager.DocumentActivated -= DocumentActivated;
if (palette is not null) palette.Visible = false;
}
public static void Show()
{
if (palette is null)
{
control = new ManualAnnotationControl();
palette = new PaletteSet("Ortho drawing annotations", "P_REC_ANNOT_MANUAL", PaletteId)
{ Style = PaletteSetStyles.NameEditable | PaletteSetStyles.ShowPropertiesMenu | PaletteSetStyles.ShowAutoHideButton | PaletteSetStyles.ShowCloseButton };
palette.Add("Annotations", control);
}
control!.RefreshAvailability(); palette.Visible = true;
RuntimeDiagnostics.Write("ManualAnnotationPaletteShown", resultStatus: "Visible");
}
public static void PlaceSelected() => control?.Place();
public static ManualAnnotationState CurrentState() => control?.CurrentState() ?? new ManualAnnotationState("AutoCadBlock", ManualDatumMode.Absolute, 0);
private static void DocumentActivated(object sender, Autodesk.AutoCAD.ApplicationServices.DocumentCollectionEventArgs args) => control?.RefreshAvailability();
}
internal sealed class ManualAnnotationControl : WinForms.UserControl
{
private readonly WinForms.FlowLayoutPanel choices = new() { Dock = WinForms.DockStyle.Top, AutoSize = true, FlowDirection = WinForms.FlowDirection.TopDown, WrapContents = false };
private readonly WinForms.Button place = new() { Text = "Place", AutoSize = true };
private readonly WinForms.Button reconnect = new() { Text = "Reconnect / refresh", AutoSize = true };
private readonly WinForms.Button help = new() { Text = "Help", AutoSize = true };
private readonly WinForms.RadioButton absoluteDatum = new() { Text = "Absolute datum", AutoSize = true, Checked = true };
private readonly WinForms.RadioButton relativeDatum = new() { Text = "Relative datum", AutoSize = true };
private readonly WinForms.NumericUpDown datumOffset = new() { DecimalPlaces = 3, Minimum = -100000, Maximum = 100000, Increment = 0.001M, Enabled = false };
private readonly Dictionary<WinForms.RadioButton, ManualAnnotationKind> kinds = new();
public ManualAnnotationControl()
{
Dock = WinForms.DockStyle.Fill; AutoScroll = true;
Add("Line number", ManualAnnotationKind.LineNumber, true); Add("Valve or nozzle", ManualAnnotationKind.ValveOrNozzle); Add("Flow direction", ManualAnnotationKind.FlowDirection);
Add("Instrument position", ManualAnnotationKind.InstrumentPosition); Add("Section elevation", ManualAnnotationKind.SectionElevation); Add("Instrument connection point", ManualAnnotationKind.InstrumentConnection);
Add("AutoCAD block", ManualAnnotationKind.AutoCadBlock); Add("Platform elevation", ManualAnnotationKind.PlatformElevation); Add("Other elevation", ManualAnnotationKind.OtherElevation);
place.Click += (_, _) => Place(); reconnect.Click += (_, _) => Reconnect(); help.Click += (_, _) => OpenHelp();
var datum = new WinForms.FlowLayoutPanel { Dock = WinForms.DockStyle.Top, AutoSize = true }; datum.Controls.Add(absoluteDatum); datum.Controls.Add(relativeDatum); datum.Controls.Add(datumOffset);
absoluteDatum.CheckedChanged += (_, _) => datumOffset.Enabled = relativeDatum.Checked;
relativeDatum.CheckedChanged += (_, _) => datumOffset.Enabled = relativeDatum.Checked;
var actions = new WinForms.FlowLayoutPanel { Dock = WinForms.DockStyle.Top, AutoSize = true }; actions.Controls.Add(place); actions.Controls.Add(reconnect); actions.Controls.Add(help);
Controls.Add(datum); Controls.Add(actions); Controls.Add(choices); RefreshTheme();
}
private void Add(string text, ManualAnnotationKind kind, bool selected = false) { var button = new WinForms.RadioButton { Text = text, AutoSize = true, Checked = selected }; kinds.Add(button, kind); choices.Controls.Add(button); }
public void RefreshAvailability() { var available = AcadApplication.DocumentManager.MdiActiveDocument is not null && PlantApplication.CurrentProject is not null; place.Enabled = available; reconnect.Enabled = available; RefreshTheme(); }
public void Place()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument; if (document is null) return;
var kind = kinds.First(value => value.Key.Checked).Value;
var command = ManualAnnotationCommandRouter.Resolve(kind);
document.SendStringToExecute(command + " ", true, false, false);
RuntimeDiagnostics.Write("ManualAnnotationActionInvoked", commandName: command, resultStatus: kind.ToString(), document: document);
}
public ManualAnnotationState CurrentState() => new(kinds.First(value => value.Key.Checked).Value.ToString(), relativeDatum.Checked ? ManualDatumMode.Relative : ManualDatumMode.Absolute, (double)datumOffset.Value);
private static void Reconnect() => AcadApplication.DocumentManager.MdiActiveDocument?.SendStringToExecute("PXP-ULN ", true, false, false);
private static void OpenHelp()
{
var path = Path.Combine(Path.GetDirectoryName(typeof(ManualAnnotationControl).Assembly.Location)!, "ManualAnnotationHelp.pdf");
if (File.Exists(path)) System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
private void RefreshTheme()
{
var dark = Convert.ToInt16(AcadApplication.GetSystemVariable("COLORTHEME")) == 0;
BackColor = dark ? System.Drawing.Color.FromArgb(92, 92, 92) : System.Drawing.Color.FromArgb(245, 245, 245); ForeColor = dark ? System.Drawing.Color.White : System.Drawing.Color.Black;
foreach (WinForms.Control child in choices.Controls) { child.BackColor = BackColor; child.ForeColor = ForeColor; }
}
}
internal static class ManualBlockInsertionCommand
{
public static void Run(ManualAnnotationState state)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument; if (document is null) return;
var name = document.Editor.GetString(new PromptStringOptions("\nBlock name: ") { AllowSpaces = true }); if (name.Status != PromptStatus.OK || string.IsNullOrWhiteSpace(name.StringResult)) return;
var point = document.Editor.GetPoint("\nInsertion point: "); if (point.Status != PromptStatus.OK) return;
var angle = document.Editor.GetAngle(new PromptAngleOptions("\nRotation <0>: ") { AllowNone = true, BasePoint = point.Value, UseBasePoint = true }); if (angle.Status is not (PromptStatus.OK or PromptStatus.None)) return;
using var documentLock = document.LockDocument(); using var transaction = document.Database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead); if (!blocks.Has(name.StringResult)) { document.Editor.WriteMessage("\nBlock definition not found."); return; }
var space = (BlockTableRecord)transaction.GetObject(document.Database.CurrentSpaceId, OpenMode.ForWrite); var definition = (BlockTableRecord)transaction.GetObject(blocks[name.StringResult], OpenMode.ForRead);
using var reference = new BlockReference(point.Value, definition.ObjectId) { Rotation = angle.Status == PromptStatus.OK ? angle.Value : 0 };
space.AppendEntity(reference); transaction.AddNewlyCreatedDBObject(reference, true);
var regApps = (RegAppTable)transaction.GetObject(document.Database.RegAppTableId, OpenMode.ForRead);
const string appName = "P_REC_ANNOT_MANUAL";
if (!regApps.Has(appName)) { regApps.UpgradeOpen(); using var app = new RegAppTableRecord { Name = appName }; regApps.Add(app); transaction.AddNewlyCreatedDBObject(app, true); }
reference.XData = new ResultBuffer(new TypedValue((int)DxfCode.ExtendedDataRegAppName, appName), new TypedValue((int)DxfCode.ExtendedDataAsciiString, state.Serialize()));
foreach (ObjectId id in definition)
if (transaction.GetObject(id, OpenMode.ForRead) is AttributeDefinition source && !source.Constant)
{ var value = document.Editor.GetString(new PromptStringOptions($"\n{source.Tag} <{source.TextString}>: ") { AllowSpaces = true }); using var attribute = new AttributeReference(); attribute.SetAttributeFromBlock(source, reference.BlockTransform); attribute.TextString = value.Status == PromptStatus.OK ? value.StringResult : source.TextString; reference.AttributeCollection.AppendAttribute(attribute); transaction.AddNewlyCreatedDBObject(attribute, true); }
transaction.Commit(); document.Editor.WriteMessage("\nManual block placed; AutoCAD UNDO is available.");
}
}
@@ -0,0 +1,72 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance;
using PRecAnnot.Abstractions;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Core.Application;
namespace PRecAnnot.Plant3D2026;
// Source port of OrthoAutoAnnotate.Commands.RemoveAnnotations (PXP-RA).
// The modern writer records the viewport identity in PXP-OA XData, allowing
// deterministic removal without depending on the historical named-dictionary scan.
internal static class OrthoAnnotationRemovalCommand
{
private const string ApplicationName = "PXP-OA";
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var options = new PromptEntityOptions("\nSelect Ortho viewport: ");
options.SetRejectMessage("\nA viewport is required.");
options.AddAllowedClass(typeof(Viewport), true);
var selected = document.Editor.GetEntity(options);
if (selected.Status != PromptStatus.OK) return;
var viewport = Resolve(document, selected.ObjectId);
using var documentLock = document.LockDocument();
using var transaction = document.Database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForRead);
var removed = 0;
foreach (ObjectId id in paper)
{
var entity = transaction.GetObject(id, OpenMode.ForRead) as Entity;
if (entity is null || !OwnedByViewport(entity, viewport.Id)) continue;
entity.UpgradeOpen();
entity.Erase();
removed++;
}
transaction.Commit();
document.Editor.WriteMessage($"\nPXP-RA: removed={removed}; native AutoCAD UNDO is available.");
RuntimeDiagnostics.Write("OrthoAnnotationRemovalCompleted", commandName: "PXP-RA", resultStatus: "Completed", document: document, details: new Dictionary<string, object?> { ["Removed"] = removed, ["Viewport"] = viewport.Id });
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("OrthoAnnotationRemovalFailed", "ERROR", commandName: "PXP-RA", resultStatus: exception.Message, exception: exception, document: document);
document.Editor.WriteMessage("\nPXP-RA: " + exception.Message);
}
}
private static OrthoViewportInfo Resolve(Document document, ObjectId selectedId)
{
using var transaction = document.Database.TransactionManager.StartOpenCloseTransaction();
var selected = (Viewport)transaction.GetObject(selectedId, OpenMode.ForRead);
var project = PlantApplication.ProjectPart("Ortho") as OrthoProject ?? throw new InvalidOperationException("ORTHO_PROJECT_UNAVAILABLE");
var matches = new PlantOrthoViewportReader(project, document.Name).ReadViewports(CancellationToken.None)
.Where(x => Math.Abs(x.CenterX - selected.CenterPoint.X) < .001 && Math.Abs(x.CenterY - selected.CenterPoint.Y) < .001 && Math.Abs(x.Width - selected.Width) < .001 && Math.Abs(x.Height - selected.Height) < .001)
.ToArray();
return matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "ORTHO_VIEW_NOT_FOUND" : "ORTHO_VIEW_AMBIGUOUS");
}
private static bool OwnedByViewport(Entity entity, string viewportId)
{
using var data = entity.GetXDataForApplication(ApplicationName);
var values = data?.AsArray().Where(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Select(value => value.Value?.ToString()).ToArray();
return values is { Length: >= 2 } && string.Equals(values[0], "VIEWPORT", StringComparison.Ordinal) && string.Equals(values[1], viewportId, StringComparison.Ordinal);
}
}
@@ -0,0 +1,74 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
internal sealed class OrthoAnnotationWriter
{
private const string ApplicationName = "PXP-OA";
public string Replace(Document document, OrthoViewportInfo viewport, PlantObjectSnapshot source, OrthoAnnotationClassification classification, OrthoAnnotationAssets assets)
{
if (source.Geometry.PaperPosition is not { } point || classification.Representation == OrthoAnnotationRepresentation.None) throw new InvalidOperationException("ORTHO_ANNOTATION_OPERATION_INVALID");
using var documentLock = document.LockDocument(); using var transaction = document.Database.TransactionManager.StartTransaction();
var blocks = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var layers = (LayerTable)transaction.GetObject(document.Database.LayerTableId, OpenMode.ForRead);
var styles = (TextStyleTable)transaction.GetObject(document.Database.TextStyleTableId, OpenMode.ForRead);
var leaders = (DBDictionary)transaction.GetObject(document.Database.MLeaderStyleDictionaryId, OpenMode.ForRead);
if (!layers.Has(assets.Layer)) throw new InvalidOperationException("ORTHO_ANNOTATION_LAYER_MISSING:" + assets.Layer);
if (!styles.Has(assets.TextStyle)) throw new InvalidOperationException("ORTHO_ANNOTATION_TEXT_STYLE_MISSING:" + assets.TextStyle);
if (!leaders.Contains(assets.MultiLeaderStyle)) throw new InvalidOperationException("ORTHO_ANNOTATION_MLEADER_STYLE_MISSING:" + assets.MultiLeaderStyle);
if (classification.Representation == OrthoAnnotationRepresentation.BubbleBlock && !blocks.Has(assets.BubbleBlock)) throw new InvalidOperationException("ORTHO_ANNOTATION_BUBBLE_BLOCK_MISSING:" + assets.BubbleBlock);
EnsureRegApp(transaction, document.Database);
var paper = (BlockTableRecord)transaction.GetObject(blocks[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
var deleted = 0;
foreach (ObjectId id in paper)
{
var entity = transaction.GetObject(id, OpenMode.ForRead) as Entity; if (entity is null || !OwnedBy(entity, source.SnapshotId)) continue;
entity.UpgradeOpen(); entity.Erase(); deleted++;
}
var start = new Point3d(point.X, point.Y, 0); var right = point.X >= viewport.CenterX;
var land = new Point3d(viewport.CenterX + (right ? viewport.Width / 2d + 5d : -viewport.Width / 2d - 5d), point.Y, 0);
if (classification.Representation == OrthoAnnotationRepresentation.MultiLeader)
{
using var leader = new MLeader { LayerId = layers[assets.Layer], MLeaderStyle = leaders.GetAt(assets.MultiLeaderStyle), ContentType = ContentType.MTextContent, LeaderLineType = LeaderType.StraightLeader };
var leaderId = leader.AddLeader(); var lineId = leader.AddLeaderLine(leaderId); leader.AddFirstVertex(lineId, start); leader.AddLastVertex(lineId, land);
using var text = new MText { Contents = classification.Text, TextStyleId = styles[assets.TextStyle], Location = land, BackgroundFill = true, UseBackgroundColor = true, BackgroundScaleFactor = 1.1 };
leader.MText = text; leader.TextLocation = land; leader.XData = Ownership(viewport, source, classification);
paper.AppendEntity(leader); transaction.AddNewlyCreatedDBObject(leader, true);
}
else
{
var definition = (BlockTableRecord)transaction.GetObject(blocks[assets.BubbleBlock], OpenMode.ForRead);
using var reference = new BlockReference(start, definition.ObjectId) { LayerId = layers[assets.Layer], XData = Ownership(viewport, source, classification) };
paper.AppendEntity(reference); transaction.AddNewlyCreatedDBObject(reference, true);
foreach (ObjectId id in definition)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not AttributeDefinition definitionAttribute || definitionAttribute.Constant) continue;
using var attribute = new AttributeReference(); attribute.SetAttributeFromBlock(definitionAttribute, reference.BlockTransform); attribute.TextString = classification.Text; attribute.TextStyleId = styles[assets.TextStyle]; attribute.UpdateMTextAttribute(); reference.AttributeCollection.AppendAttribute(attribute); transaction.AddNewlyCreatedDBObject(attribute, true);
}
if (reference.IsDynamicBlock) foreach (DynamicBlockReferenceProperty property in reference.DynamicBlockReferencePropertyCollection)
{
if (property.ReadOnly) continue;
if (string.Equals(property.PropertyName, "P1 X", StringComparison.OrdinalIgnoreCase)) property.Value = land.X - start.X;
else if (string.Equals(property.PropertyName, "P1 Y", StringComparison.OrdinalIgnoreCase)) property.Value = land.Y - start.Y;
}
}
transaction.Commit(); return deleted == 0 ? "Created" : "Updated";
}
private static bool OwnedBy(Entity entity, string source) => entity.GetXDataForApplication(ApplicationName)?.AsArray().Any(x => x.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(x.Value?.ToString(), source, StringComparison.Ordinal)) == true;
private static ResultBuffer Ownership(OrthoViewportInfo viewport, PlantObjectSnapshot source, OrthoAnnotationClassification value)
{
source.Properties.TryGetValue("PRecAnnot.RowId", out var rowId);
return new ResultBuffer(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "VIEWPORT"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, viewport.Id),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCE"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, source.SnapshotId),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCE_DRAWING"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, viewport.SourceDrawingName ?? string.Empty),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCE_ROW"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, rowId ?? string.Empty),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "ANNOTATIONTEXT"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, value.Text),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, value.Representation.ToString()));
}
private static void EnsureRegApp(Transaction transaction, Database database) { var table = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead); if (table.Has(ApplicationName)) return; table.UpgradeOpen(); using var record = new RegAppTableRecord { Name = ApplicationName }; table.Add(record); transaction.AddNewlyCreatedDBObject(record, true); }
}
@@ -0,0 +1,111 @@
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
namespace PRecAnnot.Plant3D2026;
// Source-backed PXP-DJ mutation boundary. The legacy command dimensions the
// selected PXP-OA annotation blocks, rejects mixed viewport scales and keeps
// all dimension creation in one transaction so native UNDO restores the prior
// state. The Plant-specific candidate discovery remains deliberately typed
// through the PXP-OA ownership contract.
internal static class OrthoDimensionCommand
{
private const string AnnotationApp = "PXP-OA";
private const string DimensionApp = "PXP-DJ";
public static void Run()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
var selection = document.Editor.GetSelection(new SelectionFilter(new[] { new TypedValue((int)DxfCode.ExtendedDataRegAppName, AnnotationApp) }));
if (selection.Status != PromptStatus.OK || selection.Value.Count < 2)
{
document.Editor.WriteMessage("\nSelect at least two PXP-OA annotations.");
return;
}
using var read = document.Database.TransactionManager.StartOpenCloseTransaction();
var points = new List<(ObjectId Id, Point3d Position, string Viewport)>();
foreach (var id in selection.Value.GetObjectIds())
{
if (read.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue;
if (!TryReadViewport(reference, out var viewport)) continue;
points.Add((id, reference.Position, viewport));
}
read.Commit();
if (points.Count < 2 || points.Select(point => point.Viewport).Distinct(StringComparer.Ordinal).Count() != 1)
{
document.Editor.WriteMessage("\nSelected annotations must belong to one Ortho viewport.");
return;
}
points = points.OrderBy(point => point.Position.X).ThenBy(point => point.Position.Y).ThenBy(point => point.Id.Handle.Value).ToList();
var defaultAngle = Math.Abs(points[^1].Position.X - points[0].Position.X) >= Math.Abs(points[^1].Position.Y - points[0].Position.Y) ? 0d : Math.PI / 2d;
var angle = document.Editor.GetAngle(new PromptAngleOptions("\nDimension rotation <automatic>: ") { AllowNone = true });
if (angle.Status is not (PromptStatus.OK or PromptStatus.None)) return;
var rotation = angle.Status == PromptStatus.OK ? angle.Value : defaultAngle;
var offset = document.Editor.GetPoint("\nDimension line location: ");
if (offset.Status != PromptStatus.OK) return;
using var documentLock = document.LockDocument();
using var transaction = document.Database.TransactionManager.StartTransaction();
var table = (BlockTable)transaction.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)transaction.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
EnsureRegApp(transaction, document.Database);
EraseOwnedDimensions(transaction, paper, points[0].Viewport, points.Select(point => point.Id.Handle.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase));
for (var index = 1; index < points.Count; index++)
{
var first = points[index - 1].Position;
var second = points[index].Position;
var dimension = new RotatedDimension(rotation, first, second, offset.Value, string.Empty, document.Database.Dimstyle);
dimension.LayerId = document.Database.Clayer;
dimension.XData = Ownership(points[0].Viewport, points.Select(point => point.Id.Handle.ToString()));
paper.AppendEntity(dimension);
transaction.AddNewlyCreatedDBObject(dimension, true);
}
transaction.Commit();
document.Editor.WriteMessage($"\nCreated {points.Count - 1} Ortho dimensions; native UNDO is available.");
}
private static bool TryReadViewport(BlockReference reference, out string viewport)
{
viewport = string.Empty;
using var data = reference.GetXDataForApplication(AnnotationApp);
var values = data?.AsArray().Where(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Select(value => value.Value?.ToString() ?? string.Empty).ToArray();
if (values is null) return false;
for (var index = 0; index + 1 < values.Length; index++) if (string.Equals(values[index], "VIEWPORT", StringComparison.Ordinal)) { viewport = values[index + 1]; break; }
return !string.IsNullOrWhiteSpace(viewport);
}
private static void EraseOwnedDimensions(Transaction transaction, BlockTableRecord paper, string viewport, HashSet<string> handles)
{
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not Entity entity) continue;
using var data = entity.GetXDataForApplication(DimensionApp);
var values = data?.AsArray().Where(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString).Select(value => value.Value?.ToString() ?? string.Empty).ToArray();
if (values is null || !values.Contains(viewport, StringComparer.Ordinal)) continue;
if (!handles.Any(handle => values.Contains(handle, StringComparer.OrdinalIgnoreCase))) continue;
entity.UpgradeOpen();
entity.Erase();
}
}
private static ResultBuffer Ownership(string viewport, IEnumerable<string> handles) => new(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, DimensionApp),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, viewport),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCES"),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, string.Join(",", handles)));
private static void EnsureRegApp(Transaction transaction, Database database)
{
var table = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead);
if (table.Has(DimensionApp)) return;
table.UpgradeOpen();
using var record = new RegAppTableRecord { Name = DimensionApp };
table.Add(record);
transaction.AddNewlyCreatedDBObject(record, true);
}
}
@@ -0,0 +1,67 @@
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.P3dProjectParts;
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Core.Application;
namespace PRecAnnot.Plant3D2026;
internal static class OrthoMaintenanceCommands
{
public static void CheckViewport()
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return;
try
{
var options = new PromptEntityOptions("\nВыберите видовой экран для проверки: ");
options.SetRejectMessage("\nДолжен быть видовой экран."); options.AddAllowedClass(typeof(Viewport), true);
var selected = document.Editor.GetEntity(options); if (selected.Status != PromptStatus.OK) return;
OrthoViewportInfo view;
ViewportLayerPlan layerPlan;
using (var transaction = document.Database.TransactionManager.StartOpenCloseTransaction())
{
var viewport = (Viewport)transaction.GetObject(selected.ObjectId, OpenMode.ForRead);
var layers = (LayerTable)transaction.GetObject(document.Database.LayerTableId, OpenMode.ForRead);
layerPlan = new ViewportLayerPlanner().Build(viewport.Layer, layers.Has("Defpoints"));
view = Resolve(document, viewport);
}
if (layerPlan.FailureCode is not null) throw new InvalidOperationException(layerPlan.FailureCode);
var objects = new PlantObjectReader().ReadObjects(view, CancellationToken.None);
var resolved = objects.Count(x => x.MembershipStatus == ViewMembershipStatus.Resolved);
var outside = objects.Count(x => x.MembershipStatus == ViewMembershipStatus.ObjectOutsideView);
var unresolved = objects.Count - resolved - outside;
document.Editor.WriteMessage($"\nPXP-COV preview: resolved={resolved}; outside={outside}; unresolved={unresolved}; viewport-layer-change={layerPlan.RequiresChange}. DWG не изменён.");
RuntimeDiagnostics.Write("OrthoViewportCheckCompleted", commandName: "PXP-COV", resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["Resolved"] = resolved, ["Outside"] = outside, ["Unresolved"] = unresolved, ["LayerChange"] = layerPlan.RequiresChange });
if (!layerPlan.RequiresChange || !string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase)) return;
ValidateFixture(document);
using var documentLock = document.LockDocument();
using var write = document.Database.TransactionManager.StartTransaction();
var target = (Viewport)write.GetObject(selected.ObjectId, OpenMode.ForWrite);
var layerTable = (LayerTable)write.GetObject(document.Database.LayerTableId, OpenMode.ForRead);
target.LayerId = layerTable[layerPlan.TargetLayer];
write.Commit();
document.Editor.WriteMessage("\nPXP-COV Apply: viewport moved to Defpoints; AutoCAD UNDO is available.");
}
catch (Exception exception) { RuntimeDiagnostics.Write("OrthoViewportCheckFailed", "ERROR", commandName: "PXP-COV", resultStatus: exception.Message, exception: exception, document: document); document.Editor.WriteMessage("\nPXP-COV: " + exception.Message); }
}
private static OrthoViewportInfo Resolve(Document document, Viewport selected)
{
var project = PlantApplication.ProjectPart("Ortho") as OrthoProject ?? throw new InvalidOperationException("ORTHO_PROJECT_UNAVAILABLE");
var matches = new PlantOrthoViewportReader(project, document.Name).ReadViewports(CancellationToken.None)
.Where(x => Math.Abs(x.CenterX - selected.CenterPoint.X) < .001 && Math.Abs(x.CenterY - selected.CenterPoint.Y) < .001 && Math.Abs(x.Width - selected.Width) < .001 && Math.Abs(x.Height - selected.Height) < .001).ToArray();
return matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "ORTHO_VIEW_NOT_FOUND" : "ORTHO_VIEW_AMBIGUOUS");
}
private static void ValidateFixture(Document document)
{
var marker = Environment.GetEnvironmentVariable("P_REC_ANNOT_ORTHO_MAINTENANCE_FIXTURE_MARKER");
if (string.IsNullOrWhiteSpace(marker) || !Path.IsPathFullyQualified(marker) || !File.Exists(marker) || !string.Equals(File.ReadAllText(marker).Trim(), "P_REC_ANNOT_TEST_FIXTURE=ORTHO_MAINTENANCE_APPLY", StringComparison.Ordinal)) throw new InvalidOperationException("ORTHO_MAINTENANCE_TEST_FIXTURE_REQUIRED");
var root = Path.GetDirectoryName(Path.GetFullPath(marker))! + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(document.Name).StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("ORTHO_MAINTENANCE_TEST_FIXTURE_REQUIRED");
}
}
@@ -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];
}

Some files were not shown because too many files have changed in this diff Show More