feat: scaffold P_Rec_Annot .NET 8 architecture

This commit is contained in:
codex
2026-07-31 09:57:53 +03:00
parent 34222cedc8
commit 60b3934cd2
30 changed files with 482 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
artifacts/
bin/
obj/
.vs/
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<DebugType>portable</DebugType>
<Plant3D2026InstallDir Condition="'$(Plant3D2026InstallDir)' == ''">$(ProgramFiles)\Autodesk\AutoCAD 2026</Plant3D2026InstallDir>
</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.1.0" 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,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.
+3
View File
@@ -0,0 +1,3 @@
# Build
SDK 8.0.423 is installed. Plant 3D 2026 is not installed on the audit machine, so Autodesk references are currently absent and the adapter is a deliberate blocker. Build Core/tests independently first; after installation pass `Plant3D2026InstallDir` and validate exact assembly/member compatibility. No Autodesk DLL is copied to Git or bundle. `SeriesMin`, `SeriesMax` and the exact platform token are intentionally absent from the manifest until verified against the installed product and official package contract.
@@ -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,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,3 @@
# Migration dashboard
The active command set is mapped in `command-migration-matrix.md`; all currently published target descriptors are `BLOCKED_WITH_REASON` because Plant 3D 2026 assemblies/runtime are unavailable. Coverage is not reported as a percentage until every active use case has a runtime result. Current status: mapped 3 foundation descriptors, implemented Core planning/registry/telemetry contracts, runtime verified 0, native replacements approved 0, retirements approved 0, blockers 3.
@@ -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,3 @@
# Runtime test plan
On a controlled Plant 3D 2026 test machine: install the bundle outside production, start Plant, verify exactly one Ribbon tab/panel, open a test project and Ortho DWG, run `PHP_REC_ANNOT`, inspect dry-run counts, confirm one transaction, save/reopen/audit, repeat command, test no duplicates and Undo, then compare entities/text/layers/positions/links with the 2019 golden sample. This plan is prepared but not executed.
@@ -0,0 +1,95 @@
namespace PRecAnnot.Abstractions;
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 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<PlantObjectData> ReadObjects(OrthoViewportRef 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,48 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
{
public AnnotationPlan BuildPlan(AnnotationRequest request)
{
var existing = request.Existing.ToDictionary(x => x.StableKey, StringComparer.OrdinalIgnoreCase);
var create = new List<AnnotationIntent>();
var update = new List<AnnotationIntent>();
var skipped = new List<string>();
foreach (var item in request.Objects.OrderBy(x => x.ObjectId, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(item.LineNumber) && string.IsNullOrWhiteSpace(item.Tag))
{
skipped.Add($"{item.ObjectId}:missing-line-number-or-tag");
continue;
}
var text = BuildText(item);
var intent = new AnnotationIntent
{
StableKey = $"{request.Viewport.DrawingId}:{request.Viewport.ViewportId}:{item.ObjectId}",
Text = text,
Position = item.Position,
Rotation = item.Rotation,
ClassName = item.ClassName,
Layer = "PXP_ANNOTATION"
};
if (existing.ContainsKey(intent.StableKey)) update.Add(intent); else create.Add(intent);
}
return new AnnotationPlan
{
Create = create,
Update = update,
Delete = Array.Empty<string>(),
SkipReasons = skipped,
ExaminedCount = request.Objects.Count
};
}
private static string BuildText(PlantObjectData item) =>
string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag!.Trim() : item.LineNumber.Trim();
}
@@ -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,15 @@
namespace PRecAnnot.Plant3D2026;
/// <summary>
/// Autodesk-facing composition-root lifecycle. The concrete IExtensionApplication
/// implementation is enabled only after the Plant 3D 2026 SDK reference contract
/// is verified on the target installation.
/// </summary>
public sealed class EntryPointLifecycle : IDisposable
{
private int _initialized;
public bool IsInitialized => Volatile.Read(ref _initialized) == 1;
public bool Initialize() => Interlocked.Exchange(ref _initialized, 1) == 0;
public void Terminate() => Interlocked.Exchange(ref _initialized, 0);
public void Dispose() => Terminate();
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><PlatformTarget>x64</PlatformTarget><UseWindowsForms>true</UseWindowsForms></PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" />
<ProjectReference Include="..\PRecAnnot.Application\PRecAnnot.Application.csproj" />
<ProjectReference Include="..\PRecAnnot.Core\PRecAnnot.Core.csproj" />
</ItemGroup>
<ItemGroup Condition="Exists('$(Plant3D2026InstallDir)')">
<Reference Include="AcMgd"><HintPath>$(Plant3D2026InstallDir)\AcMgd.dll</HintPath><Private>false</Private></Reference>
<Reference Include="AcDbMgd"><HintPath>$(Plant3D2026InstallDir)\AcDbMgd.dll</HintPath><Private>false</Private></Reference>
<Reference Include="AcCoreMgd"><HintPath>$(Plant3D2026InstallDir)\AcCoreMgd.dll</HintPath><Private>false</Private></Reference>
<Reference Include="PnPProjectManagerMgd"><HintPath>$(Plant3D2026InstallDir)\PLNT3D\PnPProjectManagerMgd.dll</HintPath><Private>false</Private></Reference>
<Reference Include="PnPDataLinks"><HintPath>$(Plant3D2026InstallDir)\PLNT3D\PnPDataLinks.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
namespace PRecAnnot.Plant3D2026;
public sealed record Plant3D2026Availability(bool IsAvailable, string InstallDirectory, IReadOnlyList<string> MissingAssemblies)
{
public static Plant3D2026Availability Detect(string installDirectory)
{
var expected = new[] { "AcMgd.dll", "AcDbMgd.dll", "AcCoreMgd.dll", Path.Combine("PLNT3D", "PnPProjectManagerMgd.dll"), Path.Combine("PLNT3D", "PnPDataLinks.dll") };
var missing = expected.Where(x => !File.Exists(Path.Combine(installDirectory, x))).ToArray();
return new(missing.Length == 0, installDirectory, missing);
}
}
public sealed class Plant3D2026BlockedException(Plant3D2026Availability availability)
: InvalidOperationException($"Plant 3D 2026 assemblies are unavailable at '{availability.InstallDirectory}'. Missing: {string.Join(", ", availability.MissingAssemblies)}") { }
@@ -0,0 +1,14 @@
using System.Text.Json;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Telemetry;
public sealed class JsonlUsageSink(string path) : ICommandUsageSink
{
private readonly object _gate = new();
public void Record(CommandUsageEvent usageEvent)
{
var json = JsonSerializer.Serialize(usageEvent);
lock (_gate) File.AppendAllText(path, json + Environment.NewLine);
}
}
@@ -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,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><UseWindowsForms>true</UseWindowsForms></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Application\PRecAnnot.Application.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,17 @@
using PRecAnnot.Application;
namespace PRecAnnot.UI;
public sealed class RibbonRegistration
{
private readonly HashSet<string> _registered = new(StringComparer.OrdinalIgnoreCase);
public bool Register(CommandRegistry registry)
{
var changed = false;
foreach (var item in registry.Commands)
changed |= _registered.Add($"{item.RibbonPanel}:{item.CommandName}");
return changed;
}
public void Unregister(CommandRegistry registry) { foreach (var item in registry.Commands) _registered.Remove($"{item.RibbonPanel}:{item.CommandName}"); }
public int RegisteredCount => _registered.Count;
}
@@ -0,0 +1,37 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Application;
using PRecAnnot.Core;
using PRecAnnot.UI;
namespace PRecAnnot.Core.Tests;
public sealed class FoundationTests
{
[Fact] public void Plan_IsDeterministic()
{
var engine = new AnnotationRuleEngine();
var request = new AnnotationRequest { Viewport = new("d", "v"), Objects = new[] { new PlantObjectData { ObjectId = "2", ClassName = "Pipe", LineNumber = "L-2", Position = new(2, 2) }, new PlantObjectData { ObjectId = "1", ClassName = "Pipe", LineNumber = "L-1", Position = new(1, 1) } }, Existing = Array.Empty<ExistingAnnotation>() };
var plan = engine.BuildPlan(request);
Assert.Equal(new[] { "1", "2" }, plan.Create.Select(x => x.StableKey.Split(':').Last()));
}
[Fact] public void ExistingAnnotation_IsUpdatedNotDuplicated()
{
var item = new PlantObjectData { ObjectId = "1", ClassName = "Valve", Tag = "V-1", Position = new(0, 0) };
var plan = new AnnotationRuleEngine().BuildPlan(new AnnotationRequest { Viewport = new("d", "v"), Objects = new[] { item }, Existing = new[] { new ExistingAnnotation("d:v:1", "old", new(0, 0)) } });
Assert.Empty(plan.Create); Assert.Single(plan.Update);
}
[Fact] public void CoreContracts_DoNotExposeAutodeskTypes()
{
var names = typeof(AnnotationRequest).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray();
Assert.DoesNotContain(names, x => x is not null && x.StartsWith("Ac", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(names, x => x is not null && x.StartsWith("Autodesk", StringComparison.OrdinalIgnoreCase));
}
[Fact] public void RibbonRegistration_IsIdempotent()
{
var ribbon = new RibbonRegistration(); var registry = CommandRegistry.Foundation();
Assert.True(ribbon.Register(registry)); Assert.False(ribbon.Register(registry)); Assert.Equal(3, ribbon.RegisteredCount);
}
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\PRecAnnot.Core\PRecAnnot.Core.csproj" /><ProjectReference Include="..\..\src\PRecAnnot.Application\PRecAnnot.Application.csproj" /><ProjectReference Include="..\..\src\PRecAnnot.UI\PRecAnnot.UI.csproj" /></ItemGroup>
<ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /><PackageReference Include="xunit" Version="2.5.3" /><PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /></ItemGroup>
<ItemGroup><Using Include="Xunit" /></ItemGroup>
</Project>
@@ -0,0 +1,4 @@
param([ValidateSet('Debug','Release')][string]$Configuration='Release',[string]$Plant3D2026InstallDir)
$ErrorActionPreference='Stop'
dotnet build (Join-Path $PSScriptRoot '..\PRecAnnot2026.sln') --configuration $Configuration /p:Plant3D2026InstallDir=$Plant3D2026InstallDir
Write-Host 'Bundle assembly build completed; deployment is intentionally not performed.'
@@ -0,0 +1,8 @@
$ErrorActionPreference='Stop'
$package=Join-Path $PSScriptRoot '..\bundle\P_Rec_Annot.bundle\PackageContents.xml'
[xml]$xml=Get-Content -LiteralPath $package
$entry=$xml.ApplicationPackage.Components.ComponentEntry
if(@($entry).Count -ne 1){throw 'Bundle must have exactly one entry assembly.'}
if($entry.ModuleName -match '_netload|OrthoLoader|Settings.xml'){throw 'Legacy loading chain is forbidden.'}
if($entry.ModuleName -notmatch 'PRecAnnot\.Plant3D2026\.dll'){throw 'Unexpected entry assembly.'}
Write-Host 'Bundle manifest is structurally valid.'