From b520476c88d672891f4a67db9ca7a415e5b8f3f7 Mon Sep 17 00:00:00 2001 From: Eli Powell Date: Mon, 13 Jul 2026 06:34:14 -0400 Subject: [PATCH] Improve geometry-aware tag readability --- .../deterministic/aec_level_tag_runtime.ts | 3 +- .../test/aec_level_tag_runtime.test.ts | 23 ++ .../TagPlacementPlannerTests.cs | 135 ++++++ .../Annotation/TagPlacementPlanner.cs | 152 ++++++- .../Handlers/TagElementsHandler.cs | 384 +++++++++++++++--- .../Operator/OperatorToolManifest.cs | 2 +- 6 files changed, 647 insertions(+), 52 deletions(-) diff --git a/apps/operator-backend/src/deterministic/aec_level_tag_runtime.ts b/apps/operator-backend/src/deterministic/aec_level_tag_runtime.ts index a4da6be..73ac8f2 100644 --- a/apps/operator-backend/src/deterministic/aec_level_tag_runtime.ts +++ b/apps/operator-backend/src/deterministic/aec_level_tag_runtime.ts @@ -235,7 +235,8 @@ function continueApply(req: ChatRequest, state: RuntimeState, results: ToolResul view.created_tag_ids = [...new Set(view.created_tag_ids)]; view.last_error_count = errors; const kinds = errorKinds(payload); - const retryable = errors > 0 && kinds.length === errors && kinds.every(kind => kind === "tag_unresolved_collision"); + const retryableKinds = new Set(["tag_unresolved_collision", "tag_unresolved_leader_collision"]); + const retryable = errors > 0 && kinds.length === errors && kinds.every(kind => retryableKinds.has(kind)); if (errors > 0 && retryable) retryViews.push(view); else if (errors > 0) state.hard_failure = `${errors} non-retryable tag error(s) remained in ${view.name}.`; appendGoalProgress(req.session_id, { diff --git a/apps/operator-backend/test/aec_level_tag_runtime.test.ts b/apps/operator-backend/test/aec_level_tag_runtime.test.ts index eb704dd..0e14717 100644 --- a/apps/operator-backend/test/aec_level_tag_runtime.test.ts +++ b/apps/operator-backend/test/aec_level_tag_runtime.test.ts @@ -144,6 +144,29 @@ test("level tag runtime retains a fifth bounded tall-offset repair before final assert.equal(done?.aec_query_receipt?.status, "complete"); })); +test("level tag runtime retries professionally unsafe leader routes with a bounded alternate placement", () => withWorkspace(() => { + __testOnlyClearAecLevelTagRuntime(); + const session = "level-tag-leader-repair"; + activateGoal(session); + startAecLevelTagRuntime(req(session), task(), [{ id: 101, name: "L4 HVAC", levelName: "L4" }]); + maybeContinueAecLevelTagRuntime(req(session, [result("aec-level-tag-inventory-101", { count: 1, truncated: false, elementIds: [11] })])); + maybeContinueAecLevelTagRuntime(req(session, [result("aec-level-tag-dry_run-101", { targetCount: 1, plannedToTag: 1, skippedAlreadyTagged: 0, geometry: { plans: [{ candidateCount: 180 }] } })])); + + const retry = maybeContinueAecLevelTagRuntime(req(session, [ + result("aec-level-tag-apply-101-v1", { + targetCount: 1, + taggedCount: 0, + skippedAlreadyTagged: 0, + errorCount: 1, + tagIds: [], + errors: [{ failureKind: "tag_unresolved_leader_collision" }] + }) + ])); + + assert.equal(retry?.actions[0]?.action_id, "aec-level-tag-apply-101-v2"); + assert.equal(body(retry!.actions[0]!).placementMode, "geometry_aware"); +})); + test("level tag runtime fails closed on truncated inventories before any tag action", () => withWorkspace(() => { __testOnlyClearAecLevelTagRuntime(); const session = "level-tag-truncated"; diff --git a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs index 3d2556d..ef4d83c 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common.Tests/TagPlacementPlannerTests.cs @@ -98,6 +98,141 @@ public void UsesDeterministicLanesToAvoidPriorTags() Assert.NotEqual((first.HeadX, first.HeadY), (second.HeadX, second.HeadY)); } + [Fact] + public void Leader_segment_runs_from_nearest_target_boundary_to_tag_head() + { + var leader = TagPlacementPlanner.BuildLeaderSegment( + Rect(-0.5, -0.5, 0.5, 0.5), + Rect(1.5, -0.25, 2.5, 0.25)); + + Assert.Equal(0.5, leader.StartX, 6); + Assert.Equal(0, leader.StartY, 6); + Assert.Equal(2, leader.EndX, 6); + Assert.Equal(0, leader.EndY, 6); + Assert.Equal(1.5, leader.Length, 6); + } + + [Fact] + public void Rejects_otherwise_clear_head_when_its_leader_crosses_an_existing_leader() + { + var target = Rect(-0.5, -0.5, 0.5, 0.5); + var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest + { + Target = target, + Obstacles = new[] { target }, + LeaderSegments = new[] { new TagSegment2(0.8, -2, 0.8, 2) }, + TagWidth = 1, + TagHeight = 0.5, + Clearance = 0.1, + Profile = "mep", + MaxCandidates = 4 + }); + + Assert.Equal("top", ranked.First().Side); + Assert.True(ranked.First().CollisionFree); + Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.CollisionCount == 0 && candidate.LeaderCrossingCount == 1 && !candidate.CollisionFree); + } + + [Fact] + public void Prevents_leader_from_running_through_protected_annotation_content() + { + var target = Rect(-0.5, -0.5, 0.5, 0.5); + var protectedText = Rect(0.52, -0.05, 0.58, 0.05); + var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest + { + Target = target, + Obstacles = new[] { target }, + LeaderProtectedObstacles = new[] { protectedText }, + TagWidth = 1, + TagHeight = 0.5, + Clearance = 0.1, + Profile = "mep", + MaxCandidates = 4 + }); + + Assert.Equal("top", ranked.First().Side); + Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.CollisionCount == 0 && candidate.LeaderProtectedCrossingCount == 1); + } + + [Fact] + public void Prefers_clear_floor_space_over_soft_wall_or_equipment_overlap() + { + var target = Rect(-0.5, -0.5, 0.5, 0.5); + var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest + { + Target = target, + Obstacles = new[] { target }, + SoftObstacles = new[] { Rect(0.55, -1, 2, 1) }, + TagWidth = 1, + TagHeight = 0.5, + Clearance = 0.1, + Profile = "mep", + MaxCandidates = 4 + }); + + Assert.Equal("top", ranked.First().Side); + Assert.Equal(0, ranked.First().SoftObstacleCount); + Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.SoftObstacleCount > 0); + } + + [Fact] + public void Keeps_the_nearest_clear_leader_ahead_of_farther_clear_variants() + { + var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest + { + Target = Rect(-0.5, -0.5, 0.5, 0.5), + Obstacles = new[] { Rect(-0.5, -0.5, 0.5, 0.5) }, + TagWidth = 1, + TagHeight = 0.5, + Clearance = 0.2, + Profile = "mep", + MaxCandidates = 180 + }); + + Assert.Equal(0.7, ranked.First().LeaderLength, 6); + Assert.Equal( + ranked.Where(candidate => candidate.Side == ranked.First().Side).Min(candidate => candidate.LeaderLength), + ranked.First().LeaderLength, + 6); + } + + [Fact] + public void Leader_disabled_uses_head_geometry_only_and_reports_no_leader_metrics() + { + var target = Rect(-0.5, -0.5, 0.5, 0.5); + var candidate = TagPlacementPlanner.RankCandidates(new TagPlacementRequest + { + Target = target, + Obstacles = new[] { target }, + LeaderSegments = new[] { new TagSegment2(0.8, -2, 0.8, 2) }, + LeaderProtectedObstacles = new[] { Rect(0.52, -0.05, 0.58, 0.05) }, + LeaderEnabled = false, + TagWidth = 1, + TagHeight = 0.5, + Clearance = 0.1, + Profile = "mep", + MaxCandidates = 4 + }).First(); + + Assert.True(candidate.CollisionFree); + Assert.Equal("right", candidate.Side); + Assert.Null(candidate.LeaderSegment); + Assert.Equal(0, candidate.LeaderLength); + Assert.Equal(0, candidate.LeaderCrossingCount); + Assert.Equal(0, candidate.LeaderProtectedCrossingCount); + } + + [Fact] + public void Segment_geometry_distinguishes_interior_crossings_from_boundary_touches() + { + var rect = Rect(1, 1, 2, 2); + Assert.True(new TagSegment2(0, 1.5, 3, 1.5).CrossesInterior(rect)); + Assert.False(new TagSegment2(0, 1, 3, 1).CrossesInterior(rect)); + Assert.True(new TagSegment2(0, 0, 2, 2).Intersects(new TagSegment2(0, 2, 2, 0))); + Assert.True(new TagSegment2(0, 0, 2, 0).Intersects(new TagSegment2(2, 0, 3, 1))); + Assert.True(new TagSegment2(0, 0, 3, 0).Intersects(new TagSegment2(1, 0, 2, 0))); + } + [Fact] public void ReportsLeastBadCandidateWhenEverySideIsBlocked() { diff --git a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs index bedd005..b822e9e 100644 --- a/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs +++ b/apps/revit-bridge-addin/RevitBridge.Common/Annotation/TagPlacementPlanner.cs @@ -45,10 +45,101 @@ public double IntersectionArea(TagRect2 other) private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); } + public sealed class TagSegment2 + { + public TagSegment2(double startX, double startY, double endX, double endY) + { + if (!IsFinite(startX) || !IsFinite(startY) || !IsFinite(endX) || !IsFinite(endY)) + throw new ArgumentException("Tag leader coordinates must be finite."); + StartX = startX; + StartY = startY; + EndX = endX; + EndY = endY; + } + + public double StartX { get; } + public double StartY { get; } + public double EndX { get; } + public double EndY { get; } + public double Length => Math.Sqrt(Math.Pow(EndX - StartX, 2) + Math.Pow(EndY - StartY, 2)); + + public bool Intersects(TagSegment2 other, double tolerance = 1e-9) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + var o1 = Orientation(StartX, StartY, EndX, EndY, other.StartX, other.StartY, tolerance); + var o2 = Orientation(StartX, StartY, EndX, EndY, other.EndX, other.EndY, tolerance); + var o3 = Orientation(other.StartX, other.StartY, other.EndX, other.EndY, StartX, StartY, tolerance); + var o4 = Orientation(other.StartX, other.StartY, other.EndX, other.EndY, EndX, EndY, tolerance); + if (o1 != o2 && o3 != o4) return true; + return (o1 == 0 && OnSegment(StartX, StartY, EndX, EndY, other.StartX, other.StartY, tolerance)) || + (o2 == 0 && OnSegment(StartX, StartY, EndX, EndY, other.EndX, other.EndY, tolerance)) || + (o3 == 0 && OnSegment(other.StartX, other.StartY, other.EndX, other.EndY, StartX, StartY, tolerance)) || + (o4 == 0 && OnSegment(other.StartX, other.StartY, other.EndX, other.EndY, EndX, EndY, tolerance)); + } + + public bool CrossesInterior(TagRect2 rect, double tolerance = 1e-9) + { + if (rect == null) throw new ArgumentNullException(nameof(rect)); + var minX = rect.MinX + tolerance; + var minY = rect.MinY + tolerance; + var maxX = rect.MaxX - tolerance; + var maxY = rect.MaxY - tolerance; + if (maxX <= minX || maxY <= minY) return false; + if (Inside(StartX, StartY, minX, minY, maxX, maxY) || Inside(EndX, EndY, minX, minY, maxX, maxY)) return true; + + var t0 = 0.0; + var t1 = 1.0; + var dx = EndX - StartX; + var dy = EndY - StartY; + if (!Clip(-dx, StartX - minX, ref t0, ref t1) || + !Clip(dx, maxX - StartX, ref t0, ref t1) || + !Clip(-dy, StartY - minY, ref t0, ref t1) || + !Clip(dy, maxY - StartY, ref t0, ref t1)) return false; + return t1 - t0 > tolerance; + } + + private static bool Clip(double p, double q, ref double t0, ref double t1) + { + if (Math.Abs(p) <= 1e-12) return q >= 0; + var r = q / p; + if (p < 0) + { + if (r > t1) return false; + if (r > t0) t0 = r; + } + else + { + if (r < t0) return false; + if (r < t1) t1 = r; + } + return true; + } + + private static int Orientation(double ax, double ay, double bx, double by, double cx, double cy, double tolerance) + { + var value = (by - ay) * (cx - bx) - (bx - ax) * (cy - by); + if (Math.Abs(value) <= tolerance) return 0; + return value > 0 ? 1 : 2; + } + + private static bool OnSegment(double ax, double ay, double bx, double by, double px, double py, double tolerance) => + px <= Math.Max(ax, bx) + tolerance && px >= Math.Min(ax, bx) - tolerance && + py <= Math.Max(ay, by) + tolerance && py >= Math.Min(ay, by) - tolerance; + + private static bool Inside(double x, double y, double minX, double minY, double maxX, double maxY) => + x > minX && x < maxX && y > minY && y < maxY; + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + public sealed class TagPlacementRequest { public TagRect2 Target { get; set; } = new TagRect2(0, 0, 0, 0); public IReadOnlyList Obstacles { get; set; } = Array.Empty(); + public IReadOnlyList SoftObstacles { get; set; } = Array.Empty(); + public IReadOnlyList LeaderProtectedObstacles { get; set; } = Array.Empty(); + public IReadOnlyList LeaderSegments { get; set; } = Array.Empty(); + public bool LeaderEnabled { get; set; } = true; public double TagWidth { get; set; } public double TagHeight { get; set; } public double Clearance { get; set; } @@ -64,8 +155,14 @@ public sealed class TagPlacementCandidate public TagRect2 Bounds { get; set; } = new TagRect2(0, 0, 0, 0); public int CollisionCount { get; set; } public double CollisionArea { get; set; } + public int SoftObstacleCount { get; set; } + public double SoftObstacleArea { get; set; } + public TagSegment2? LeaderSegment { get; set; } + public double LeaderLength { get; set; } + public int LeaderCrossingCount { get; set; } + public int LeaderProtectedCrossingCount { get; set; } public double Score { get; set; } - public bool CollisionFree => CollisionCount == 0; + public bool CollisionFree => CollisionCount == 0 && LeaderCrossingCount == 0 && LeaderProtectedCrossingCount == 0; } public static class TagPlacementPlanner @@ -90,6 +187,9 @@ public static IReadOnlyList RankCandidates(TagPlacementRe // dense production views instead of truncating it after the first 128 positions. var maxCandidates = Math.Max(1, Math.Min(180, request.MaxCandidates)); var obstacles = (request.Obstacles ?? Array.Empty()).Where(x => x != null).ToList(); + var softObstacles = (request.SoftObstacles ?? Array.Empty()).Where(x => x != null).ToList(); + var leaderProtectedObstacles = (request.LeaderProtectedObstacles ?? Array.Empty()).Where(x => x != null).ToList(); + var leaderSegments = (request.LeaderSegments ?? Array.Empty()).Where(x => x != null).ToList(); var directions = DirectionsFor(request.Profile); var candidates = new List(); var halfWidth = request.TagWidth * 0.5; @@ -118,6 +218,22 @@ public static IReadOnlyList RankCandidates(TagPlacementRe collisionArea += bounds.IntersectionArea(inflated); } + var softObstacleCount = 0; + var softObstacleArea = 0.0; + foreach (var obstacle in softObstacles) + { + if (!bounds.Intersects(obstacle)) continue; + softObstacleCount++; + softObstacleArea += bounds.IntersectionArea(obstacle); + } + + var leaderSegment = request.LeaderEnabled ? BuildLeaderSegment(request.Target, bounds) : null; + var leaderCrossingCount = leaderSegment == null ? 0 : leaderSegments.Count(segment => leaderSegment.Intersects(segment)); + var leaderProtectedCrossingCount = leaderSegment == null + ? 0 + : leaderProtectedObstacles.Count(obstacle => !SameRect(obstacle, request.Target) && leaderSegment.CrossesInterior(obstacle.Inflate(request.Clearance))); + var leaderLength = leaderSegment?.Length ?? 0.0; + var distance = Math.Sqrt( Math.Pow(headX - request.Target.CenterX, 2) + Math.Pow(headY - request.Target.CenterY, 2)); @@ -129,7 +245,22 @@ public static IReadOnlyList RankCandidates(TagPlacementRe Bounds = bounds, CollisionCount = collisionCount, CollisionArea = collisionArea, - Score = collisionCount * 1000000.0 + collisionArea * 1000.0 + direction.Penalty * 300.0 + Math.Pow(distanceScale - 1.0, 2.0) * 10.0 + Math.Abs(lane) * 10.0 + distance + SoftObstacleCount = softObstacleCount, + SoftObstacleArea = softObstacleArea, + LeaderSegment = leaderSegment, + LeaderLength = leaderLength, + LeaderCrossingCount = leaderCrossingCount, + LeaderProtectedCrossingCount = leaderProtectedCrossingCount, + Score = collisionCount * 1000000000000.0 + + leaderCrossingCount * 100000000000.0 + + leaderProtectedCrossingCount * 10000000000.0 + + collisionArea * 1000000.0 + + softObstacleCount * 100000.0 + + softObstacleArea * 1000.0 + + leaderLength * 2.0 + + direction.Penalty * 300.0 + + Math.Pow(distanceScale - 1.0, 2.0) * 10.0 + + Math.Abs(lane) * 10.0 + distance }); } if (candidates.Count >= maxCandidates) break; @@ -147,6 +278,23 @@ public static IReadOnlyList RankCandidates(TagPlacementRe .ToList(); } + public static TagSegment2 BuildLeaderSegment(TagRect2 target, TagRect2 tagBounds) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (tagBounds == null) throw new ArgumentNullException(nameof(tagBounds)); + var startX = Clamp(tagBounds.CenterX, target.MinX, target.MaxX); + var startY = Clamp(tagBounds.CenterY, target.MinY, target.MaxY); + return new TagSegment2(startX, startY, tagBounds.CenterX, tagBounds.CenterY); + } + + 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 && + Math.Abs(left.MaxX - right.MaxX) <= tolerance && + Math.Abs(left.MaxY - right.MaxY) <= tolerance; + + private static double Clamp(double value, double min, double max) => Math.Max(min, Math.Min(max, value)); + private static IReadOnlyList DirectionsFor(string? profile) { var normalized = (profile ?? "auto").Trim().ToLowerInvariant(); diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs index 9ce74a1..e24736d 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/TagElementsHandler.cs @@ -64,6 +64,15 @@ private sealed class GeometryPlacementPlan public TagRect2 TargetBounds { get; set; } = null!; public IReadOnlyList Candidates { get; set; } = Array.Empty(); public double PlaneW { get; set; } + public bool LeaderEnabled { get; set; } + } + + private sealed class GeometryObstacleSet + { + public List HeadObstacles { get; } = new List(); + public List SoftHeadObstacles { get; } = new List(); + public List LeaderProtectedObstacles { get; } = new List(); + public List LeaderSegments { get; } = new List(); } private sealed class GeometryPlacementOutcome @@ -75,6 +84,14 @@ private sealed class GeometryPlacementOutcome public bool OutsideTarget { get; set; } public bool CollisionFree { get; set; } public bool LeaderApplied { get; set; } + public string LeaderGeometryStatus { get; set; } = "not_requested"; + public int LeaderCrossingCount { get; set; } + public int LeaderProtectedCrossingCount { get; set; } + public double? LeaderLength { get; set; } + public TagSegment2? LeaderSegment { get; set; } + public List LeaderSegments { get; set; } = new List(); + public int SoftObstacleCount { get; set; } + public double SoftObstacleArea { get; set; } public TagRect2? TargetBounds { get; set; } public TagRect2? TagBounds { get; set; } public XYZ? TagHeadPosition { get; set; } @@ -218,7 +235,7 @@ public Task Handle(UIApplication app, string jsonData) throw new InvalidOperationException(tagFamilyResolution?.Error ?? "A compatible tag family could not be loaded."); var geometryPlans = geometryAware && dryRun - ? BuildGeometryPlans(doc, view, targets, existingTagged, onlyUntagged, tagWidth, tagHeight, clearance, placementProfile, maxRepairAttempts) + ? BuildGeometryPlans(doc, view, targets, existingTagged, onlyUntagged, tagWidth, tagHeight, clearance, placementProfile, maxRepairAttempts, addLeader) : new Dictionary(); var tagVisibility = EvaluateTagVisibility(doc, view, targetTagCategory, ensureTagCategoryVisible, dryRun: true); @@ -258,7 +275,7 @@ public Task Handle(UIApplication app, string jsonData) var geometryOutcomes = new List(); var actualObstacles = geometryAware ? CollectGeometryObstacles(doc, view, targets, tagWidth, tagHeight) - : new List(); + : new GeometryObstacleSet(); var tagSizeCalibration = new TagSizeCalibration(tagWidth, tagHeight); using (var t = new Transaction(doc, "Tag Elements")) @@ -267,7 +284,10 @@ public Task Handle(UIApplication app, string jsonData) tagVisibility = EvaluateTagVisibility(doc, view, targetTagCategory, ensureTagCategoryVisible, dryRun: false); if (ensureTagCategoryVisible && !string.IsNullOrWhiteSpace(tagVisibility.Error)) throw new InvalidOperationException($"Tag category visibility could not be ensured: {tagVisibility.Error}"); - foreach (var element in targets) + IEnumerable orderedTargets = geometryAware + ? targets.OrderBy(element => ElementIdCompat.GetValue(element.Id)) + : targets.AsEnumerable(); + foreach (var element in orderedTargets) { var elementId = ElementIdCompat.GetValue(element.Id); if (onlyUntagged && existingTagged.Contains(elementId)) @@ -276,7 +296,7 @@ public Task Handle(UIApplication app, string jsonData) } var geometryPlan = geometryAware - ? BuildGeometryPlan(view, element, actualObstacles, tagSizeCalibration.Width, tagSizeCalibration.Height, clearance, placementProfile, maxRepairAttempts) + ? BuildGeometryPlan(view, element, actualObstacles, tagSizeCalibration.Width, tagSizeCalibration.Height, clearance, placementProfile, maxRepairAttempts, addLeader) : null; var point = geometryPlan?.Candidates.FirstOrDefault() is TagPlacementCandidate firstCandidate ? FromViewCoordinates(view, firstCandidate.HeadX, firstCandidate.HeadY, geometryPlan.PlaneW) @@ -330,33 +350,42 @@ public Task Handle(UIApplication app, string jsonData) var measuredHeight = outcome.TagBounds.MaxY - outcome.TagBounds.MinY; tagSizeCalibration.Observe(measuredWidth, measuredHeight); } + if (outcome.CollisionFree && addLeader) + { + ValidateAppliedLeader(doc, view, independentTag, geometryPlan, actualObstacles, clearance, outcome); + } if (!TagWorkPolicy.KeepCreatedTag(geometryAware: true, hasMeasurableGeometry, outcome.CollisionFree)) { doc.Delete(tag.Id); errors.Add(new { elementId, - failureKind = hasMeasurableGeometry ? "tag_unresolved_collision" : "tag_no_geometry", + failureKind = hasMeasurableGeometry + ? (addLeader && (!outcome.LeaderApplied || !string.Equals(outcome.LeaderGeometryStatus, "actual", StringComparison.OrdinalIgnoreCase) || outcome.LeaderCrossingCount > 0 || outcome.LeaderProtectedCrossingCount > 0) ? "tag_unresolved_leader_collision" : "tag_unresolved_collision") + : "tag_no_geometry", error = hasMeasurableGeometry - ? $"No collision-free tag-head position remained after {outcome.Attempts} bounded attempts. The unresolved tag was removed." + ? $"No professionally clear tag/leader position remained after {outcome.Attempts} bounded attempts. The unresolved tag was removed." : "The selected tag type produced no visible/measurable tag-head geometry. The test tag was removed." }); continue; } - if (outcome.TagBounds != null) actualObstacles.Add(outcome.TagBounds); - if (addLeader) + if (outcome.TagBounds != null) { - try - { - independentTag.HasLeader = true; - doc.Regenerate(); - outcome.LeaderApplied = independentTag.HasLeader; - } - catch - { - outcome.LeaderApplied = false; - } + actualObstacles.HeadObstacles.Add(outcome.TagBounds); + actualObstacles.LeaderProtectedObstacles.Add(outcome.TagBounds); } + foreach (var leaderSegment in outcome.LeaderSegments) actualObstacles.LeaderSegments.Add(leaderSegment); + } + else if (geometryAware) + { + doc.Delete(tag.Id); + errors.Add(new + { + elementId, + failureKind = "tag_unsupported_geometry_kind", + error = $"Geometry-aware placement is not yet supported for {tag.GetType().Name}. The unverified tag was removed." + }); + continue; } tagIds.Add(ElementIdCompat.GetValue(tag.Id)); @@ -409,7 +438,13 @@ public Task Handle(UIApplication app, string jsonData) evaluatedCount = geometryOutcomes.Count, collisionFreeCount = geometryOutcomes.Count(x => x.CollisionFree), repairedCount = geometryOutcomes.Count(x => x.Attempts > 1), - unresolvedCollisionCount = geometryOutcomes.Count(x => !x.CollisionFree) + unresolvedCollisionCount = geometryOutcomes.Count(x => !x.CollisionFree), + leaderAppliedCount = geometryOutcomes.Count(x => x.LeaderApplied), + actualLeaderReadbackCount = geometryOutcomes.Count(x => x.LeaderGeometryStatus == "actual"), + predictedLeaderFallbackCount = geometryOutcomes.Count(x => x.LeaderGeometryStatus == "predicted_fallback"), + unavailableLeaderGeometryCount = geometryOutcomes.Count(x => x.LeaderGeometryStatus == "unavailable"), + leaderCrossingCount = geometryOutcomes.Sum(x => x.LeaderCrossingCount), + leaderProtectedCrossingCount = geometryOutcomes.Sum(x => x.LeaderProtectedCrossingCount) } : null, tagIds, tags = tagReadback, @@ -520,7 +555,8 @@ private static Dictionary BuildGeometryPlans( double tagHeight, double clearance, string profile, - int maxCandidates) + int maxCandidates, + bool leaderEnabled) { var plans = new Dictionary(); var obstacles = CollectGeometryObstacles(doc, view, targets, tagWidth, tagHeight); @@ -529,10 +565,14 @@ private static Dictionary BuildGeometryPlans( { var targetId = ElementIdCompat.GetValue(target.Id); if (onlyUntagged && existingTagged.Contains(targetId)) continue; - var plan = BuildGeometryPlan(view, target, obstacles, tagWidth, tagHeight, clearance, profile, maxCandidates); + var plan = BuildGeometryPlan(view, target, obstacles, tagWidth, tagHeight, clearance, profile, maxCandidates, leaderEnabled); if (plan == null) continue; plans[targetId] = plan; - obstacles.Add(plan.Candidates[0].Bounds); + var selected = plan.Candidates.FirstOrDefault(candidate => candidate.CollisionFree); + if (selected == null) continue; + obstacles.HeadObstacles.Add(selected.Bounds); + obstacles.LeaderProtectedObstacles.Add(selected.Bounds); + if (leaderEnabled && selected.LeaderSegment != null) obstacles.LeaderSegments.Add(selected.LeaderSegment); } return plans; @@ -541,12 +581,13 @@ private static Dictionary BuildGeometryPlans( private static GeometryPlacementPlan? BuildGeometryPlan( View view, Element target, - IReadOnlyList obstacles, + GeometryObstacleSet obstacles, double tagWidth, double tagHeight, double clearance, string profile, - int maxCandidates) + int maxCandidates, + bool leaderEnabled) { var targetBounds = ToViewRect(target.get_BoundingBox(view), view); if (targetBounds == null) return null; @@ -555,7 +596,11 @@ private static Dictionary BuildGeometryPlans( var candidates = TagPlacementPlanner.RankCandidates(new TagPlacementRequest { Target = targetBounds, - Obstacles = obstacles, + Obstacles = obstacles.HeadObstacles, + SoftObstacles = obstacles.SoftHeadObstacles, + LeaderProtectedObstacles = obstacles.LeaderProtectedObstacles, + LeaderSegments = obstacles.LeaderSegments, + LeaderEnabled = leaderEnabled, TagWidth = tagWidth, TagHeight = tagHeight, Clearance = clearance, @@ -568,7 +613,8 @@ private static Dictionary BuildGeometryPlans( Target = target, TargetBounds = targetBounds, Candidates = candidates, - PlaneW = center.DotProduct(view.ViewDirection.Normalize()) + PlaneW = center.DotProduct(view.ViewDirection.Normalize()), + LeaderEnabled = leaderEnabled }; } @@ -584,37 +630,58 @@ private static object BuildGeometryPlanReadback(GeometryPlacementPlan plan) side = candidate.Side, collisionFree = candidate.CollisionFree, collisionCount = candidate.CollisionCount, + softObstacleCount = candidate.SoftObstacleCount, + softObstacleArea = candidate.SoftObstacleArea, + leaderLength = candidate.LeaderLength, + leaderCrossingCount = candidate.LeaderCrossingCount, + leaderProtectedCrossingCount = candidate.LeaderProtectedCrossingCount, + leaderSegment = candidate.LeaderSegment == null ? null : SegmentPayload(candidate.LeaderSegment), candidateCount = plan.Candidates.Count }; } - private static List CollectGeometryObstacles(Document doc, View view, IReadOnlyList targets, double tagWidth, double tagHeight) + private static GeometryObstacleSet CollectGeometryObstacles(Document doc, View view, IReadOnlyList targets, double tagWidth, double tagHeight) { - var obstacles = new List(); + var obstacles = new GeometryObstacleSet(); + var targetIds = new HashSet(targets.Select(target => ElementIdCompat.GetValue(target.Id))); foreach (var target in targets) { var rect = ToViewRect(target.get_BoundingBox(view), view); - if (rect != null) obstacles.Add(rect); + if (rect == null) continue; + obstacles.HeadObstacles.Add(rect); + obstacles.LeaderProtectedObstacles.Add(rect); } foreach (var element in new FilteredElementCollector(doc, view.Id).WhereElementIsNotElementType()) { - if (element?.Category?.CategoryType != CategoryType.Annotation) continue; - TagRect2? rect; - if (element is IndependentTag existingTag) + if (element?.Category == null) continue; + if (element.Category.CategoryType == CategoryType.Annotation) { - var head = existingTag.TagHeadPosition; - var right = view.RightDirection.Normalize(); - var up = view.UpDirection.Normalize(); - 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); + TagRect2? rect; + if (element is IndependentTag existingTag) + { + var head = existingTag.TagHeadPosition; + var right = view.RightDirection.Normalize(); + var up = view.UpDirection.Normalize(); + 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)) + 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 + { + rect = ToViewRect(element.get_BoundingBox(view), view); + } + if (rect == null) continue; + obstacles.HeadObstacles.Add(rect); + obstacles.LeaderProtectedObstacles.Add(rect); } - else + else if (!targetIds.Contains(ElementIdCompat.GetValue(element.Id)) && IsSoftPlanObstacle(element) && obstacles.SoftHeadObstacles.Count < 3000) { - rect = ToViewRect(element.get_BoundingBox(view), view); + var rect = ToViewRect(element.get_BoundingBox(view), view); + if (rect != null) obstacles.SoftHeadObstacles.Add(rect); } - if (rect != null) obstacles.Add(rect); } return obstacles; } @@ -624,7 +691,7 @@ private static GeometryPlacementOutcome RepairAndReadGeometryPlacement( View view, IndependentTag tag, GeometryPlacementPlan plan, - IReadOnlyList obstacles, + GeometryObstacleSet obstacles, double clearance, int maxAttempts) { @@ -654,22 +721,41 @@ private static GeometryPlacementOutcome RepairAndReadGeometryPlacement( if (tagBounds == null) continue; } - var collisionCount = obstacles.Count(x => tagBounds.Intersects(x.Inflate(clearance))); + var collisionCount = obstacles.HeadObstacles.Count(x => tagBounds.Intersects(x.Inflate(clearance))); + var softObstacleCount = obstacles.SoftHeadObstacles.Count(rect => tagBounds.Intersects(rect)); + var softObstacleArea = obstacles.SoftHeadObstacles + .Where(rect => tagBounds.Intersects(rect)) + .Sum(tagBounds.IntersectionArea); + var leaderSegment = plan.LeaderEnabled ? TagPlacementPlanner.BuildLeaderSegment(plan.TargetBounds, tagBounds) : null; + var leaderCrossingCount = leaderSegment == null ? 0 : obstacles.LeaderSegments.Count(segment => leaderSegment.Intersects(segment)); + var leaderProtectedCrossingCount = leaderSegment == null + ? 0 + : obstacles.LeaderProtectedObstacles.Count(rect => !SameRect(rect, plan.TargetBounds) && leaderSegment.CrossesInterior(rect.Inflate(clearance))); + var collisionFree = collisionCount == 0 && leaderCrossingCount == 0 && leaderProtectedCrossingCount == 0; var outcome = new GeometryPlacementOutcome { - Status = collisionCount == 0 ? (attempts == 1 ? "placed" : "repaired") : "collision", + Status = collisionFree ? (attempts == 1 ? "placed" : "repaired") : "collision", Side = candidate.Side, Attempts = attempts, CollisionCount = collisionCount, + SoftObstacleCount = softObstacleCount, + SoftObstacleArea = softObstacleArea, + LeaderSegment = leaderSegment, + LeaderSegments = leaderSegment == null ? new List() : new List { leaderSegment }, + LeaderLength = leaderSegment?.Length, + LeaderCrossingCount = leaderCrossingCount, + LeaderProtectedCrossingCount = leaderProtectedCrossingCount, + LeaderGeometryStatus = plan.LeaderEnabled ? "predicted" : "not_requested", OutsideTarget = !tagBounds.Intersects(plan.TargetBounds.Inflate(clearance)), - CollisionFree = collisionCount == 0, + CollisionFree = collisionFree, TargetBounds = plan.TargetBounds, TagBounds = tagBounds, TagHeadPosition = head, AnchorOffsetU = anchorCalibration.OffsetX, AnchorOffsetV = anchorCalibration.OffsetY }; - if (best == null || outcome.CollisionCount < best.CollisionCount) best = outcome; + if (best == null || PlacementFailureScore(outcome) < PlacementFailureScore(best) || + (PlacementFailureScore(outcome) == PlacementFailureScore(best) && outcome.SoftObstacleArea < best.SoftObstacleArea)) best = outcome; if (outcome.CollisionFree) return outcome; } @@ -693,11 +779,194 @@ private static GeometryPlacementOutcome RepairAndReadGeometryPlacement( }; } + private static int PlacementFailureScore(GeometryPlacementOutcome outcome) => + Math.Max(0, outcome.CollisionCount) + outcome.LeaderCrossingCount + outcome.LeaderProtectedCrossingCount; + + private static void ValidateAppliedLeader( + Document doc, + View view, + IndependentTag tag, + GeometryPlacementPlan plan, + GeometryObstacleSet obstacles, + double clearance, + GeometryPlacementOutcome outcome) + { + try + { + tag.HasLeader = true; + doc.Regenerate(); + outcome.LeaderApplied = tag.HasLeader; + } + catch + { + outcome.LeaderApplied = false; + } + + if (!outcome.LeaderApplied) + { + outcome.LeaderGeometryStatus = "apply_failed"; + outcome.CollisionFree = false; + return; + } + + var actualSegments = ReadIndependentTagLeaderSegments(tag, view); + if (actualSegments.Count == 0) + { + outcome.LeaderGeometryStatus = "unavailable"; + outcome.CollisionFree = false; + outcome.Status = "leader_geometry_unavailable"; + return; + } + + outcome.LeaderGeometryStatus = "actual"; + outcome.LeaderSegments = actualSegments; + outcome.LeaderSegment = actualSegments.FirstOrDefault(); + outcome.LeaderLength = actualSegments.Sum(segment => segment.Length); + outcome.LeaderCrossingCount = actualSegments.Sum(segment => obstacles.LeaderSegments.Count(existing => segment.Intersects(existing))); + outcome.LeaderProtectedCrossingCount = actualSegments.Sum(segment => + obstacles.LeaderProtectedObstacles.Count(rect => !SameRect(rect, plan.TargetBounds) && segment.CrossesInterior(rect.Inflate(clearance)))); + outcome.CollisionFree = outcome.CollisionCount == 0 && outcome.LeaderCrossingCount == 0 && outcome.LeaderProtectedCrossingCount == 0; + if (!outcome.CollisionFree) outcome.Status = "actual_leader_collision"; + } + + private static bool AddExistingTagLeaderSegments( + View view, + IndependentTag tag, + ICollection destination) + { + if (!tag.HasLeader) return true; + var actual = ReadIndependentTagLeaderSegments(tag, view); + if (actual.Count > 0) + { + foreach (var segment in actual) destination.Add(segment); + return true; + } + return false; + } + + private static List ReadIndependentTagLeaderSegments(IndependentTag tag, View view) + { + var segments = new List(); + try + { + if (!tag.HasLeader) return segments; + var head = tag.TagHeadPosition; + var references = TryReadTaggedReferences(tag); + if (references.Count > 0) + { + foreach (var reference in references) + { + var end = TryInvokeXyzMethod(tag, "GetLeaderEnd", reference); + var elbow = TryInvokeXyzMethod(tag, "GetLeaderElbow", reference); + AddLeaderPath(end, elbow, head, view, segments); + } + } + else + { + AddLeaderPath( + TryReadXyzProperty(tag, "LeaderEnd"), + TryReadXyzProperty(tag, "LeaderElbow"), + head, + view, + segments); + } + } + catch + { + segments.Clear(); + } + return segments; + } + + private static void AddLeaderPath(XYZ? end, XYZ? elbow, XYZ head, View view, ICollection destination) + { + if (end == null) return; + if (elbow != null) + { + AddLeaderSegment(end, elbow, view, destination); + AddLeaderSegment(elbow, head, view, destination); + return; + } + AddLeaderSegment(end, head, view, destination); + } + + private static List TryReadTaggedReferences(IndependentTag tag) + { + try + { + var method = tag.GetType().GetMethod("GetTaggedReferences", BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); + if (method?.Invoke(tag, null) is System.Collections.IEnumerable values) + return values.Cast().Where(value => value != null).ToList(); + } + catch + { + // Legacy Revit versions expose leader geometry as properties instead. + } + return new List(); + } + + private static XYZ? TryInvokeXyzMethod(object source, string methodName, object argument) + { + try + { + var method = source.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(candidate => candidate.Name == methodName && candidate.GetParameters().Length == 1); + return method?.Invoke(source, new[] { argument }) as XYZ; + } + catch + { + return null; + } + } + + private static void AddLeaderSegment(XYZ start, XYZ end, View view, ICollection destination) + { + var right = view.RightDirection.Normalize(); + var up = view.UpDirection.Normalize(); + var segment = new TagSegment2( + start.DotProduct(right), + start.DotProduct(up), + end.DotProduct(right), + end.DotProduct(up)); + if (segment.Length > 1e-9) destination.Add(segment); + } + + private static XYZ? TryReadXyzProperty(object source, string propertyName) + { + try + { + return source.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public)?.GetValue(source, null) as XYZ; + } + catch + { + return null; + } + } + + private static bool IsSoftPlanObstacle(Element element) + { + if (element is Wall || element is FamilyInstance) return true; + var categoryId = ElementIdCompat.GetValue(element.Category?.Id); + return categoryId == (long)BuiltInCategory.OST_DuctCurves || + categoryId == (long)BuiltInCategory.OST_PipeCurves || + categoryId == (long)BuiltInCategory.OST_CableTray || + categoryId == (long)BuiltInCategory.OST_Conduit || + categoryId == (long)BuiltInCategory.OST_RoomSeparationLines; + } + + 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 && + Math.Abs(left.MaxX - right.MaxX) <= tolerance && + Math.Abs(left.MaxY - right.MaxY) <= tolerance; + private static XYZ? ResolveElementCenter(Element element, View view) { if (element.Location is LocationPoint lp && lp.Point != null) return lp.Point; var bbox = element.get_BoundingBox(view); - return bbox == null ? null : (bbox.Min + bbox.Max) * 0.5; + if (bbox == null) return null; + var localCenter = (bbox.Min + bbox.Max) * 0.5; + return bbox.Transform?.OfPoint(localCenter) ?? localCenter; } private static TagRect2? ToViewRect(BoundingBoxXYZ? bbox, View view) @@ -709,7 +978,10 @@ private static GeometryPlacementOutcome RepairAndReadGeometryPlacement( foreach (var x in new[] { bbox.Min.X, bbox.Max.X }) foreach (var y in new[] { bbox.Min.Y, bbox.Max.Y }) foreach (var z in new[] { bbox.Min.Z, bbox.Max.Z }) - points.Add(new XYZ(x, y, z)); + { + var localPoint = new XYZ(x, y, z); + points.Add(bbox.Transform?.OfPoint(localPoint) ?? localPoint); + } return new TagRect2( points.Min(p => p.DotProduct(right)), points.Min(p => p.DotProduct(up)), @@ -735,6 +1007,15 @@ private static XYZ FromViewCoordinates(View view, double u, double v, double w) centerV = rect.CenterY }; + private static object SegmentPayload(TagSegment2 segment) => new + { + startU = segment.StartX, + startV = segment.StartY, + endU = segment.EndX, + endV = segment.EndY, + length = segment.Length + }; + private static object? TagFamilyPayload(TagFamilyResolution? resolution) => resolution == null ? null : new { status = resolution.Status, @@ -1403,9 +1684,16 @@ private static object BuildTagReadback(Document doc, Element tag, Element target side = geometryOutcome.Side, attempts = geometryOutcome.Attempts, collisionCount = geometryOutcome.CollisionCount, + softObstacleCount = geometryOutcome.SoftObstacleCount, + softObstacleArea = geometryOutcome.SoftObstacleArea, outsideTarget = geometryOutcome.OutsideTarget, collisionFree = geometryOutcome.CollisionFree, leaderApplied = geometryOutcome.LeaderApplied, + leaderGeometryStatus = geometryOutcome.LeaderGeometryStatus, + leaderLength = geometryOutcome.LeaderLength, + leaderCrossingCount = geometryOutcome.LeaderCrossingCount, + leaderProtectedCrossingCount = geometryOutcome.LeaderProtectedCrossingCount, + leaderSegments = geometryOutcome.LeaderSegments.Select(SegmentPayload).ToList(), targetBounds = geometryOutcome.TargetBounds == null ? null : RectPayload(geometryOutcome.TargetBounds), tagBounds = geometryOutcome.TagBounds == null ? null : RectPayload(geometryOutcome.TagBounds), anchorOffset = geometryOutcome.AnchorOffsetU == null || geometryOutcome.AnchorOffsetV == null ? null : new diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs index 55bdcfb..3066aa5 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs @@ -181,7 +181,7 @@ internal static class OperatorToolManifest new OperatorToolInfo("Drafting", "POST", "/revit/create-filled-region", "Create Filled Region", OperatorActionRisk.Medium, "Create filled regions, list filled region types, or create a filled region type. Actions: create|list_types|create_type. Body: {action?,viewId?,frameId?,typeId?|typeName?,newTypeName?,sourceTypeId?|sourceTypeName?,fillPatternName?,allowExisting?,loops?,dryRun?}", "create a filled region type with hatch and apply it"), new OperatorToolInfo("Drafting", "POST", "/revit/create-revision-cloud", "Create Revision Cloud", OperatorActionRisk.Medium, "Create a revision cloud in a view, with optional one-call tagging of the created cloud. Body: {viewId,frameId?,points:[...],revisionId?,closed?,tagCreatedCloud?,tagHasLeader?,dryRun?}", "create a revision cloud around this outline"), new OperatorToolInfo("Drafting", "POST", "/revit/keynotes", "Keynote Tools", OperatorActionRisk.Medium, "Query and update keynote data. Actions: list_tags, list_elements_missing_keynote, set_element_keynote. Body: {action?,viewId?,elementIds?,categoryNames?,keynoteValue?,max?,dryRun?}.", "list keynote tags in a view"), - new OperatorToolInfo("Drafting", "POST", "/revit/tag-elements", "Tag Elements", OperatorActionRisk.Medium, "Create tags for elements by explicit ids and/or category selectors in a target view, with duplicate avoidance, optional per-category tag-type mapping, and additive geometry-aware placement/readback. geometry_aware places tag heads outside target footprints, avoids annotation/target/tag collisions, uses discipline profiles, and repairs against actual tag bounds. If no compatible tag is loaded, autoLoadTagFamily can import an exactly category-compatible tag from a workspace-scoped source project, optionally selecting an exact source family/type; dry-run reports bounded source candidates. generatedTagContentProfile=airflow_only produces a copied source family that retains bounded flow-unit labels and removes unrelated text/label content, with an explicit sanitization receipt. A dry-run may set inspectTagFamilyElements=true to return a bounded read-only inventory of the selected tag family's internal elements and parameters. Tags with no visible/measurable head geometry are removed and reported as failures. Body: {viewId?|viewName?,elementIds?|categoryNames?,categoryTagTypeMap?,tagTypeId?|tagTypeName?|tagFamilyName?,onlyUntagged?,addLeader?,orientation?:horizontal|vertical,offsetX?,offsetY?,placementMode?:offset|geometry_aware,placementProfile?:auto|mep|electrical|architectural,tagWidthPaperInches?,tagHeightPaperInches?,clearancePaperInches?,maxRepairAttempts?,autoLoadTagFamily?,tagFamilySourceProjectPath?,tagFamilySourceCategory?,tagFamilySourceFamilyName?,tagFamilySourceTypeName?,generatedTagFamilyName?,generatedTagContentProfile?:none|airflow_only,inspectTagFamilyElements?,max?,dryRun?}.", "tag all untagged air terminals in L4 with geometry-aware MEP placement"), + new OperatorToolInfo("Drafting", "POST", "/revit/tag-elements", "Tag Elements", OperatorActionRisk.Medium, "Create tags for elements by explicit ids and/or category selectors in a target view, with duplicate avoidance, optional per-category tag-type mapping, and additive geometry-aware placement/readback. geometry_aware places tag heads outside target footprints, avoids annotation/target/tag collisions, prefers clear plan space over walls and modeled equipment, keeps leaders bounded, rejects leader crossings and protected-content routes, uses discipline profiles, and requires actual Revit tag and leader geometry before retaining created work. If no compatible tag is loaded, autoLoadTagFamily can import an exactly category-compatible tag from a workspace-scoped source project, optionally selecting an exact source family/type; dry-run reports bounded source candidates. generatedTagContentProfile=airflow_only produces a copied source family that retains bounded flow-unit labels and removes unrelated text/label content, with an explicit sanitization receipt. A dry-run may set inspectTagFamilyElements=true to return a bounded read-only inventory of the selected tag family's internal elements and parameters. Tags with no visible/measurable head geometry or no professionally clear tag/leader route are removed and reported as failures. Body: {viewId?|viewName?,elementIds?|categoryNames?,categoryTagTypeMap?,tagTypeId?|tagTypeName?|tagFamilyName?,onlyUntagged?,addLeader?,orientation?:horizontal|vertical,offsetX?,offsetY?,placementMode?:offset|geometry_aware,placementProfile?:auto|mep|electrical|architectural,tagWidthPaperInches?,tagHeightPaperInches?,clearancePaperInches?,maxRepairAttempts?,autoLoadTagFamily?,tagFamilySourceProjectPath?,tagFamilySourceCategory?,tagFamilySourceFamilyName?,tagFamilySourceTypeName?,generatedTagFamilyName?,generatedTagContentProfile?:none|airflow_only,inspectTagFamilyElements?,max?,dryRun?}.", "tag all untagged air terminals in L4 with geometry-aware MEP placement"), new OperatorToolInfo("Drafting", "POST", "/revit/create-dimension", "Create Dimension", OperatorActionRisk.Medium, "Create dimensions (annotations).", "dimension between these walls"), new OperatorToolInfo("Modeling", "POST", "/revit/create-sheet", "Create Sheet", OperatorActionRisk.High, "Create a real or placeholder sheet, or convert a placeholder to real. Auto titleblock now defaults to the most-used/adjacent non-one-off titleblock, not collector-first. Body: {name?,number?,titleBlockId?(-1=auto),titleBlockName?,referenceSheetNumber?,placeholder?,convertPlaceholderSheetId?}.", "create sheet E400 enlarged electrical plans"),