feat: port p-rec-annot ortho batch and layer rules

This commit is contained in:
codex
2026-08-07 13:09:50 +03:00
parent 548c73cb27
commit aaec3f93f2
5 changed files with 113 additions and 0 deletions
@@ -159,6 +159,16 @@ 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);
@@ -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,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,32 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Core.Tests;
public sealed class OrthoBatchPlannerTests
{
[Fact]
public void KeepsSameNameDrawingsDistinctByRelativePath()
{
var plan = new OrthoBatchPlanner().Build([
new("g2", "A.dwg", "Ortho\\Area2\\A.dwg", ["V2"]),
new("g1", "A.dwg", "Ortho\\Area1\\A.dwg", ["V1"])],
[new("Ortho/Area2/A.dwg", ["V2"]), new("Ortho\\Area1\\A.dwg", ["V1"])]);
Assert.Collection(plan.Operations, x => Assert.Equal("g1", x.DrawingGuid), x => Assert.Equal("g2", x.DrawingGuid));
Assert.Empty(plan.Diagnostics);
}
[Fact]
public void IsolatesMissingDrawingAndViewFailures()
{
var plan = new OrthoBatchPlanner().Build([new("g", "A", "Ortho\\A.dwg", ["V1"])], [new("Ortho\\A.dwg", ["missing", "V1"]), new("Ortho\\B.dwg", ["V2"])]);
Assert.Single(plan.Operations); Assert.Equal(2, plan.Diagnostics.Count);
}
[Fact]
public void RejectsEscapingAndAbsolutePaths()
{
Assert.Throws<ArgumentException>(() => OrthoBatchPlanner.Normalize("..\\x.dwg"));
Assert.Throws<ArgumentException>(() => OrthoBatchPlanner.Normalize("C:\\x.dwg"));
}
}
@@ -0,0 +1,12 @@
using PRecAnnot.Abstractions;
using PRecAnnot.Core;
namespace PRecAnnot.Core.Tests;
public sealed class OrthoLayerSettingsLoaderTests
{
[Fact] public void ParsesStatusLayerAndRgbRules()
{
var rules = new OrthoLayerSettingsLoader().Load("<Main><LayersSetup><LayerSetup filtertype='Status' filtervalue='Reserved' lineweight='30' linetype='Continuous' color='1'/><LayerSetup filtertype='Layer' filtervalue='Axis' lineweight='bad' linetype='' color='1,2,3'/></LayersSetup></Main>");
Assert.True(rules[0].MatchesStatus("reserved")); Assert.Equal(30, rules[0].LineWeight); Assert.Equal((byte)2, rules[1].Green); Assert.True(rules[1].MatchesLayer("V1", "v1-axis"));
}
[Fact] public void RejectsOldOrEmptyShape() => Assert.Throws<InvalidDataException>(() => new OrthoLayerSettingsLoader().Load("<Main><LayersSetup/></Main>"));
}