diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 38762d2..69e3649 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -68,6 +68,8 @@ public sealed record ElevationCandidate(string StableId, double ModelElevation, public sealed record ExistingElevationAnnotation(string StableId, string SourceStableId, string Text, AnnotationPoint Position); public sealed record ElevationAnnotationOperation(string StableId, AnnotationOperationKind Kind, string? Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyList Reasons); public sealed record ElevationAnnotationPlan(IReadOnlyList Operations); +public sealed record LineConnectionCandidate(string StableId, string LineNumber, Point3Dto FirstEndpoint, Point3Dto SecondEndpoint, Bounds3Dto Bounds, AnnotationPoint Position, double Rotation); +public sealed record LineConnectionGroup(string LineNumber, IReadOnlyList Members, AnnotationPoint Placement, double Rotation); public sealed record LineNumberTextAsset(string Layer, string TextStyle, double Size, string TextValue, Point2Dto Position); public sealed record LineNumberLineAsset(string Layer, Point2Dto Start, Point2Dto End); public sealed record LineNumberMarkerAsset(string Layer, double MaskScaleFactor, IReadOnlyList Texts, IReadOnlyList Lines); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberConnectionPlanner.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberConnectionPlanner.cs new file mode 100644 index 0000000..3a5e0d6 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberConnectionPlanner.cs @@ -0,0 +1,42 @@ +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +public sealed class LineNumberConnectionPlanner +{ + public IReadOnlyList Build(IEnumerable candidates, double tolerance) + { + if (tolerance < 0 || double.IsNaN(tolerance) || double.IsInfinity(tolerance)) throw new ArgumentOutOfRangeException(nameof(tolerance)); + return candidates + .Where(value => !string.IsNullOrWhiteSpace(value.LineNumber)) + .OrderBy(value => value.StableId, StringComparer.Ordinal) + .GroupBy(value => value.LineNumber, StringComparer.OrdinalIgnoreCase) + .Select(group => BuildGroup(group.Key, group.ToArray(), tolerance)) + .Where(group => group is not null) + .Cast() + .OrderBy(group => group.LineNumber, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static LineConnectionGroup? BuildGroup(string number, LineConnectionCandidate[] members, double tolerance) + { + if (members.Length < 2) return null; + var connected = members.Where(first => members.Any(second => !ReferenceEquals(first, second) && Connected(first, second, tolerance))).OrderBy(value => value.StableId, StringComparer.Ordinal).ToArray(); + if (connected.Length < 2) return null; + var anchor = connected[0]; + return new(number, connected, anchor.Position, anchor.Rotation); + } + + private static bool Connected(LineConnectionCandidate first, LineConnectionCandidate second, double tolerance) + { + if (Distance(first.FirstEndpoint, second.FirstEndpoint) <= tolerance || Distance(first.FirstEndpoint, second.SecondEndpoint) <= tolerance || Distance(first.SecondEndpoint, second.FirstEndpoint) <= tolerance || Distance(first.SecondEndpoint, second.SecondEndpoint) <= tolerance) + return true; + return ExtentsIntersectionClassifier.Classify(first.Bounds, second.Bounds) is not ExtentsIntersectionType.DoNotIntersect; + } + + private static double Distance(Point3Dto first, Point3Dto second) + { + var x = first.X - second.X; var y = first.Y - second.Y; var z = first.Z - second.Z; + return Math.Sqrt(x * x + y * y + z * z); + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberConnectionPlannerTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberConnectionPlannerTests.cs new file mode 100644 index 0000000..4f0b856 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberConnectionPlannerTests.cs @@ -0,0 +1,29 @@ +using PRecAnnot.Abstractions; +using PRecAnnot.Core; + +namespace PRecAnnot.Core.Tests; + +public sealed class LineNumberConnectionPlannerTests +{ + [Fact] + public void GroupsConnectedSameLineCandidatesInStableOrder() + { + var a = Candidate("a", "L-1", new(0, 0, 0), new(1, 0, 0)); + var b = Candidate("b", "l-1", new(1.05, 0, 0), new(2, 0, 0)); + var c = Candidate("c", "L-2", new(0, 0, 0), new(1, 0, 0)); + var result = new LineNumberConnectionPlanner().Build([b, c, a], .1); + var group = Assert.Single(result); + Assert.Equal("L-1", group.LineNumber); + Assert.Equal(new[] { "a", "b" }, group.Members.Select(value => value.StableId)); + } + + [Fact] + public void DoesNotGroupDisjointOrSingleLineCandidates() + { + var a = Candidate("a", "L-1", new(0, 0, 0), new(1, 0, 0)); + var b = Candidate("b", "L-1", new(10, 0, 0), new(11, 0, 0)); + Assert.Empty(new LineNumberConnectionPlanner().Build([a, b], .1)); + } + + private static LineConnectionCandidate Candidate(string id, string line, Point3Dto first, Point3Dto second) => new(id, line, first, second, new(new(Math.Min(first.X, second.X), Math.Min(first.Y, second.Y), Math.Min(first.Z, second.Z)), new(Math.Max(first.X, second.X), Math.Max(first.Y, second.Y), Math.Max(first.Z, second.Z))), new(first.X, first.Y), 0); +}