diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 14f2939..38762d2 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -64,6 +64,10 @@ public sealed record LineNumberApplyResult(int Created, int Updated, int Unchang public sealed record LineNumberListApplyPreflight(string StableId, AnnotationPoint Position, double Rotation, IReadOnlyList Rows); public sealed record LineNumberPreviewResult(bool Success, LineNumberCommandPlan? Plan, IReadOnlyList Apply, string? FailureCode = null); public sealed record LineNumberCandidateReadResult(bool Success, LineNumberCandidate? Candidate, string? FailureCode = null); +public sealed record ElevationCandidate(string StableId, double ModelElevation, double DatumOffset, AnnotationPoint Position, double Rotation, string Layer, string TextStyle); +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 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/ElevationAnnotationPlanner.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/ElevationAnnotationPlanner.cs new file mode 100644 index 0000000..ef3512e --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/ElevationAnnotationPlanner.cs @@ -0,0 +1,39 @@ +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +public sealed class ElevationAnnotationPlanner +{ + public ElevationAnnotationPlan Build(IEnumerable candidates, IEnumerable existing) + { + var known = existing.GroupBy(value => value.SourceStableId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var operations = new List(); + foreach (var candidate in candidates.OrderBy(value => value.StableId, StringComparer.Ordinal)) + { + if (!seen.Add(candidate.StableId)) + { + operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_CANDIDATE"])); + continue; + } + if (string.IsNullOrWhiteSpace(candidate.Layer) || string.IsNullOrWhiteSpace(candidate.TextStyle) || double.IsNaN(candidate.ModelElevation) || double.IsInfinity(candidate.ModelElevation)) + { + operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ELEVATION_CANDIDATE_INVALID"])); + continue; + } + var text = LegacyElevationFormatter.Calculate(candidate.ModelElevation, candidate.DatumOffset); + var matches = known.GetValueOrDefault(candidate.StableId, []); + if (matches.Length > 1) + { + operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_EXISTING_ANNOTATION"])); + continue; + } + if (matches.Length == 1 && string.Equals(matches[0].Text, text, StringComparison.Ordinal) && matches[0].Position == candidate.Position) + operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ALREADY_CURRENT"])); + else + operations.Add(new(candidate.StableId, matches.Length == 1 ? AnnotationOperationKind.Update : AnnotationOperationKind.Create, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, Array.Empty())); + } + return new(operations); + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/ElevationAnnotationPlannerTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/ElevationAnnotationPlannerTests.cs new file mode 100644 index 0000000..ce80fce --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/ElevationAnnotationPlannerTests.cs @@ -0,0 +1,28 @@ +using PRecAnnot.Abstractions; +using PRecAnnot.Core; + +namespace PRecAnnot.Core.Tests; + +public sealed class ElevationAnnotationPlannerTests +{ + [Fact] + public void PlansCreateUpdateAlreadyCurrentAndConflictsDeterministically() + { + var a = new ElevationCandidate("a", 5, 0, new(0, 0), 0, "PXP", "GOSTC"); + var b = new ElevationCandidate("b", 10, 0, new(1, 0), 0, "PXP", "GOSTC"); + var plan = new ElevationAnnotationPlanner().Build([b, a, a], [new("old-a", "a", "+0,005", new(0, 0)), new("old-b1", "b", "+0,010", new()), new("old-b2", "b", "+0,010", new())]); + Assert.Collection(plan.Operations, + value => Assert.Equal(AnnotationOperationKind.Skip, value.Kind), + value => Assert.Equal(AnnotationOperationKind.Conflict, value.Kind), + value => Assert.Equal(AnnotationOperationKind.Conflict, value.Kind)); + Assert.Equal("ALREADY_CURRENT", plan.Operations[0].Reasons.Single()); + } + + [Fact] + public void PlansUpdateWhenTheFormattedElevationChanged() + { + var plan = new ElevationAnnotationPlanner().Build([new ElevationCandidate("a", 13, 0, new(), 0, "PXP", "GOSTC")], [new("old", "a", "+0,010", new())]); + Assert.Equal(AnnotationOperationKind.Update, plan.Operations.Single().Kind); + Assert.Equal("+0,015", plan.Operations.Single().Text); + } +}