# -*- coding: utf-8 -*-
"""SPOT COORDINATE ALIGNER v5.5 - Dynamo / CPython3 / Revit 2026

Paste this complete file into one Python node. Manual run; OUT -> Watch.
IN[0] = minimum row spacing, MODEL inches (18 suggested at view scale 48)
IN[1] = minimum horizontal landing, MODEL inches (48 suggested)
IN[2] = minimum 45-degree X/Y component, MODEL inches (12 suggested)
Use exactly THREE connected inputs, IN[0..2], for the normal setup.
DELETE the direction String/Boolean nodes and remove their unused input ports
with '-'. Direction is chosen again on EVERY placement click:
upper-right = UR; upper-left = UL; lower-right = LR; lower-left = LL.
The center of the selected reference origins defines these quadrants in the view.
No direction input is needed. A legacy UR/UL/LR/LL input, if present, is ignored.
For MANUAL text calibration only, add IN[3] and CONNECT a string 'MANUAL'.
Legacy calibration at IN[4]/IN[5] is also accepted.
Keep ONE copy of this Python node in the main graph, using Manual run mode.
Each Run starts a NEW selection. Completion, Esc and errors prepare the node
for the next Run without changing inputs or registering persistent callbacks.
Run-node lookup is optional housekeeping: lookup failure is reported in Watch
and cannot prevent the Revit selection call. Revit is activated before picking.

LEFT alignment means the left edge of each complete two-line text block.
The shared text column stays NEXT TO THE SYMBOL, at the far end of the landing.
Its left edge is derived from the symbol center and widest text block, NOT
from the shoulder. The widest text retains the previous gap from the end symbol;
shorter blocks share its left edge and have more space before the symbol.
This does NOT change internal N/E justification or any SpotDimension type.
Placement uses Revit's default PickPoint snapping (no forced ObjectSnapTypes.None).
No permanent type, prefix, suffix, reference, coordinate or symbol changes.

Run -> select spots -> Finish -> calibrate if needed -> repeatedly click.
No script confirmation/result dialogs. Read selection prompts in Revit's status
bar; details, warnings and errors are in OUT -> Watch. Native Revit failure
handling is unchanged. Selection's native Finish button is still required.
Each click updates the drawing. This is click-to-update, not mouse-hover drag.
ESC during placement keeps the last successful placement and exits. ESC before
any successful placement leaves the model unchanged. All successful clicks form
ONE Revit Undo item. A failed click rolls back only that placement.

Picked point = requested TOP symbol center (native LeaderEndPosition), in the view.
All subsequent rows go down by at least IN[0]. If there is enough space, the symbol center
stays at the snapped X/Y. To move inward, the solver first keeps the picked X
and adjusts the block height only as much as needed to shorten the 45-degree
diagonals. It moves X outward ONLY if no height can fit the selected spots,
minimum lengths and full text at that X. Lack of space alone never rejects a
click. The actual X/Y offset is shown in the next status-bar prompt and Watch.
There is no arbitrary maximum travel distance. The remaining inward limit is
calculated from the selected origins, row spacing, minimum lengths and text.
Shoulders are NOT aligned vertically. Horizontal landings vary with the origins.
Rows are ordered by their 45-degree diagonal paths, recalculated for each quadrant.
This prevents one selected spot's diagonal from crossing another's landing.
The row order may change when the click changes direction. Each spot keeps its
own reference and coordinate text; labels are never swapped between elements.
Row spacing grows automatically when needed to fit the measured text height,
the 1 mm paper text-to-line gap and an additional 1 mm paper clearance.
Crossing/overlapping selected leader segments are checked before committing.
If origins share an unavoidable diagonal path in the chosen direction, that
click is rejected with the affected IDs; try another quadrant or separate them.
This check covers the selected spots, not unrelated drawing/model geometry.
Landing length automatically covers the widest text + 2 mm paper padding/side.
Distances are model inches: printed mm = input inches * 25.4 / view.Scale.

Every placement rebuilds the leader's native shoulder state inside the click's
transaction. The end is placed with the shoulder temporarily disabled, then
the shoulder is restored before setting bend/end/text. This removes the old
landing direction from Revit's LeaderEndPosition projection constraint. The
leader, shoulder, reference and type must be intact before the click commits.
The original spot elements are retained. No delete/recreate or element movement.
Last attempt and the last 20 attempts distinguish geometry fitting from a
Revit handle rejection. Measurements are rebuilt for each Run, never stored
globally or grown using the previous placement's bounding box.

AUTO text measurement: temporary duplicate type, Symbol=None, Leader=False,
translation probe, then rollback. Runtime rejection invokes MANUAL calibration
for that spot. MANUAL requires two clicks: text block bottom-left / top-right.
Use MANUAL if AUTO returns a box whose visible edge is inaccurate for the type.
Axis-rotated views use MANUAL because global bounding boxes inflate text extents.
Calibration is in view coordinates and is reused within the placement session.

Verification includes original origins/references/type/parameters, 45-degree
shoulder and end positions, text anchor placement and line coverage. Text extent
offsets are cached for this session; visual review of the final native text is
required. Geometry/lifecycle simulations are NOT live Revit tests.

If there is no active work plane, set one in Revit before running.
Change row spacing between sessions, not while Dynamo is waiting for clicks.

API sources checked 2026-09-29 (official Autodesk 2026 reference):
SpotDimension: f3c633ac-1595-cb8d-5c1b-66eb3eefb433.htm
LeaderShoulderPosition: cb42a9d3-8a01-433c-46fb-2e390218f67a.htm
LeaderEndPosition: cf75655f-f779-5c31-5ea5-dc27839c46d5.htm
LeaderHasShoulder: 7285e050-0673-cd70-64c4-de7df60f05c2.htm
TextPosition: 2a3b8401-48c9-8425-1f98-b142babdf0a2.htm
SpotDimensionType: e653cdb6-319d-6c7a-c5b0-16992144e6f6.htm
PickPoint: 80e5e8e6-42af-c1b8-6ce1-db598ea166fb.htm
UIApplication.MainWindowHandle: e28d23a9-6814-1e70-9943-1ee852887dae.htm
TransactionGroup.Assimilate: 158471e4-5554-16ed-f9bf-f7499b86309c.htm
Window activation: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow

Dynamo Run handling checked against DynamoDS/Dynamo v3.6.2:
src/DynamoCore/Scheduler/UpdateGraphAsyncTask.cs (clears flags BEFORE execution)
src/DynamoCore/Graph/Nodes/NodeModel.cs (MarkNodeAsModified(forceExecute=True))
src/DynamoCore/Models/RunSettings.cs (Manual=0, Automatic=1, Periodic=2)
https://github.com/DynamoDS/Dynamo/tree/v3.6.2
"""
# SPOT_ALIGNER_ENTRY_V5_5

