"""
Parametric Rectangular Table - Revit family generator
=====================================================
Dynamo 3.x  (CPython3 or PythonNet3 engine)  /  Revit 2025+

Creates a brand-new .rfa from the Furniture family template with these
INSTANCE parameters:

    Table Height, Table Width, Table Length, Tabletop Thickness,
    Tabletop Material, Number of Legs, Leg Size (square), Leg Material

How it stays parametric
-----------------------
* Reference planes are dimensioned from the template's center planes and the
  dimensions are labeled with (formula-driven) family parameters.
* Every vertical face of the tabletop and the legs is LOCKED to a reference
  plane, so the geometry follows the planes when values change.
* Extrusion start/end, material and visibility are associated with family
  parameters.
* Number of Legs: 4 (corners), 6 (adds one centered leg on each long side) or
  8 (adds two evenly spaced legs on each long side). Any other value shows 4.

Dynamo inputs (all optional)
----------------------------
IN[0]  Save path  - full .rfa path, or a folder (file is named
                    "Parametric Table.rfa"). Default: Documents folder.
IN[1]  Load into project  - Boolean. Default: False.
IN[2]  Family template path (.rft) - Default: found automatically.
"""

import clr
import os
import traceback

clr.AddReference('RevitAPI')
from Autodesk.Revit.DB import *

clr.AddReference('RevitServices')
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager

doc = DocumentManager.Instance.CurrentDBDocument
app = DocumentManager.Instance.CurrentUIApplication.Application

# ---------------------------------------------------------------------------
# DEFAULT VALUES (inches) - change these to suit. Revit works in feet
# internally, so they are converted below. Metric: mm / 25.4 = inches.
# ---------------------------------------------------------------------------
DEFAULT_HEIGHT_IN = 30.0
DEFAULT_WIDTH_IN = 36.0
DEFAULT_LENGTH_IN = 72.0
DEFAULT_TOP_THICK_IN = 1.5
DEFAULT_LEG_SIZE_IN = 3.0
DEFAULT_LEG_COUNT = 4

FAMILY_FILE_NAME = "Parametric Table.rfa"
TOL = 1e-4  # feet


def ft(inches):
    return inches / 12.0


# ---------------------------------------------------------------------------
# Inputs
# ---------------------------------------------------------------------------
def get_input(i, default=None):
    try:
        v = IN[i]
        return default if v is None or v == "" else v
    except Exception:
        return default


save_path = get_input(0, os.path.join(os.path.expanduser("~"), "Documents"))
if not str(save_path).lower().endswith(".rfa"):
    save_path = os.path.join(str(save_path), FAMILY_FILE_NAME)
load_into_project = bool(get_input(1, False))
template_override = get_input(2, None)


# ---------------------------------------------------------------------------
# Template lookup
# ---------------------------------------------------------------------------
def find_template():
    if template_override and os.path.isfile(str(template_override)):
        return str(template_override)
    roots = []
    if app.FamilyTemplatePath:
        roots.append(app.FamilyTemplatePath)
        roots.append(os.path.dirname(app.FamilyTemplatePath.rstrip("\\/")))
    roots.append(r"C:\ProgramData\Autodesk\RVT {0}\Family Templates".format(app.VersionNumber))
    names = ["Furniture.rft", "Metric Furniture.rft",
             "Generic Model.rft", "Metric Generic Model.rft"]
    for name in names:
        for root in roots:
            if root and os.path.isdir(root):
                for dirpath, _dirs, files in os.walk(root):
                    if name in files:
                        return os.path.join(dirpath, name)
    raise Exception("No Furniture or Generic Model template found. "
                    "Pass a .rft path into IN[2].")


# ---------------------------------------------------------------------------
# Family-document helpers (everything below works on fam_doc, not the project)
# ---------------------------------------------------------------------------
fam_doc = None
fm = None
view = None
sketch = None


def add_param(name, group, spec):
    """Add an INSTANCE family parameter (or reuse an existing one)."""
    fp = fm.get_Parameter(name)
    if fp is None:
        fp = fm.AddParameter(name, group, spec, True)
    return fp


def plane_coord(rp, axis):
    return rp.BubbleEnd.X if axis == "X" else rp.BubbleEnd.Y


def make_plane(name, axis, offset, extent):
    """axis 'X' -> plane perpendicular to X at x=offset (runs front-to-back).
       axis 'Y' -> plane perpendicular to Y at y=offset (runs left-to-right)."""
    if axis == "X":
        p1, p2 = XYZ(offset, -extent, 0), XYZ(offset, extent, 0)
    else:
        p1, p2 = XYZ(-extent, offset, 0), XYZ(extent, offset, 0)
    rp = fam_doc.FamilyCreate.NewReferencePlane(p1, p2, XYZ.BasisZ, view)
    rp.Name = name
    return rp


