diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 6ad136a..a5cfabd 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -80,8 +80,23 @@ public sealed record LineConnectionBoundaryCandidate( Point3Dto SecondEndpoint, Bounds3Dto Bounds, AnnotationPoint Position, - double Rotation); + double Rotation, + string? FirstLineGroupStableId = null, + string? SecondLineGroupStableId = null); public sealed record LineConnectionBoundaryPlan(string StableId, string Text, AnnotationPoint Position, double Rotation, IReadOnlyList Reasons); +// The geometry adapter supplies the paper-space connector point and orientation. +// Keeping it separate from the PnP relation snapshot prevents Autodesk objects +// from crossing into the deterministic PXP-ALC planning layer. +public sealed record LineConnectionPlacement( + Point3Dto FirstEndpoint, + Point3Dto SecondEndpoint, + Bounds3Dto Bounds, + AnnotationPoint Position, + double Rotation); +public sealed record LineConnectionPreviewResult( + bool Success, + IReadOnlyList Plans, + string? FailureCode = null); public sealed record PlantConnectorRelationshipSnapshot( string StableId, long ConnectorRowId, diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionBoundaryPlanner.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionBoundaryPlanner.cs index b4179aa..7de3d63 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionBoundaryPlanner.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionBoundaryPlanner.cs @@ -20,6 +20,16 @@ public sealed class LineConnectionBoundaryPlanner } var first = candidate.FirstLineNumber?.Trim() ?? string.Empty; var second = candidate.SecondLineNumber?.Trim() ?? string.Empty; + // The legacy command gates insertion on the two P3dLineGroup + // relationship IDs, not just on their rendered text. Equal text in + // distinct groups remains a real connector boundary; equal group IDs + // never does. + if (!string.IsNullOrWhiteSpace(candidate.FirstLineGroupStableId) && + string.Equals(candidate.FirstLineGroupStableId, candidate.SecondLineGroupStableId, StringComparison.OrdinalIgnoreCase)) + { + result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["SAME_LINE_GROUP"])); + continue; + } if (first.Length == 0 || second.Length == 0) { result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["LINE_GROUP_NOT_RESOLVED"])); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionPreviewService.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionPreviewService.cs new file mode 100644 index 0000000..ebc7e65 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineConnectionPreviewService.cs @@ -0,0 +1,72 @@ +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +// Source-backed bridge for AutoLinenumberConnections: each endpoint uses the +// same GetLinenumberAnnotationText formatting contract as PXP-LN, while the +// connector itself remains the stable annotation ownership key. +public sealed class LineConnectionPreviewService( + LineNumberFieldResolver fieldResolver, + ILineNumberAnnotationFormatter formatter, + LineConnectionBoundaryPlanner boundaryPlanner) +{ + public LineConnectionPreviewResult Preview( + LineNumberAnnotationDefinition definition, + IEnumerable connectors, + IReadOnlyDictionary placements) + { + ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(connectors); + ArgumentNullException.ThrowIfNull(placements); + + var candidates = new List(); + foreach (var connector in connectors.OrderBy(value => value.StableId, StringComparer.Ordinal)) + { + if (!placements.TryGetValue(connector.StableId, out var placement)) + { + candidates.Add(new(connector.StableId, string.Empty, string.Empty, new(), new(), new(new(), new()), new(), 0, + connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture), + connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture))); + continue; + } + + var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field)); + var first = ResolveText(definition, connector.FirstPartRowId, fields); + var second = ResolveText(definition, connector.SecondPartRowId, fields); + candidates.Add(new( + connector.StableId, + first.Text, + second.Text, + placement.FirstEndpoint, + placement.SecondEndpoint, + placement.Bounds, + placement.Position, + placement.Rotation, + connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture), + connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + + var plans = boundaryPlanner.Build(candidates); + // Per-connector property failures are represented as non-insertable + // plans, matching legacy's continue-on-one-bad-connector behaviour. + var repaired = plans.Select(plan => + { + var connector = connectors.First(value => string.Equals(value.StableId, plan.StableId, StringComparison.Ordinal)); + if (!placements.ContainsKey(plan.StableId)) return plan with { Reasons = plan.Reasons.Append("CONNECTOR_GEOMETRY_UNAVAILABLE").Distinct(StringComparer.Ordinal).ToArray() }; + var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field)); + var first = ResolveText(definition, connector.FirstPartRowId, fields); + var second = ResolveText(definition, connector.SecondPartRowId, fields); + var reasons = plan.Reasons.Concat(first.Error is null ? [] : [first.Error]).Concat(second.Error is null ? [] : [second.Error]).Distinct(StringComparer.Ordinal).ToArray(); + return reasons.Length == plan.Reasons.Count ? plan : plan with { Text = reasons.Length == 0 ? plan.Text : string.Empty, Reasons = reasons }; + }).ToArray(); + return new(true, repaired); + } + + private (string Text, string? Error) ResolveText(LineNumberAnnotationDefinition definition, long rowId, IEnumerable fields) + { + var resolved = fieldResolver.Resolve(rowId, fields); + if (!resolved.Success) return (string.Empty, resolved.FailureCode ?? "DATALINKS_PROPERTY_READ_FAILED"); + var formatted = formatter.Format(definition, resolved.Values); + return formatted.Success ? (formatted.Text, null) : (string.Empty, formatted.ErrorCode ?? "LINE_NUMBER_FORMAT_FAILED"); + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineConnectionPreviewServiceTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineConnectionPreviewServiceTests.cs new file mode 100644 index 0000000..cc9fcdc --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineConnectionPreviewServiceTests.cs @@ -0,0 +1,75 @@ +using PRecAnnot.Abstractions; +using PRecAnnot.Core; + +namespace PRecAnnot.Core.Tests; + +public sealed class LineConnectionPreviewServiceTests +{ + [Fact] + public void FormatsBothRelatedPartsAndKeepsConnectorOrderDeterministic() + { + var service = CreateService(new Dictionary> + { + [10] = Fields("L-200"), [20] = Fields("L-100"), [30] = Fields("L-300"), [40] = Fields("L-400") + }); + var result = service.Preview(Definition(), [Connector("b", 10, 20, 1, 2), Connector("a", 30, 40, 3, 4)], Placements("a", "b")); + + Assert.True(result.Success); + Assert.Equal(["a", "b"], result.Plans.Select(plan => plan.StableId)); + Assert.Equal("L-100 / L-200", result.Plans[1].Text); + Assert.Empty(result.Plans[1].Reasons); + } + + [Fact] + public void RejectsSameLineGroupEvenWhenFormattedValuesDiffer() + { + var service = CreateService(new Dictionary> { [10] = Fields("L-100"), [20] = Fields("L-200") }); + var result = service.Preview(Definition(), [Connector("connector", 10, 20, 7, 7)], Placements("connector")); + + Assert.Equal(string.Empty, result.Plans.Single().Text); + Assert.Contains("SAME_LINE_GROUP", result.Plans.Single().Reasons); + } + + [Fact] + public void PreservesPropertyFailureOnOnlyTheAffectedConnector() + { + var service = CreateService(new Dictionary> { [10] = Fields("L-100"), [30] = Fields("L-300"), [40] = Fields("L-400") }); + var result = service.Preview(Definition(), [Connector("bad", 10, 20, 1, 2), Connector("good", 30, 40, 3, 4)], Placements("bad", "good")); + + Assert.Contains("LINE_NUMBER_COLUMN_MISSING:EngineeringItems.LineNumber", result.Plans.Single(plan => plan.StableId == "bad").Reasons); + Assert.Equal("L-300 / L-400", result.Plans.Single(plan => plan.StableId == "good").Text); + } + + [Fact] + public void RecordsMissingGeometryWithoutInventingPlacement() + { + var service = CreateService(new Dictionary> { [10] = Fields("L-100"), [20] = Fields("L-200") }); + var result = service.Preview(Definition(), [Connector("connector", 10, 20, 1, 2)], new Dictionary()); + + Assert.Contains("CONNECTOR_GEOMETRY_UNAVAILABLE", result.Plans.Single().Reasons); + Assert.Equal(string.Empty, result.Plans.Single().Text); + } + + private static LineConnectionPreviewService CreateService(IReadOnlyDictionary> rows) => + new(new LineNumberFieldResolver(new Reader(rows)), new LineNumberAnnotationFormatter(), new LineConnectionBoundaryPlanner()); + + private static LineNumberAnnotationDefinition Definition() => new() + { + Nickname = "-", Tags = [new() { Field = "EngineeringItems.LineNumber", SaveWhenEmpty = true }] + }; + + private static PlantConnectorRelationshipSnapshot Connector(string id, long first, long second, long firstGroup, long secondGroup) => + new(id, 99, first, second, firstGroup, secondGroup, Array.Empty()); + + private static IReadOnlyDictionary Placements(params string[] ids) => ids.ToDictionary(id => id, id => + new LineConnectionPlacement(new(1, 2, 0), new(3, 4, 0), new(new(1, 2, 0), new(3, 4, 0)), new(2, 3), 0), StringComparer.Ordinal); + + private static IReadOnlyDictionary Fields(string lineNumber) => new Dictionary { ["EngineeringItems.LineNumber"] = lineNumber }; + + private sealed class Reader(IReadOnlyDictionary> rows) : IDataLinksPropertyReader + { + public DataLinksPropertyReadResult ReadProperties(long rowId) => rows.TryGetValue(rowId, out var values) + ? new(true, values) + : new(true, new Dictionary()); + } +}