import math
import traceback
import uuid
import clr

clr.AddReference("RevitAPI")
clr.AddReference("RevitAPIUI")
clr.AddReference("RevitServices")

from System.Collections.Generic import List
from Autodesk.Revit.DB import (
    BuiltInCategory, BuiltInParameter, ElementId, SpotDimension, XYZ,
    Transaction, TransactionGroup, TransactionStatus, StorageType,
    UnitUtils, UnitTypeId, ViewType
)
from Autodesk.Revit.UI.Selection import ObjectType
from Autodesk.Revit.Exceptions import OperationCanceledException, RegenerationFailedException
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager

VERSION = "5.5"
TITLE = "Spot Aligner v5.5"
NODE_MARKER = "# SPOT_ALIGNER_ENTRY_V5_5"


class PlacementRejected(Exception):
    pass


def choose_direction(points, pick_x, pick_y):
    """Use reference origins in view coordinates, not the previous text positions."""
    if not points:
        raise ValueError("Empty point selection.")
    center_x = (min(p[1] for p in points) + max(p[1] for p in points)) / 2.0
    center_y = (min(p[2] for p in points) + max(p[2] for p in points)) / 2.0
    return ("U" if pick_y >= center_y else "L") + ("R" if pick_x >= center_x else "L")


def solve_layout(points, pick_x, pick_y, spacing, minimum_landing,
                 diagonal, mode, max_width, padding):
    """Keep picked X if feasible; use the nearest feasible height at that X.

    In signed coordinates U = side * end_x and V = vertical * top_y,
    every row requires V >= lower_v and U - V >= landing_offset.
    Solving these together lets an inward click shorten the diagonals instead
    of always pushing the column outward at its previous/requested height.
    """
    if not points:
        raise ValueError("Empty point selection.")
    if mode not in ("UR", "UL", "LR", "LL"):
        raise ValueError("Invalid mode.")
    if min(spacing, minimum_landing, diagonal, max_width, padding) <= 0:
        raise ValueError("Distances must be positive.")
    vertical = 1.0 if mode in ("UR", "UL") else -1.0
    side = 1.0 if mode in ("UR", "LR") else -1.0
    # A diagonal has y - (vertical * side) * x = constant. Ordering that
    # intercept from high to low keeps the horizontal landings outside all
    # other diagonal segments. Sorting only origin Y crossed same-height spots.
    slope = vertical * side
    points = sorted(points, key=lambda p: (-(p[2] - slope * p[1]), -p[2], p[0]))
    needed = max(minimum_landing, max_width + 2.0 * padding)
    shifted = [(x, y + i * spacing) for i, (_, x, y) in enumerate(points)]
    lower_v = max(vertical * y + diagonal for x, y in shifted)
    landing_offset = max(side * x - vertical * y + needed for x, y in shifted)
    actual_u = max(side * pick_x, lower_v + landing_offset)
    upper_v = max(lower_v, actual_u - landing_offset)  # guard roundoff
    actual_v = min(max(vertical * pick_y, lower_v), upper_v)
    end_x, top_y = side * actual_u, vertical * actual_v
    rows = []
    for i, (key, x, y) in enumerate(points):
        row_y = top_y - i * spacing
        shoulder_x = x + side * abs(row_y - y)
        rows.append((key, shoulder_x, row_y, end_x))
    if side > 0:
        text_right = end_x - padding
    else:
        text_right = end_x + padding + max_width
    return rows, text_right, top_y


