feat: plan p-rec-annot connector annotations
This commit is contained in:
@@ -80,8 +80,23 @@ public sealed record LineConnectionBoundaryCandidate(
|
|||||||
Point3Dto SecondEndpoint,
|
Point3Dto SecondEndpoint,
|
||||||
Bounds3Dto Bounds,
|
Bounds3Dto Bounds,
|
||||||
AnnotationPoint Position,
|
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);
|
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(
|
public sealed record PlantConnectorRelationshipSnapshot(
|
||||||
string StableId,
|
string StableId,
|
||||||
long ConnectorRowId,
|
long ConnectorRowId,
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ public sealed class LineConnectionBoundaryPlanner
|
|||||||
}
|
}
|
||||||
var first = candidate.FirstLineNumber?.Trim() ?? string.Empty;
|
var first = candidate.FirstLineNumber?.Trim() ?? string.Empty;
|
||||||
var second = candidate.SecondLineNumber?.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)
|
if (first.Length == 0 || second.Length == 0)
|
||||||
{
|
{
|
||||||
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["LINE_GROUP_NOT_RESOLVED"]));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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?>());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user