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
@@ -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;
}