def write_spot_handles(spot, bend, end, text, regenerate, error_xy, tolerance):
    """Rebuild the landing axis before using Revit's projected end setter.

    Caller owns the transaction and rolls back on ANY failure. Do not commit
    while the shoulder is disabled. Do not cache a handle from an earlier click.
    """
    if not spot.HasLeader or not spot.LeaderHasShoulder:
        raise PlacementRejected("Leader and Shoulder must both be enabled.")
    spot.LeaderHasShoulder = False
    regenerate()
    spot.LeaderEndPosition = end
    regenerate()
    if error_xy(spot.LeaderEndPosition, end) > tolerance:
        raise PlacementRejected("Revit constrained the leader end even without a shoulder.")
    spot.LeaderHasShoulder = True
    regenerate()
    spot.LeaderShoulderPosition = bend
    regenerate()
    spot.LeaderEndPosition = end
    regenerate()
    spot.TextPosition = text
    regenerate()


def segments_intersect(a, b, c, d, tolerance=1e-7):
    """Include collinear overlap and touching, with tolerance in model feet."""
    for axis in (0, 1):
        if (max(a[axis], b[axis]) < min(c[axis], d[axis]) - tolerance or
                max(c[axis], d[axis]) < min(a[axis], b[axis]) - tolerance):
            return False

    def orientation(p, q, r):
        dx, dy = q[0] - p[0], q[1] - p[1]
        length = math.hypot(dx, dy)
        cross = dx * (r[1] - p[1]) - dy * (r[0] - p[0])
        limit = tolerance * length
        return 1 if cross > limit else (-1 if cross < -limit else 0)

    return (orientation(a, b, c) * orientation(a, b, d) <= 0 and
            orientation(c, d, a) * orientation(c, d, b) <= 0)


def check_leader_conflicts(points, rows, actual_ends=None):
    """Check different selected leaders only; each leader's own elbow is valid."""
    origins = {key: (x, y) for key, x, y in points}
    routes = [(key, ((origins[key], (bx, y)),
                     ((bx, y), actual_ends[key] if actual_ends is not None else (ex, y))))
              for key, bx, y, ex in rows]
    for i, (key, segments) in enumerate(routes):
        for other, other_segments in routes[i + 1:]:
            if any(segments_intersect(a, b, c, d)
                   for a, b in segments for c, d in other_segments):
                raise PlacementRejected(
                    "Spots {} / {}: leader paths overlap in this direction. "
                    "Click another quadrant or align these spots separately.".format(key, other))


def placement_loop(next_point, apply_point, on_reject, on_update):
    """A None pick finishes. Rejected updates never replace the last good one."""
    accepted, rejected, last = 0, 0, None
    while True:
        point = next_point()
        if point is None:
            return {"Accepted": accepted, "Rejected": rejected, "Last": last}
        try:
            result = apply_point(point)
        except PlacementRejected as error:
            rejected += 1
            on_reject(error)
            continue
        accepted += 1
        last = result
        on_update(result)


def positive(value, label):
    if isinstance(value, bool):
        raise ValueError(label + " must be numeric.")
    value = float(value)
    if not math.isfinite(value) or value <= 0:
        raise ValueError(label + " must be finite and positive.")
    return value


def measurement_mode(inputs):
    for value in inputs[3:6]:
        if value is None or isinstance(value, bool):
            continue
        mode = str(value).strip().upper()
        if mode in ("UR", "UL", "LR", "LL"):
            continue  # Legacy direction input is no longer used.
        if mode in ("AUTO", "MANUAL"):
            return mode
        raise ValueError("Optional text-calibration input must be AUTO or MANUAL.")
    return "AUTO"


