diff --git a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs index ef4d83c..27621f8 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs @@ -112,6 +112,21 @@ public void Leader_segment_runs_from_nearest_target_boundary_to_tag_head() Assert.Equal(1.5, leader.Length, 6); } + [Fact] + public void Attached_leader_envelope_contains_the_tagged_element_and_hidden_attachment_leg() + { + var envelope = TagPlacementPlanner.BuildConservativeLeaderEnvelope( + Rect(-2, -1, 1, 3), + 4, + -3); + + Assert.Equal(-2, envelope.MinX, 6); + Assert.Equal(-3, envelope.MinY, 6); + Assert.Equal(4, envelope.MaxX, 6); + Assert.Equal(3, envelope.MaxY, 6); + Assert.True(new TagSegment2(-3, 0, 5, 0).CrossesInterior(envelope)); + } + [Fact] public void Rejects_otherwise_clear_head_when_its_leader_crosses_an_existing_leader() { diff --git a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagWorkPolicyTests.cs b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagWorkPolicyTests.cs index 5d9b1cd..692d017 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagWorkPolicyTests.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagWorkPolicyTests.cs @@ -36,5 +36,28 @@ public void GeometryAwareTagsRemainOnlyWithMeasuredCollisionFreeReadback( { Assert.Equal(expected, TagWorkPolicy.KeepCreatedTag(geometryAware, hasMeasurableGeometry, collisionFree)); } + + [Theory] + [InlineData(false, 1, false, true, false)] + [InlineData(true, 0, false, true, false)] + [InlineData(true, 2, false, true, false)] + [InlineData(true, 1, false, false, false)] + [InlineData(true, 1, false, true, true)] + [InlineData(true, 1, true, false, true)] + public void MeasuredLeaderPreparationRequiresOneReferenceAndSupportedFreeEnd( + bool leaderApplied, + int taggedReferenceCount, + bool isAlreadyFree, + bool canAssignFree, + bool expected) + { + Assert.Equal( + expected, + TagWorkPolicy.CanPrepareMeasuredLeader( + leaderApplied, + taggedReferenceCount, + isAlreadyFree, + canAssignFree)); + } } } diff --git a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs index b822e9e..de29337 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs @@ -287,6 +287,19 @@ public static TagSegment2 BuildLeaderSegment(TagRect2 target, TagRect2 tagBounds return new TagSegment2(startX, startY, tagBounds.CenterX, tagBounds.CenterY); } + public static TagRect2 BuildConservativeLeaderEnvelope(TagRect2 target, double anchorX, double anchorY) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (double.IsNaN(anchorX) || double.IsInfinity(anchorX) || + double.IsNaN(anchorY) || double.IsInfinity(anchorY)) + throw new ArgumentOutOfRangeException(nameof(anchorX), "Leader anchor coordinates must be finite."); + return new TagRect2( + Math.Min(target.MinX, anchorX), + Math.Min(target.MinY, anchorY), + Math.Max(target.MaxX, anchorX), + Math.Max(target.MaxY, anchorY)); + } + private static bool SameRect(TagRect2 left, TagRect2 right, double tolerance = 1e-9) => Math.Abs(left.MinX - right.MinX) <= tolerance && Math.Abs(left.MinY - right.MinY) <= tolerance && diff --git a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagWorkPolicy.cs b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagWorkPolicy.cs index 66d8a72..08b24c1 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagWorkPolicy.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagWorkPolicy.cs @@ -11,5 +11,14 @@ public static bool KeepCreatedTag(bool geometryAware, bool hasMeasurableGeometry { return !geometryAware || (hasMeasurableGeometry && collisionFree); } + + public static bool CanPrepareMeasuredLeader( + bool leaderApplied, + int taggedReferenceCount, + bool isAlreadyFree, + bool canAssignFree) + { + return leaderApplied && taggedReferenceCount == 1 && (isAlreadyFree || canAssignFree); + } } } diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs index e24736d..d4b3937 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs @@ -85,6 +85,10 @@ private sealed class GeometryPlacementOutcome public bool CollisionFree { get; set; } public bool LeaderApplied { get; set; } public string LeaderGeometryStatus { get; set; } = "not_requested"; + public string? LeaderEndConditionBefore { get; set; } + public string? LeaderEndConditionAfter { get; set; } + public bool LeaderEndpointAssigned { get; set; } + public string? LeaderGeometryError { get; set; } public int LeaderCrossingCount { get; set; } public int LeaderProtectedCrossingCount { get; set; } public double? LeaderLength { get; set; } @@ -666,7 +670,7 @@ private static GeometryObstacleSet CollectGeometryObstacles(Document doc, View v var centerU = head.DotProduct(right); var centerV = head.DotProduct(up); rect = new TagRect2(centerU - tagWidth * 0.5, centerV - tagHeight * 0.5, centerU + tagWidth * 0.5, centerV + tagHeight * 0.5); - if (!AddExistingTagLeaderSegments(view, existingTag, obstacles.LeaderSegments)) + if (!AddExistingTagLeaderGeometry(view, existingTag, obstacles)) throw new InvalidOperationException($"Existing leader geometry for tag {ElementIdCompat.GetValue(existingTag.Id)} could not be measured. Geometry-aware placement stopped to avoid an unverified leader crossing."); } else @@ -809,6 +813,13 @@ private static void ValidateAppliedLeader( return; } + if (!TryPrepareCreatedLeaderForReadback(doc, view, tag, plan, outcome)) + { + outcome.CollisionFree = false; + outcome.Status = "leader_geometry_unavailable"; + return; + } + var actualSegments = ReadIndependentTagLeaderSegments(tag, view); if (actualSegments.Count == 0) { @@ -829,19 +840,123 @@ private static void ValidateAppliedLeader( if (!outcome.CollisionFree) outcome.Status = "actual_leader_collision"; } - private static bool AddExistingTagLeaderSegments( + private static bool TryPrepareCreatedLeaderForReadback( + Document doc, View view, IndependentTag tag, - ICollection destination) + GeometryPlacementPlan plan, + GeometryPlacementOutcome outcome) { - if (!tag.HasLeader) return true; - var actual = ReadIndependentTagLeaderSegments(tag, view); - if (actual.Count > 0) + try { - foreach (var segment in actual) destination.Add(segment); + var references = tag.GetTaggedReferences(); + var isAlreadyFree = tag.LeaderEndCondition == LeaderEndCondition.Free; + var canAssignFree = isAlreadyFree || tag.CanLeaderEndConditionBeAssigned(LeaderEndCondition.Free); + outcome.LeaderEndConditionBefore = tag.LeaderEndCondition.ToString(); + + if (!TagWorkPolicy.CanPrepareMeasuredLeader( + outcome.LeaderApplied, + references.Count, + isAlreadyFree, + canAssignFree)) + { + outcome.LeaderGeometryStatus = references.Count == 1 + ? "free_end_not_supported" + : "tagged_reference_count_unsupported"; + return false; + } + + if (!isAlreadyFree) + { + tag.LeaderEndCondition = LeaderEndCondition.Free; + doc.Regenerate(); + } + + var plannedSegment = outcome.LeaderSegment; + if (plannedSegment == null) + { + outcome.LeaderGeometryStatus = "planned_endpoint_unavailable"; + return false; + } + + var reference = references[0]; + if (!tag.IsLeaderVisible(reference)) + { + outcome.LeaderGeometryStatus = "leader_not_visible"; + return false; + } + + var endpoint = FromViewCoordinates( + view, + plannedSegment.StartX, + plannedSegment.StartY, + plan.PlaneW); + tag.SetLeaderEnd(reference, endpoint); + doc.Regenerate(); + + outcome.LeaderEndConditionAfter = tag.LeaderEndCondition.ToString(); + outcome.LeaderEndpointAssigned = true; return true; } - return false; + catch (Exception ex) + { + outcome.LeaderGeometryStatus = "free_end_assignment_failed"; + outcome.LeaderGeometryError = ex.Message; + return false; + } + } + + private static bool AddExistingTagLeaderGeometry( + View view, + IndependentTag tag, + GeometryObstacleSet obstacles) + { + if (!tag.HasLeader) return true; + try + { + var references = tag.GetTaggedReferences(); + if (references.Count == 0) return false; + var visibleLeaderCount = 0; + var head = tag.TagHeadPosition; + var right = view.RightDirection.Normalize(); + var up = view.UpDirection.Normalize(); + + foreach (var reference in references) + { + if (!tag.IsLeaderVisible(reference)) continue; + visibleLeaderCount++; + + if (tag.LeaderEndCondition == LeaderEndCondition.Free) + { + var end = tag.GetLeaderEnd(reference); + var elbow = tag.HasLeaderElbow(reference) ? tag.GetLeaderElbow(reference) : null; + var before = obstacles.LeaderSegments.Count; + AddLeaderPath(end, elbow, head, view, obstacles.LeaderSegments); + if (obstacles.LeaderSegments.Count == before) return false; + continue; + } + + var attachedElbow = tag.HasLeaderElbow(reference) ? tag.GetLeaderElbow(reference) : null; + var hiddenLegAnchor = attachedElbow ?? head; + var taggedElement = tag.Document.GetElement(reference.ElementId); + var targetBounds = taggedElement == null ? null : ToViewRect(taggedElement.get_BoundingBox(view), view); + if (targetBounds == null) return false; + + obstacles.LeaderProtectedObstacles.Add( + TagPlacementPlanner.BuildConservativeLeaderEnvelope( + targetBounds, + hiddenLegAnchor.DotProduct(right), + hiddenLegAnchor.DotProduct(up))); + if (attachedElbow != null) + AddLeaderSegment(attachedElbow, head, view, obstacles.LeaderSegments); + } + + return visibleLeaderCount > 0; + } + catch + { + return false; + } } private static List ReadIndependentTagLeaderSegments(IndependentTag tag, View view) @@ -850,26 +965,19 @@ private static List ReadIndependentTagLeaderSegments(IndependentTag try { if (!tag.HasLeader) return segments; + if (tag.LeaderEndCondition != LeaderEndCondition.Free) return segments; var head = tag.TagHeadPosition; - var references = TryReadTaggedReferences(tag); + var references = tag.GetTaggedReferences(); if (references.Count > 0) { foreach (var reference in references) { - var end = TryInvokeXyzMethod(tag, "GetLeaderEnd", reference); - var elbow = TryInvokeXyzMethod(tag, "GetLeaderElbow", reference); + if (!tag.IsLeaderVisible(reference)) return new List(); + var end = tag.GetLeaderEnd(reference); + var elbow = tag.HasLeaderElbow(reference) ? tag.GetLeaderElbow(reference) : null; AddLeaderPath(end, elbow, head, view, segments); } } - else - { - AddLeaderPath( - TryReadXyzProperty(tag, "LeaderEnd"), - TryReadXyzProperty(tag, "LeaderElbow"), - head, - view, - segments); - } } catch { @@ -1690,6 +1798,10 @@ private static object BuildTagReadback(Document doc, Element tag, Element target collisionFree = geometryOutcome.CollisionFree, leaderApplied = geometryOutcome.LeaderApplied, leaderGeometryStatus = geometryOutcome.LeaderGeometryStatus, + leaderEndConditionBefore = geometryOutcome.LeaderEndConditionBefore, + leaderEndConditionAfter = geometryOutcome.LeaderEndConditionAfter, + leaderEndpointAssigned = geometryOutcome.LeaderEndpointAssigned, + leaderGeometryError = geometryOutcome.LeaderGeometryError, leaderLength = geometryOutcome.LeaderLength, leaderCrossingCount = geometryOutcome.LeaderCrossingCount, leaderProtectedCrossingCount = geometryOutcome.LeaderProtectedCrossingCount,