# -----------------------------------------------------------------------------
# Add Spandrel Beams at Level 4 Cantilever Tips  -  Dynamo 3.x (CPython3 / PythonNet3)
#
# Finds the Level 4 beams that cantilever 10'-0" past Grid F (made by
# cantilever_level4_south.py) and connects their tips with W12x26 spandrel
# beams, one between each pair of adjacent tips, at Level 4.
#
#   With the current grid: 3 spandrels along y = -10', between the tips on
#   grids 1-2 (20'), 2-3 (40') and 3-5 (20').
#
#        1          2              3          5
#   F  --+----------+--------------+----------+--   y =  0'
#        |          |              |          |      10' cantilevers
#        +==========+==============+==========+     y = -10'  spandrels
#
# Safe to re-run: skips a spandrel if a beam already joins the same two tips.
#
# HOW TO USE
#   Dynamo 3.x > Python Script node (CPython3 or PythonNet3) > paste > Run.
#   Ctrl+Z in Revit undoes the whole run.
# -----------------------------------------------------------------------------

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

from Autodesk.Revit.DB import (
    XYZ, Line, Grid, Level, LocationCurve, ElementId, FamilySymbol,
    FilteredElementCollector, BuiltInCategory, BuiltInParameter,
)
from Autodesk.Revit.DB.Structure import StructuralType
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager

doc = DocumentManager.Instance.CurrentDBDocument

# ---------------------------------------------------------------- settings ---
LEVEL_NAME = "Level 4"
EDGE_GRID = "F"
CANTILEVER = 10.0     # feet past the grid where the tips are
BEAM_TYPE = "W12x26"
TOL = 0.01


def norm(s):
    return s.replace(" ", "").upper()


def flat(p):
    return XYZ(p.X, p.Y, 0)


levels = {l.Name: l for l in FilteredElementCollector(doc).OfClass(Level)}
if LEVEL_NAME not in levels:
    raise Exception("'{}' not found. Levels: {}".format(LEVEL_NAME, ", ".join(sorted(levels))))
level = levels[LEVEL_NAME]

grids = {g.Name: g for g in FilteredElementCollector(doc).OfClass(Grid)}
if EDGE_GRID not in grids or not isinstance(grids[EDGE_GRID].Curve, Line):
    raise Exception("Straight grid '{}' not found.".format(EDGE_GRID))

beam_sym = None
for s in FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_StructuralFraming).OfClass(FamilySymbol):
    if norm(s.Name) == norm(BEAM_TYPE):
        beam_sym = s
        break
if beam_sym is None:
    raise Exception("Beam type '{}' is not loaded. Run place_steel_frame.py first "
                    "or load it via Insert > Load Family.".format(BEAM_TYPE))

# Grid F direction and outward normal (away from building center)
g0 = flat(grids[EDGE_GRID].Curve.GetEndPoint(0))
g_dir = (flat(grids[EDGE_GRID].Curve.GetEndPoint(1)) - g0).Normalize()
xs, ys = [], []
for g in grids.values():
    if isinstance(g.Curve, Line):
        for i in (0, 1):
            p = g.Curve.GetEndPoint(i)
            xs.append(p.X)
            ys.append(p.Y)
center = XYZ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0, 0)
normal = XYZ(-g_dir.Y, g_dir.X, 0)
if (center - g0).DotProduct(normal) > 0:
    normal = normal.Negate()


def outward_dist(p):
    return (flat(p) - g0).DotProduct(normal)


def ref_level(e):
    p = e.get_Parameter(BuiltInParameter.INSTANCE_REFERENCE_LEVEL_PARAM)
    return p.AsElementId() if p else ElementId.InvalidElementId


level_beams = [b for b in FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_StructuralFraming)
               .WhereElementIsNotElementType()
               if ref_level(b) == level.Id and isinstance(b.Location, LocationCurve)
               and isinstance(b.Location.Curve, Line)]

# ------------------------------------------------------- cantilever tips ----
tips = []
for b in level_beams:
    c = b.Location.Curve
    d = flat(c.GetEndPoint(1) - c.GetEndPoint(0))
    if d.GetLength() < TOL or abs(d.Normalize().DotProduct(g_dir)) > 0.1:
        continue  # only beams perpendicular to Grid F
    for i in (0, 1):
        p = c.GetEndPoint(i)
        if abs(outward_dist(p) - CANTILEVER) < TOL:
            if not any(flat(q).DistanceTo(flat(p)) < TOL for q in tips):
                tips.append(p)

if len(tips) < 2:
    raise Exception("Found {} cantilever tip(s) {:.0f}' past Grid {} at {} - need at least 2. "
                    "Run cantilever_level4_south.py first.".format(len(tips), CANTILEVER, EDGE_GRID, LEVEL_NAME))

tips.sort(key=lambda p: (flat(p) - g0).DotProduct(g_dir))


def beam_exists(p, q):
    for b in level_beams:
        c = b.Location.Curve
        a, z = flat(c.GetEndPoint(0)), flat(c.GetEndPoint(1))
        if (a.DistanceTo(flat(p)) < TOL and z.DistanceTo(flat(q)) < TOL) or \
           (a.DistanceTo(flat(q)) < TOL and z.DistanceTo(flat(p)) < TOL):
            return True
    return False


# ---------------------------------------------------------------- place -----
report = []
made = skipped = 0

TransactionManager.Instance.EnsureInTransaction(doc)
try:
    if not beam_sym.IsActive:
        beam_sym.Activate()
        doc.Regenerate()
    for p, q in zip(tips, tips[1:]):
        if beam_exists(p, q):
            skipped += 1
            continue
        line = Line.CreateBound(p, XYZ(q.X, q.Y, p.Z))
        doc.Create.NewFamilyInstance(line, beam_sym, level, StructuralType.Beam)
        made += 1
        report.append("Spandrel {:.0f}' long".format(line.Length))
finally:
    TransactionManager.Instance.TransactionTaskDone()

report.insert(0, "{} {} spandrel beam(s) placed across {} cantilever tips at {}".format(
    made, beam_sym.Name, len(tips), LEVEL_NAME))
if skipped:
    report.append("Skipped {} already in place".format(skipped))

OUT = report