def find_run_node():
    """Resolve the host via CLR metadata, not a Python namespace import."""
    clr.AddReference("DynamoCore")
    assembly = clr.AddReference("DynamoRevitDS")
    from System.Reflection import BindingFlags
    flags = BindingFlags.Public | BindingFlags.Static
    host_type = assembly.GetType("Dynamo.Applications.DynamoRevit")
    prop = host_type.GetProperty("RevitDynamoModel", flags) if host_type is not None else None
    if prop is None:
        # Some Dynamo hosts expose the same public property under another type.
        properties = [t.GetProperty("RevitDynamoModel", flags)
                      for t in assembly.GetExportedTypes()]
        properties = [p for p in properties if p is not None]
        if len(properties) != 1:
            raise RuntimeError("Cannot resolve the Dynamo Revit host model.")
        prop = properties[0]
    model = prop.GetValue(None, None)
    if model is None or model.CurrentWorkspace is None:
        raise RuntimeError("Dynamo's active workspace is unavailable.")
    workspace = model.CurrentWorkspace
    matches = [n for n in workspace.Nodes
               if NODE_MARKER in str(getattr(n, "Script", ""))]
    if len(matches) != 1:
        raise RuntimeError("Keep exactly one Spot Aligner v5.5 Python node in this graph.")
    return matches[0], workspace


def normalize_run_mode(value):
    """CPython can marshal RunType as 0/1/2 rather than an enum name."""
    if value is None:
        return None
    token = str(value).strip().lower()
    names = {"manual": "Manual", "automatic": "Automatic", "periodic": "Periodic"}
    if token.rsplit(".", 1)[-1] in names:
        return names[token.rsplit(".", 1)[-1]]
    return {"0": "Manual", "1": "Automatic", "2": "Periodic"}.get(
        token, "Unknown ({})".format(value))


def prepare_run_context(report):
    """Optional rerun setup must never abort selection on lookup failure."""
    try:
        node, workspace = find_run_node()
        run_settings = getattr(workspace, "RunSettings", None)
        raw_mode = run_settings.RunType if run_settings is not None else None
        mode = normalize_run_mode(raw_mode)
        report["Dynamo run mode raw"] = str(raw_mode)
        report["Dynamo run mode"] = mode or "Unavailable"
        return node, mode
    except Exception as ex:
        report["Run setup warning"] = str(ex)
        report["Warnings"].append("Rerun setup unavailable; selection will still start: " + str(ex))
        return None, None


def activate_revit(uiapp, report):
    """Bring Revit forward without a TaskDialog; failure does not block picking."""
    try:
        import ctypes
        handle = int(uiapp.MainWindowHandle.ToInt64())
        if not handle:
            raise RuntimeError("Revit main-window handle is unavailable.")
        user32 = ctypes.WinDLL("user32", use_last_error=True)
        user32.IsIconic.argtypes = [ctypes.c_void_p]
        user32.IsIconic.restype = ctypes.c_int
        user32.ShowWindow.argtypes = [ctypes.c_void_p, ctypes.c_int]
        user32.ShowWindow.restype = ctypes.c_int
        user32.SetForegroundWindow.argtypes = [ctypes.c_void_p]
        user32.SetForegroundWindow.restype = ctypes.c_int
        user32.GetForegroundWindow.argtypes = []
        user32.GetForegroundWindow.restype = ctypes.c_void_p
        if user32.IsIconic(handle):
            user32.ShowWindow(handle, 9)  # SW_RESTORE, only if minimized.
        activated = bool(user32.SetForegroundWindow(handle))
        report["Revit focused"] = activated or user32.GetForegroundWindow() == handle
        if not report["Revit focused"]:
            report["Warnings"].append("Switch to the Revit window to use the active selection prompt.")
    except Exception as ex:
        report["Revit focused"] = False
        report["Warnings"].append("Automatic Revit focus unavailable: " + str(ex))


def prepare_next_run(node, report):
    """Set the force-execute flag only; do not request an immediate graph run."""
    if node is None:
        try:
            node, _ = find_run_node()
        except Exception as ex:
            report["Next Run"] = "Not ready: " + str(ex)
            report["Warnings"].append("Selection completed, but the next Run could not be prepared: " + str(ex))
            return
    try:
        node.MarkNodeAsModified(True)
        if not node.IsModified or not node.NeedsForceExecution:
            raise RuntimeError("Dynamo did not retain the force-execute flag.")
        report["Next Run"] = "Ready: click Run for a new selection."
    except Exception as ex:
        report["Next Run"] = "Not ready: " + str(ex)
        report["Warnings"].append("Could not prepare the next Run: " + str(ex))


def reference_keys(spot, doc):
    return tuple(spot.References.get_Item(i).ConvertToStableRepresentation(doc)
                 for i in range(spot.References.Size))