def get_center_plane(axis, extent):
    """Template center plane: 'Center (Left/Right)' is perpendicular to X,
       'Center (Front/Back)' is perpendicular to Y."""
    target = "Center (Left/Right)" if axis == "X" else "Center (Front/Back)"
    planes = list(FilteredElementCollector(fam_doc).OfClass(ReferencePlane))
    for rp in planes:
        if rp.Name == target:
            return rp
    for rp in planes:  # fallback: any plane through the origin with right normal
        n = rp.Normal
        if (axis == "X" and abs(n.X) > 0.99 and abs(rp.BubbleEnd.X) < TOL) or \
           (axis == "Y" and abs(n.Y) > 0.99 and abs(rp.BubbleEnd.Y) < TOL):
            return rp
    rp = make_plane(target, axis, 0.0, extent)
    rp.Pinned = True
    return rp


def label_dim(rp_a, rp_b, axis, line_pos, fp):
    """Dimension between two parallel planes and label it with fp."""
    refs = ReferenceArray()
    refs.Append(rp_a.GetReference())
    refs.Append(rp_b.GetReference())
    a, b = plane_coord(rp_a, axis), plane_coord(rp_b, axis)
    if axis == "X":
        line = Line.CreateBound(XYZ(a, line_pos, 0), XYZ(b, line_pos, 0))
    else:
        line = Line.CreateBound(XYZ(line_pos, a, 0), XYZ(line_pos, b, 0))
    d = fam_doc.FamilyCreate.NewDimension(view, line, refs)
    d.FamilyLabel = fp
    return d


def side_faces(ext):
    """Return (face, normal, midpoint) for every face of the extrusion."""
    opts = Options()
    opts.ComputeReferences = True
    opts.IncludeNonVisibleObjects = True
    result = []
    for g in ext.get_Geometry(opts):
        if isinstance(g, Solid) and g.Faces.Size > 0:
            for f in g.Faces:
                bb = f.GetBoundingBox()
                uv = UV((bb.Min.U + bb.Max.U) / 2.0, (bb.Min.V + bb.Max.V) / 2.0)
                result.append((f, f.ComputeNormal(uv), f.Evaluate(uv)))
    return result


def make_box(x0, x1, y0, y1, end_height):
    """Extrusion whose 4 vertical faces are locked to planes x0/x1/y0/y1."""
    xa, xb = plane_coord(x0, "X"), plane_coord(x1, "X")
    ya, yb = plane_coord(y0, "Y"), plane_coord(y1, "Y")
    pts = [XYZ(xa, ya, 0), XYZ(xb, ya, 0), XYZ(xb, yb, 0), XYZ(xa, yb, 0)]
    loop = CurveArray()
    for i in range(4):
        loop.Append(Line.CreateBound(pts[i], pts[(i + 1) % 4]))
    profile = CurveArrArray()
    profile.Append(loop)
    ext = fam_doc.FamilyCreate.NewExtrusion(True, profile, sketch, end_height)
    fam_doc.Regenerate()

    faces = side_faces(ext)
    for rp, axis in ((x0, "X"), (x1, "X"), (y0, "Y"), (y1, "Y")):
        c = plane_coord(rp, axis)
        for f, n, p in faces:
            on_axis = abs(n.X) > 0.99 if axis == "X" else abs(n.Y) > 0.99
            pos = p.X if axis == "X" else p.Y
            if on_axis and abs(pos - c) < TOL:
                fam_doc.FamilyCreate.NewAlignment(view, rp.GetReference(), f.Reference)
                break
        else:
            raise Exception("Could not find a face to lock to plane '{0}'".format(rp.Name))
    return ext


def associate(elem, bip, fp):
    p = elem.get_Parameter(bip)
    if p is None:
        raise Exception("Element {0} has no parameter {1}".format(elem.Id, bip))
    fm.AssociateElementParameterToFamilyParameter(p, fp)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
