feat: expand p-rec-annot ortho annotation workflows

This commit is contained in:
codex
2026-08-07 12:51:54 +03:00
parent c57c844f78
commit 548c73cb27
8 changed files with 179 additions and 2 deletions
@@ -155,6 +155,10 @@ public sealed record ComponentGeometryDefinition(
bool ImperialDimensions); bool ImperialDimensions);
public sealed record ComponentGeometryReadResult(bool Success, ComponentGeometryDefinition? Geometry, string? FailureCode = null); public sealed record ComponentGeometryReadResult(bool Success, ComponentGeometryDefinition? Geometry, string? FailureCode = null);
public sealed record ViewportLayerPlan(bool RequiresChange, string TargetLayer, 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 interface IPlantComponentGeometryReader public interface IPlantComponentGeometryReader
{ {
ComponentGeometryReadResult Read(string stableEntityId, bool convertToMetric); ComponentGeometryReadResult Read(string stableEntityId, bool convertToMetric);
@@ -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);
}
@@ -27,6 +27,8 @@ internal static class AutoAnnotateViewCommand
viewport = matches.Length == 1 ? matches[0] : throw new InvalidOperationException(matches.Length == 0 ? "AUTO_ANNOTATE_VIEW_NOT_FOUND" : "AUTO_ANNOTATE_VIEW_AMBIGUOUS"); 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 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) var permit = string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE"), "Apply", StringComparison.OrdinalIgnoreCase)
? FirstLimitedApplyPermitReader.ReadFor(document) : null; ? FirstLimitedApplyPermitReader.ReadFor(document) : null;
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
@@ -35,9 +37,24 @@ internal static class AutoAnnotateViewCommand
LimitedApplyTargetSnapshotId = permit?.TargetSnapshotId, LimitedApplyTargetSnapshotId = permit?.TargetSnapshotId,
LimitedApplyRuleId = permit is null ? null : FirstLimitedApplyPlanner.RuleId LimitedApplyRuleId = permit is null ? null : FirstLimitedApplyPlanner.RuleId
}); });
document.Editor.WriteMessage($"\nPXP-AAV preview: examined={plan.Summary.ObjectsExamined}; create={plan.Summary.CreateCount}; update={plan.Summary.UpdateCount}; skip={plan.Summary.SkipCount}; conflicts={plan.Summary.ConflictCount}; PlanId={plan.PlanId}. DWG не изменён."); var leaders = classifications.Count(x => x.Representation == OrthoAnnotationRepresentation.MultiLeader);
RuntimeDiagnostics.Write("AutoAnnotateViewPreviewCompleted", commandName: "PXP-AAV", resultStatus: "PreviewCompleted", document: document, details: new Dictionary<string, object?> { ["PlanId"] = plan.PlanId, ["ObjectsExamined"] = plan.Summary.ObjectsExamined }); 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 });
if (permit is null) return; 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 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 preflight = new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, plan, viewport, objects, corePermit);
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document, preflight.Operation, permit.VisibilityState, permit.AnnotationTemplateDwgPath); var outcome = new FirstLimitedPipeAnnotationWriter().Write(document, preflight.Operation, permit.VisibilityState, permit.AnnotationTemplateDwgPath);
@@ -228,6 +228,8 @@ public sealed class EntryPoint : IExtensionApplication
var membershipPartial = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.PartiallyResolved); 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 membershipUnresolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.NotResolved);
var membershipOutside = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.ObjectOutsideView); 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; var planId = plans.Count == 1 ? plans[0].PlanId : null;
details["ObjectsExamined"] = summary.ObjectsExamined; details["ObjectsExamined"] = summary.ObjectsExamined;
details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion; details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
@@ -236,6 +238,8 @@ public sealed class EntryPoint : IExtensionApplication
details["MembershipPartial"] = membershipPartial; details["MembershipPartial"] = membershipPartial;
details["MembershipUnresolved"] = membershipUnresolved; details["MembershipUnresolved"] = membershipUnresolved;
details["MembershipOutsideView"] = membershipOutside; 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["PlanCreateCount"] = summary.CreateCount;
details["PlanUpdateCount"] = summary.UpdateCount; details["PlanUpdateCount"] = summary.UpdateCount;
details["PlanSkipCount"] = summary.SkipCount; details["PlanSkipCount"] = summary.SkipCount;
@@ -0,0 +1,63 @@
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.Id, source.SnapshotId, 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.Id, source.SnapshotId, 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(string viewport, string source, OrthoAnnotationClassification value) => new(new TypedValue((int)DxfCode.ExtendedDataRegAppName, ApplicationName), new TypedValue((int)DxfCode.ExtendedDataAsciiString, "VIEWPORT"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, viewport), new TypedValue((int)DxfCode.ExtendedDataAsciiString, "SOURCE"), new TypedValue((int)DxfCode.ExtendedDataAsciiString, source), 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,23 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Core.Tests;
public sealed class OrthoAnnotationClassifierTests
{
private static readonly OrthoAnnotationOptions All = new(true, true, true, true, true);
[Theory]
[InlineData("Valve", "Annotation", "V-1", OrthoAnnotationRepresentation.MultiLeader, "V-1")]
[InlineData("Valve", "Annotation", "PIC$101", OrthoAnnotationRepresentation.BubbleBlock, "PIC\\P101")]
[InlineData("InlineInstrument", "Tag", "TI$12", OrthoAnnotationRepresentation.BubbleBlock, "TI\\P12")]
[InlineData("Plug", "TieInNumber", "T-4", OrthoAnnotationRepresentation.MultiLeader, "Для подключения\\Pпоз. T-4")]
public void PreservesLegacyClassAndDelimiterRules(string type, string field, string value, OrthoAnnotationRepresentation representation, string text)
{
var result = new OrthoAnnotationClassifier().Classify(Source(type, new Dictionary<string, string?> { [field] = value }), All);
Assert.Equal(representation, result.Representation); Assert.Equal(text, result.Text);
}
[Fact]
public void RequiresNonstandardSupportTitle() => Assert.Equal(OrthoAnnotationRepresentation.None, new OrthoAnnotationClassifier().Classify(Source("Support", new Dictionary<string, string?> { ["Title"] = "STANDARD", ["Annotation"] = "S1" }), All).Representation);
private static PlantObjectSnapshot Source(string type, IReadOnlyDictionary<string, string?> properties) => new() { SnapshotId = "x", ObjectClass = type, Geometry = new(), Properties = properties };
}
@@ -0,0 +1,7 @@
using PRecAnnot.Core;
namespace PRecAnnot.Core.Tests;
public sealed class OrthoAnnotationSettingsLoaderTests
{
[Fact] public void ReadsLegacyAnnotationAssets() { var value = new OrthoAnnotationSettingsLoader().Load("<Main><Annotations layer='A' textstyle='T' multileaderstyle='M'/></Main>"); Assert.Equal("A", value.Layer); Assert.Equal("M", value.MultiLeaderStyle); }
[Fact] public void RejectsMissingStyle() => Assert.Throws<InvalidDataException>(() => new OrthoAnnotationSettingsLoader().Load("<Main><Annotations layer='A' textstyle='T'/></Main>"));
}