def text_parameters(spot):
    """Snapshot string instance parameters (including coordinate prefixes)."""
    return tuple(sorted((int(p.Id.Value), p.AsString()) for p in spot.Parameters
                        if p.StorageType == StorageType.String))


def rollback_started(transaction):
    if transaction.GetStatus() == TransactionStatus.Started:
        status = transaction.RollBack()
        if status != TransactionStatus.RolledBack:
            raise RuntimeError("Rollback did not complete: " + str(status))


REPORT = {"Version": VERSION, "Status": "Starting", "Stage": "Inputs",
          "Aligned": 0, "Successful clicks": 0, "Rejected clicks": [], "Warnings": [],
          "Rejected click count": 0, "Attempt count": 0, "Recent attempts": []}


def main():
    if len(IN) < 3:
        raise ValueError("Connect three inputs: spacing, minimum landing, diagonal.")
    numbers = [positive(IN[i], "IN[{}]".format(i)) for i in range(3)]
    spacing, landing, diagonal = [UnitUtils.ConvertToInternalUnits(v, UnitTypeId.Inches)
                                  for v in numbers]
    measure_mode = measurement_mode(IN)

    uiapp = DocumentManager.Instance.CurrentUIApplication
    uidoc = uiapp.ActiveUIDocument
    if uidoc is None:
        raise ValueError("Open a Revit project view first.")
    doc, view = uidoc.Document, uidoc.Document.ActiveView
    if view.ViewType not in (ViewType.FloorPlan, ViewType.CeilingPlan,
                             ViewType.EngineeringPlan, ViewType.Section, ViewType.Elevation):
        raise ValueError("Use a plan, section or elevation view.")
    if doc.IsReadOnly:
        raise ValueError("The document is read-only.")
    right, up = view.RightDirection.Normalize(), view.UpDirection.Normalize()
    tol = max(1e-7, 0.02 * view.Scale / 304.8)
    padding = 2.0 * view.Scale / 304.8
    text_gap = 1.0 * view.Scale / 304.8

    def sx(p):
        return p.DotProduct(right)

    def sy(p):
        return p.DotProduct(up)

    def at(base, x, y):
        return base.Add(right.Multiply(x - sx(base))).Add(up.Multiply(y - sy(base)))

    def error_xy(a, b):
        return math.hypot(sx(a) - sx(b), sy(a) - sy(b))

    def refresh():
        uidoc.RefreshActiveView()

    def pick(message):
        if doc.IsModifiable:
            raise RuntimeError("Internal error: a transaction was left open before selection.")
        # Use the standard picker without overriding snaps to None.
        return uidoc.Selection.PickPoint(message)

    REPORT.update({"Mode": "AUTO - direction follows each click", "View": view.Name, "View scale": view.Scale,
                   "Inputs (model inches)": numbers, "Measurement mode": measure_mode})
    TransactionManager.Instance.ForceCloseTransaction()
    activate_revit(uiapp, REPORT)
    REPORT["Stage"] = "Select spots"
    REPORT["Selection requested"] = True
    refs = uidoc.Selection.PickObjects(ObjectType.Element,
                                      "Select Spot Coordinates, then Finish | ESC: cancel")
    category = ElementId(BuiltInCategory.OST_SpotCoordinates)
    data, rejected = {}, []
    for ref in refs:
        key = int(ref.ElementId.Value)
        if key in data:
            continue
        try:
            e = doc.GetElement(ref.ElementId)
            if not isinstance(e, SpotDimension) or e.Category is None or not e.Category.Id.Equals(category):
                raise ValueError("Not a Spot Coordinate.")
            if not e.OwnerViewId.Equals(view.Id):
                raise ValueError("Not owned by the active view.")
            if e.Pinned or not e.GroupId.Equals(ElementId.InvalidElementId):
                raise ValueError("Pinned/grouped spot is unsupported.")
            if not e.HasLeader or not e.LeaderHasShoulder:
                raise ValueError("Enable Leader and Shoulder first.")
            if e.NumberOfSegments > 1:
                raise ValueError("Multi-segment spot is unsupported.")
            if e.TextPosition is None or e.Origin is None or e.LeaderEndPosition is None:
                raise ValueError("Required position is unavailable.")
            keys = reference_keys(e, doc)
            if not keys:
                raise ValueError("Geometry reference unavailable.")
            data[key] = {"id": e.Id, "origin": e.Origin, "type": e.GetTypeId(),
                         "refs": keys, "strings": text_parameters(e)}
        except Exception as ex:
            rejected.append("{}: {}".format(key, ex))
    if rejected:
        raise ValueError("Selection rejected; no changes.\n" + "\n".join(rejected))
    if not data:
        raise ValueError("No spots selected.")

    def bounds_of(e):
        box = e.get_BoundingBox(view)
        if box is None:
            raise RuntimeError("Text bounds unavailable.")
        pts = [box.Transform.OfPoint(XYZ(x, y, z))
               for x in (box.Min.X, box.Max.X)
               for y in (box.Min.Y, box.Max.Y)
               for z in (box.Min.Z, box.Max.Z)]
        return [min(sx(p) for p in pts), max(sx(p) for p in pts),
                min(sy(p) for p in pts), max(sy(p) for p in pts)]

    def native_measure(d):
        if any(max(abs(a.X), abs(a.Y), abs(a.Z)) < 1.0 - 1e-8 for a in (right, up)):
            raise ValueError("Rotated view needs visual calibration.")
        tx = Transaction(doc, "Temporary spot text measurement")
        try:
            if tx.Start() != TransactionStatus.Started:
                raise RuntimeError("Cannot start measurement transaction.")
            e = doc.GetElement(d["id"])
            temp = e.SpotDimensionType.Duplicate("SpotMeasure_" + uuid.uuid4().hex)
            symbol = temp.get_Parameter(BuiltInParameter.SPOT_ELEV_SYMBOL)
            if symbol is None or symbol.IsReadOnly or not symbol.Set(ElementId.InvalidElementId):
                raise ValueError("Cannot isolate text from the symbol.")
            e.SpotDimensionType = temp
            e.HasLeader = False
            doc.Regenerate()
            a, b = e.TextPosition, bounds_of(e)
            shift = 10.0 * view.Scale / 304.8
            e.TextPosition = a.Add(right.Multiply(shift)).Add(up.Multiply(shift))
            doc.Regenerate()
            c, moved = bounds_of(e), e.TextPosition
            if error_xy(moved, a.Add(right.Multiply(shift)).Add(up.Multiply(shift))) > tol:
                raise ValueError("Text measurement movement was constrained.")
            if any(abs(after - before - shift) > tol for before, after in zip(b, c)):
                raise ValueError("Bounds still contain non-text geometry.")
            if min(b[1] - b[0], b[3] - b[2]) <= tol:
                raise ValueError("Invalid text extents.")
            return {"left": b[0] - sx(a), "right": b[1] - sx(a),
                    "bottom": b[2] - sy(a), "height": b[3] - b[2],
                    "width": b[1] - b[0], "source": "AUTO"}
        finally:
            rollback_started(tx)
            tx.Dispose()

    def manual_measure(key, d, reason):
        selected = List[ElementId]()
        selected.Add(d["id"])
        uidoc.Selection.SetElementIds(selected)
        refresh()
        REPORT["Warnings"].append("Spot {}: {}".format(key, reason))
        REPORT["Stage"] = "Calibrate text for spot {}".format(key)
        a = pick("Highlighted spot {}: click full N/E text BOTTOM-LEFT, exclude symbol/line | ESC: cancel".format(key))
        b = pick("Highlighted spot {}: click full N/E text TOP-RIGHT, exclude symbol/line | ESC: cancel".format(key))
        if sx(b) - sx(a) <= tol or sy(b) - sy(a) <= tol:
            raise ValueError("Calibration needs bottom-left, then top-right corners.")
        text = doc.GetElement(d["id"]).TextPosition
        return {"left": sx(a) - sx(text), "right": sx(b) - sx(text),
                "bottom": sy(a) - sy(text), "width": sx(b) - sx(a),
                "height": sy(b) - sy(a), "source": "MANUAL"}

    REPORT["Stage"] = "Measure text"
    for key, d in sorted(data.items()):
        reason = "Manual calibration selected."
        if measure_mode == "AUTO":
            try:
                d["metrics"] = native_measure(d)
                continue
            except RegenerationFailedException:
                raise
            except Exception as ex:
                reason = "Automatic measurement unavailable: " + str(ex)
        d["metrics"] = manual_measure(key, d, reason)
    uidoc.Selection.SetElementIds(List[ElementId]())
    refresh()
    REPORT["Text measurements"] = [{"Id": str(k), **d["metrics"]} for k, d in sorted(data.items())]
    height = max(d["metrics"]["height"] for d in data.values())
    requested_spacing = spacing
    row_clearance = 1.0 * view.Scale / 304.8
    spacing = max(requested_spacing, height + text_gap + row_clearance)
    REPORT["Row spacing (model inches)"] = {"Requested": requested_spacing * 12.0,
                                          "Used": spacing * 12.0,
                                          "Increased for text clearance": spacing > requested_spacing + tol}
    REPORT["Row order"] = "45-degree path order; recalculated for each placement direction"

    points = [(k, sx(d["origin"]), sy(d["origin"])) for k, d in data.items()]
    max_width = max(d["metrics"]["width"] for d in data.values())
    REPORT["Layout dimensions (model inches)"] = {
        "Widest text": max_width * 12.0, "Padding per side": padding * 12.0,
        "Minimum landing input": landing * 12.0, "Minimum diagonal component": diagonal * 12.0}
    pick_feedback = [""]

    group = TransactionGroup(doc, TITLE + " - interactive alignment")
    if group.Start() != TransactionStatus.Started:
        group.Dispose()
        raise RuntimeError("Cannot start the interactive alignment group.")

    def next_point():
        REPORT["Stage"] = "Click position; Esc finishes"
        if not doc.ActiveView.Id.Equals(view.Id):
            raise RuntimeError("The active view changed.")
        try:
            prompt = "Click TOP SYMBOL CENTER | Direction follows click | ESC: finish and keep last position"
            if pick_feedback[0]:
                prompt = pick_feedback[0] + " | " + prompt
            return pick(prompt)
        except OperationCanceledException:
            return None

    def apply_point(p):
        mode = choose_direction(points, sx(p), sy(p))
        REPORT["Attempt count"] += 1
        attempt = {"Click": REPORT["Attempt count"], "Mode": mode,
                   "Requested XY (view feet)": [sx(p), sy(p)], "Result": "Calculating"}
        REPORT["Last attempt"] = attempt
        REPORT["Recent attempts"].append(attempt)
        if len(REPORT["Recent attempts"]) > 20:
            del REPORT["Recent attempts"][0]
        rows, text_right, top_y = solve_layout(points, sx(p), sy(p), spacing,
                                               landing, diagonal, mode, max_width, padding)
        attempt["Computed XY (view feet)"] = [rows[0][3], top_y]
        attempt["Fit offset (model inches)"] = [(rows[0][3] - sx(p)) * 12.0, (top_y - sy(p)) * 12.0]
        attempt["Layout"] = ("Exact clicked symbol center" if
                             max(abs(rows[0][3] - sx(p)), abs(top_y - sy(p))) <= tol else
                             "Adjusted for row spacing, text coverage and minimum lengths")
        attempt["Phase"] = "Check selected leader paths"
        check_leader_conflicts(points, rows)
        # Keep the shared left-aligned text column beside the end symbol.
        text_left = text_right - max_width
        symbol_x = rows[0][3]
        offset_x, offset_y = symbol_x - sx(p), top_y - sy(p)
        tx = Transaction(doc, TITLE + " - placement")
        try:
            if tx.Start() != TransactionStatus.Started:
                raise RuntimeError("Cannot start placement transaction.")
            expected = {}
            for key, bend_x, row_y, end_x in rows:
                d = data[key]
                e = doc.GetElement(d["id"])
                m = d["metrics"]
                bend = at(d["origin"], bend_x, row_y)
                end = at(d["origin"], end_x, row_y)
                REPORT["Stage"] = "Place spot {}".format(key)
                attempt["Phase"] = "Set native handles"
                attempt["Spot"] = str(key)
                # LEFT-align each block inside the shared area beside the symbol.
                text = at(e.TextPosition, text_left - m["left"], row_y + text_gap - m["bottom"])
                write_spot_handles(e, bend, end, text, doc.Regenerate, error_xy, tol)
                expected[key] = (bend, end, text)
            REPORT["Stage"] = "Verify placement"
            attempt["Phase"] = "Verify native handles"
            actual_rows, actual_ends = [], {}
            for key, d in data.items():
                e = doc.GetElement(d["id"])
                bend, end, text = expected[key]
                attempt["Spot"] = str(key)
                if not e.HasLeader or not e.LeaderHasShoulder:
                    raise PlacementRejected("Spot {}: Leader/Shoulder was not restored.".format(key))
                if e.Origin.DistanceTo(d["origin"]) > 1e-7:
                    raise PlacementRejected("Spot {}: reference origin moved.".format(key))
                if reference_keys(e, doc) != d["refs"] or not e.GetTypeId().Equals(d["type"]):
                    raise PlacementRejected("Spot {}: reference/type changed.".format(key))
                if text_parameters(e) != d["strings"]:
                    raise PlacementRejected("Spot {}: a text parameter changed.".format(key))
                errors = [error_xy(e.LeaderShoulderPosition, bend), error_xy(e.LeaderEndPosition, end),
                          error_xy(e.TextPosition, text)]
                if max(errors) > tol:
                    attempt["Native handle errors (model inches)"] = [v * 12.0 for v in errors]
                    raise PlacementRejected("Spot {}: Revit constrained the handles. "
                                            "Shoulder/end/text errors in model inches: {}".format(
                                                key, [round(v * 12.0, 5) for v in errors]))
                left = sx(e.TextPosition) + d["metrics"]["left"]
                right_edge = sx(e.TextPosition) + d["metrics"]["right"]
                if (left < min(sx(bend), sx(end)) + padding - tol or
                        right_edge > max(sx(bend), sx(end)) - padding + tol):
                    raise PlacementRejected("Spot {}: text would extend past the horizontal line.".format(key))
                actual_bend, actual_end = e.LeaderShoulderPosition, e.LeaderEndPosition
                actual_rows.append((key, sx(actual_bend), sy(actual_bend), sx(actual_end)))
                actual_ends[key] = (sx(actual_end), sy(actual_end))
            check_leader_conflicts(points, actual_rows, actual_ends)
            # Force modal failure handling so Commit cannot quietly leave Pending.
            options = tx.GetFailureHandlingOptions()
            options.SetForcedModalHandling(True)
            tx.SetFailureHandlingOptions(options)
            status = tx.Commit()
            if status != TransactionStatus.Committed:
                raise PlacementRejected("Revit did not commit this position: " + str(status))
            attempt.update({"Result": "Committed", "Phase": "Complete"})
            return {"Mode": mode, "Anchor": "Top symbol center", "Symbol center X": symbol_x, "Top symbol center Y": top_y,
                    "Text area right X": text_right, "Text left X": text_left,
                    "Automatically adjusted": max(abs(offset_x), abs(offset_y)) > tol,
                    "Center offset (model inches)": [offset_x * 12.0, offset_y * 12.0],
                    "Row order (top to bottom)": [str(row[0]) for row in rows],
                    "Selected leader crossings": 0,
                    "Rows": len(rows)}
        except RegenerationFailedException:
            rollback_started(tx)
            raise
        except Exception as ex:
            rollback_started(tx)
            if isinstance(ex, PlacementRejected):
                raise
            raise PlacementRejected(str(ex))
        finally:
            if tx.GetStatus() != TransactionStatus.Pending:
                tx.Dispose()

    def rejected_click(error):
        REPORT["Rejected click count"] = REPORT.get("Rejected click count", 0) + 1
        REPORT["Rejected clicks"].append(str(error))
        if len(REPORT["Rejected clicks"]) > 20:
            del REPORT["Rejected clicks"][0]
        if "Last attempt" in REPORT:
            REPORT["Last attempt"].update({"Result": "Rejected", "Reason": str(error)})
        pick_feedback[0] = "Position unchanged: " + str(error).replace("\n", " ")[:180]
        refresh()

    def updated(result):
        REPORT["Successful clicks"] += 1
        REPORT["Last placement"] = result
        REPORT["Mode"] = result["Mode"]
        if result["Automatically adjusted"]:
            dx, dy = result["Center offset (model inches)"]
            pick_feedback[0] = "{} placed; auto-fit X {:+.1f} in, Y {:+.1f} in".format(result["Mode"], dx, dy)
        else:
            pick_feedback[0] = "{} placed at picked symbol center".format(result["Mode"])
        refresh()

    try:
        outcome = placement_loop(next_point, apply_point, rejected_click, updated)
        if outcome["Accepted"]:
            status = group.Assimilate()
            if status != TransactionStatus.Committed:
                raise RuntimeError("Could not finish alignment group: " + str(status))
            REPORT.update({"Status": "Finished", "Stage": "Complete", "Aligned": len(data),
                           "Undo": "One Revit Undo restores the initial arrangement."})
        else:
            rollback_started(group)
            REPORT.update({"Status": "No changes", "Stage": "Complete"})
    except Exception:
        # Unexpected API failures abort the complete session, unlike a rejected click.
        rollback_started(group)
        REPORT["Aligned"] = 0
        REPORT["Session rolled back"] = True
        raise
    finally:
        if group.GetStatus() == TransactionStatus.Started:
            rollback_started(group)
        group.Dispose()
        refresh()


RUN_NODE = None
try:
    REPORT["Stage"] = "Prepare Run"
    RUN_NODE, RUN_MODE = prepare_run_context(REPORT)
    if RUN_MODE is not None and RUN_MODE != "Manual":
        raise ValueError("Set Dynamo run mode to Manual, then click Run.")
    REPORT["Stage"] = "Inputs"
    main()
except OperationCanceledException:
    REPORT.update({"Status": "Cancelled before placement", "Aligned": 0})
except Exception as ex:
    REPORT.update({"Status": "Failed", "Error": str(ex), "Traceback": traceback.format_exc()})
finally:
    # Dynamo clears dirty flags BEFORE evaluating Python. Rearm only this node
    # after every outcome (including Esc) so the next manual Run executes it.
    # MarkNodeAsModified does not raise Modified or request another graph run.
    prepare_next_run(RUN_NODE, REPORT)

OUT = REPORT