try:
    # Dynamo may hold an open transaction on the project; close it first.
    TransactionManager.Instance.ForceCloseTransaction()

    template = find_template()
    fam_doc = app.NewFamilyDocument(template)
    fm = fam_doc.FamilyManager

    # Plan view and level used for planes, dimensions and sketches
    plans = [v for v in FilteredElementCollector(fam_doc).OfClass(ViewPlan)
             if not v.IsTemplate]
    view = next((v for v in plans if v.Name == "Ref. Level"), plans[0] if plans else None)
    if view is None:
        raise Exception("Template has no floor plan view.")
    level = FilteredElementCollector(fam_doc).OfClass(Level).FirstElement()

    # Default geometry in feet
    H = ft(DEFAULT_HEIGHT_IN)
    W = ft(DEFAULT_WIDTH_IN)
    L = ft(DEFAULT_LENGTH_IN)
    T = ft(DEFAULT_TOP_THICK_IN)
    S = ft(DEFAULT_LEG_SIZE_IN)
    a = L / 2.0 - S / 2.0                 # center of a corner leg from table center
    inner8 = a / 3.0 - S / 2.0            # 8-leg set: near face from center
    outer8 = a / 3.0 + S / 2.0            # 8-leg set: far face from center
    if inner8 <= TOL:
        raise Exception("Default Table Length must be more than 4 x Leg Size.")

    # ---------------- 1. Parameters ----------------
    t = Transaction(fam_doc, "Table parameters")
    t.Start()
    if fm.CurrentType is None:
        fm.NewType("Standard")

    G_DIM = GroupTypeId.Geometry          # shows as "Dimensions"
    G_MAT = GroupTypeId.Materials
    G_CON = GroupTypeId.Constraints
    G_VIS = GroupTypeId.Visibility
    LEN = SpecTypeId.Length

    p_height = add_param("Table Height", G_DIM, LEN)
    p_width = add_param("Table Width", G_DIM, LEN)
    p_length = add_param("Table Length", G_DIM, LEN)
    p_thick = add_param("Tabletop Thickness", G_DIM, LEN)
    p_leg = add_param("Leg Size", G_DIM, LEN)
    p_count = add_param("Number of Legs", G_DIM, SpecTypeId.Int.Integer)
    p_top_mat = add_param("Tabletop Material", G_MAT, SpecTypeId.Reference.Material)
    p_leg_mat = add_param("Leg Material", G_MAT, SpecTypeId.Reference.Material)

    # Helper (formula-driven) parameters - greyed out for users
    p_half_len = add_param("Half Length", G_CON, LEN)
    p_half_wid = add_param("Half Width", G_CON, LEN)
    p_half_leg = add_param("Half Leg Size", G_CON, LEN)
    p_leg_h = add_param("Leg Height", G_CON, LEN)
    p_in8 = add_param("Leg8 Inner Offset", G_CON, LEN)
    p_out8 = add_param("Leg8 Outer Offset", G_CON, LEN)
    p_show6 = add_param("Show 6-Leg Set", G_VIS, SpecTypeId.Boolean.YesNo)
    p_show8 = add_param("Show 8-Leg Set", G_VIS, SpecTypeId.Boolean.YesNo)

    fm.Set(p_height, float(H))
    fm.Set(p_width, float(W))
    fm.Set(p_length, float(L))
    fm.Set(p_thick, float(T))
    fm.Set(p_leg, float(S))
    # Integer parameters have no units, so SetValueString is not allowed.
    # Call the Int32 overload of Set explicitly so Python doesn't pick the double one.
    import System
    try:
        fm.Set.Overloads[FamilyParameter, System.Int32](p_count, int(DEFAULT_LEG_COUNT))
    except Exception:
        fm.Set(p_count, int(DEFAULT_LEG_COUNT))

    fm.SetFormula(p_half_len, "Table Length / 2")
    fm.SetFormula(p_half_wid, "Table Width / 2")
    fm.SetFormula(p_half_leg, "Leg Size / 2")
    fm.SetFormula(p_leg_h, "Table Height - Tabletop Thickness")
    fm.SetFormula(p_in8, "(Table Length / 2 - Leg Size / 2) / 3 - Leg Size / 2")
    fm.SetFormula(p_out8, "(Table Length / 2 - Leg Size / 2) / 3 + Leg Size / 2")
    fm.SetFormula(p_show6, "Number of Legs = 6")
    fm.SetFormula(p_show8, "Number of Legs = 8")
    t.Commit()

    # ---------------- 2. Reference planes + labeled dimensions ----------------
    t = Transaction(fam_doc, "Table reference planes")
    t.Start()
    ext_len = max(L, W) / 2.0 + 2.0   # how far planes extend in plan
    cx = get_center_plane("X", ext_len)
    cy = get_center_plane("Y", ext_len)
    cx.Pinned = True
    cy.Pinned = True

    # Planes perpendicular to X (length direction)
    left = make_plane("Table Left", "X", -L / 2.0, ext_len)
    right = make_plane("Table Right", "X", L / 2.0, ext_len)
    left_in = make_plane("Leg Left Inner", "X", -L / 2.0 + S, ext_len)
    right_in = make_plane("Leg Right Inner", "X", L / 2.0 - S, ext_len)
    m6a = make_plane("Leg6 Mid A", "X", -S / 2.0, ext_len)
    m6b = make_plane("Leg6 Mid B", "X", S / 2.0, ext_len)
    m8la = make_plane("Leg8 Left Outer", "X", -outer8, ext_len)
    m8lb = make_plane("Leg8 Left Inner", "X", -inner8, ext_len)
    m8ra = make_plane("Leg8 Right Inner", "X", inner8, ext_len)
    m8rb = make_plane("Leg8 Right Outer", "X", outer8, ext_len)

    # Planes perpendicular to Y (width direction)
    front = make_plane("Table Front", "Y", -W / 2.0, ext_len)
    back = make_plane("Table Back", "Y", W / 2.0, ext_len)
    front_in = make_plane("Leg Front Inner", "Y", -W / 2.0 + S, ext_len)
    back_in = make_plane("Leg Back Inner", "Y", W / 2.0 - S, ext_len)
    fam_doc.Regenerate()

    # Dimension lines are staggered outside the table so they don't overlap
    y_line = W / 2.0 + 1.0
    x_line = L / 2.0 + 1.0
    step = 0.5
    x_dims = [
        (cx, left, p_half_len), (cx, right, p_half_len),
        (left, left_in, p_leg), (right_in, right, p_leg),
        (cx, m6a, p_half_leg), (cx, m6b, p_half_leg),
        (cx, m8la, p_out8), (cx, m8lb, p_in8),
        (cx, m8ra, p_in8), (cx, m8rb, p_out8),
    ]
    for i, (pa, pb, fp) in enumerate(x_dims):
        label_dim(pa, pb, "X", y_line + step * i, fp)

    y_dims = [
        (cy, front, p_half_wid), (cy, back, p_half_wid),
        (front, front_in, p_leg), (back_in, back, p_leg),
    ]
    for i, (pa, pb, fp) in enumerate(y_dims):
        label_dim(pa, pb, "Y", x_line + step * i, fp)
    t.Commit()

    # ---------------- 3. Geometry ----------------
    t = Transaction(fam_doc, "Table geometry")
    t.Start()
    try:
        sketch = SketchPlane.Create(fam_doc, level.Id)
    except Exception:
        sketch = SketchPlane.Create(fam_doc, Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero))

    # Tabletop: runs from (Table Height - Thickness) up to Table Height
    top = make_box(left, right, front, back, H)
    associate(top, BuiltInParameter.EXTRUSION_END_PARAM, p_height)
    associate(top, BuiltInParameter.EXTRUSION_START_PARAM, p_leg_h)
    associate(top, BuiltInParameter.MATERIAL_ID_PARAM, p_top_mat)

    leg_specs = [
        # (x0, x1, y0, y1, visibility parameter or None)
        (left, left_in, front, front_in, None),        # 4 corner legs
        (right_in, right, front, front_in, None),
        (left, left_in, back_in, back, None),
        (right_in, right, back_in, back, None),
        (m6a, m6b, front, front_in, p_show6),          # 6-leg set
        (m6a, m6b, back_in, back, p_show6),
        (m8la, m8lb, front, front_in, p_show8),        # 8-leg set
        (m8la, m8lb, back_in, back, p_show8),
        (m8ra, m8rb, front, front_in, p_show8),
        (m8ra, m8rb, back_in, back, p_show8),
    ]
    leg_count = 0
    for x0, x1, y0, y1, vis in leg_specs:
        leg = make_box(x0, x1, y0, y1, H - T)
        associate(leg, BuiltInParameter.EXTRUSION_END_PARAM, p_leg_h)
        associate(leg, BuiltInParameter.MATERIAL_ID_PARAM, p_leg_mat)
        if vis is not None:
            associate(leg, BuiltInParameter.IS_VISIBLE_PARAM, vis)
        leg_count += 1
    t.Commit()

    # ---------------- 4. Flex test: make sure it actually moves ----------------
    t = Transaction(fam_doc, "Flex test")
    t.Start()
    fm.Set(p_length, float(L + 1.0))
    fm.Set(p_width, float(W + 0.5))
    fam_doc.Regenerate()
    t.RollBack()   # undo the test, keep the defaults

    # ---------------- 5. Save / load ----------------
    folder = os.path.dirname(save_path)
    if folder and not os.path.isdir(folder):
        os.makedirs(folder)
    sa = SaveAsOptions()
    sa.OverwriteExistingFile = True
    fam_doc.SaveAs(save_path, sa)

    load_msg = ""
    if load_into_project and not doc.IsFamilyDocument:
        try:
            fam_doc.LoadFamily(doc)
            load_msg = "\nLoaded into the active project."
        except Exception as le:
            load_msg = "\nSaved, but loading failed (family may already exist): {0}".format(le)

    fam_doc.Close(False)
    fam_doc = None

    OUT = ("Created '{0}'\nTemplate: {1}\nGeometry: tabletop + {2} leg extrusions "
           "(4 / 6 / 8 leg configurations).{3}").format(save_path, template, leg_count, load_msg)

except Exception as e:
    try:
        if fam_doc is not None:
            fam_doc.Close(False)
    except Exception:
        pass
    OUT = "ERROR: {0}\n{1}".format(e, traceback.format_exc())
