diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 839c2f1..a198c2f 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -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 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 Views); +public sealed record OrthoBatchSelection(string RelativePath, IReadOnlyList Views); +public sealed record OrthoBatchOperation(string DrawingGuid, string DisplayName, string RelativePath, string ViewName); +public sealed record OrthoBatchPlan(IReadOnlyList Operations, IReadOnlyList 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); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoBatchPlanner.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoBatchPlanner.cs new file mode 100644 index 0000000..f729b1c --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoBatchPlanner.cs @@ -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 drawings, IEnumerable selections) + { + var diagnostics = new List(); var operations = new List(); + 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 != ".")); + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoLayerSettingsLoader.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoLayerSettingsLoader.cs new file mode 100644 index 0000000..e88e0e6 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/OrthoLayerSettingsLoader.cs @@ -0,0 +1,25 @@ +using System.Xml.Linq; +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +public sealed class OrthoLayerSettingsLoader +{ + public IReadOnlyList 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); +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoBatchPlannerTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoBatchPlannerTests.cs new file mode 100644 index 0000000..5010f1e --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoBatchPlannerTests.cs @@ -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(() => OrthoBatchPlanner.Normalize("..\\x.dwg")); + Assert.Throws(() => OrthoBatchPlanner.Normalize("C:\\x.dwg")); + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoLayerSettingsLoaderTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoLayerSettingsLoaderTests.cs new file mode 100644 index 0000000..e8a8e67 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/OrthoLayerSettingsLoaderTests.cs @@ -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("
"); + 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(() => new OrthoLayerSettingsLoader().Load("
")); +}