feat: plan p-rec-annot connector annotations

This commit is contained in:
codex
2026-08-07 09:45:07 +03:00
parent 20ac5e4808
commit 53d6378cfa
4 changed files with 173 additions and 1 deletions
@@ -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<string> 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<LineConnectionBoundaryPlan> Plans,
string? FailureCode = null);
public sealed record PlantConnectorRelationshipSnapshot(
string StableId,
long ConnectorRowId,
@@ -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"]));
@@ -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<PlantConnectorRelationshipSnapshot> connectors,
IReadOnlyDictionary<string, LineConnectionPlacement> placements)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(connectors);
ArgumentNullException.ThrowIfNull(placements);
var candidates = new List<LineConnectionBoundaryCandidate>();
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<string> 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");
}
}
@@ -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<long, IReadOnlyDictionary<string, string?>>
{
[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<long, IReadOnlyDictionary<string, string?>> { [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<long, IReadOnlyDictionary<string, string?>> { [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<long, IReadOnlyDictionary<string, string?>> { [10] = Fields("L-100"), [20] = Fields("L-200") });
var result = service.Preview(Definition(), [Connector("connector", 10, 20, 1, 2)], new Dictionary<string, LineConnectionPlacement>());
Assert.Contains("CONNECTOR_GEOMETRY_UNAVAILABLE", result.Plans.Single().Reasons);
Assert.Equal(string.Empty, result.Plans.Single().Text);
}
private static LineConnectionPreviewService CreateService(IReadOnlyDictionary<long, IReadOnlyDictionary<string, string?>> 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<string>());
private static IReadOnlyDictionary<string, LineConnectionPlacement> 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<string, string?> Fields(string lineNumber) => new Dictionary<string, string?> { ["EngineeringItems.LineNumber"] = lineNumber };
private sealed class Reader(IReadOnlyDictionary<long, IReadOnlyDictionary<string, string?>> rows) : IDataLinksPropertyReader
{
public DataLinksPropertyReadResult ReadProperties(long rowId) => rows.TryGetValue(rowId, out var values)
? new(true, values)
: new(true, new Dictionary<string, string?>());
}
}