# ParametricBox_Dynamo3.py
# Dynamo 3.x Python Script node (CPython3 / PythonNet3 engine), Revit 2022+
#
# Builds a parametric Generic Model family: a rectangular box whose Length,
# Width and Height are family type parameters. Box faces are locked to
# reference planes; the planes are dimensioned and labeled, so the family
# flexes in Family Types / Type Properties.
#
# INPUTS (wire to the Python node):
#   IN[0]  Length       number, in the project's length units (e.g. 4 for 4 ft, 1200 for 1200 mm)
#   IN[1]  Width        number, project length units
#   IN[2]  Height       number, project length units
#   IN[3]  Folder       string, folder to save the .rfa in (blank / null -> Documents)
#   IN[4]  FamilyName   string (blank / null -> "ParametricBox")
#   IN[5]  Run          boolean toggle; set True to build
#
# OUTPUT:
#   OUT    status message and saved .rfa path

import clr
import os
import uuid

clr.AddReference("RevitAPI")
from Autodesk.Revit.DB import (
    BuiltInParameter, CurveArray, CurveArrArray, Extrusion, FamilySource,
    FilteredElementCollector, GeometryInstance, GroupTypeId, IFamilyLoadOptions,
    Level, Line, Options, Plane, PlanarFace, ReferenceArray, ReferencePlane,
    SaveAsOptions, SketchPlane, Solid, SpecTypeId, Transaction, UnitUtils,
    UnitTypeId, View, ViewPlan, ViewType, XYZ,
)

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


# ── Inputs ──────────────────────────────────────────────────────────────────
def _in(i, default=None):
    try:
        v = IN[i]
        return default if v is None or v == "" else v
    except IndexError:
        return default

length_in = float(_in(0, 4.0))
width_in  = float(_in(1, 2.0))
height_in = float(_in(2, 3.0))
folder    = _in(3, os.path.join(os.path.expanduser("~"), "Documents"))
fam_name  = _in(4, "ParametricBox")
run       = bool(_in(5, False))

uiapp = DocumentManager.Instance.CurrentUIApplication
app   = uiapp.Application
doc   = DocumentManager.Instance.CurrentDBDocument   # the open project (or family)


# ── Units: convert project display units -> Revit internal feet ─────────────
def to_internal(value):
    try:
        unit = doc.GetUnits().GetFormatOptions(SpecTypeId.Length).GetUnitTypeId()
        return UnitUtils.ConvertToInternalUnits(value, unit)
    except Exception:
        return value  # assume feet if no document units are available


# ── Helpers ────────────────────────────────────────────────────────────────
def find_template():
    root = app.FamilyTemplatePath
    for name in ("Generic Model.rft", "Metric Generic Model.rft"):
        direct = os.path.join(root, name)
        if os.path.isfile(direct):
            return direct
        if os.path.isdir(root):
            for dirpath, _, files in os.walk(root):
                if name in files:
                    return os.path.join(dirpath, name)
    return None


def collect(fdoc, cls):
    return list(FilteredElementCollector(fdoc).OfClass(cls).ToElements())


def ref_array(*refs):
    ra = ReferenceArray()
    for r in refs:
        ra.Append(r)
    return ra


def face_by_normal(extrusion, normal):
    opts = Options()
    opts.ComputeReferences = True
    for go in extrusion.get_Geometry(opts):
        if isinstance(go, Solid):
            for f in go.Faces:
                if isinstance(f, PlanarFace) and f.FaceNormal.IsAlmostEqualTo(normal):
                    return f.Reference
    raise Exception("Box face with normal {} not found".format(normal))


def lock(fc, view, ref_a, ref_b):
    a = fc.NewAlignment(view, ref_a, ref_b)
    a.IsLocked = True
    return a


# Family load options (unique namespace so the node can be re-run in one session)
class _OverwriteLoadOptions(IFamilyLoadOptions):
    __namespace__ = "ParametricBox_" + uuid.uuid4().hex

    def OnFamilyFound(self, familyInUse, overwriteParameterValues):
        return (True, True)

    def OnSharedFamilyFound(self, sharedFamily, familyInUse, source, overwriteParameterValues):
        return (True, FamilySource.Family, True)


# ── Family builder ─────────────────────────────────────────────────────────
def build_family(fam, L, W, H):
    fm = fam.FamilyManager
    fc = fam.FamilyCreate

    plans = [v for v in collect(fam, ViewPlan) if not v.IsTemplate]
    plan_view = next((v for v in plans if v.Name == "Ref. Level"), plans[0])

    elevs = [v for v in collect(fam, View) if not v.IsTemplate and v.ViewType == ViewType.Elevation]
    front_view = next((v for v in elevs if v.Name == "Front"), elevs[0])

    ref_level = collect(fam, Level)[0]
    rps = {rp.Name: rp for rp in collect(fam, ReferencePlane)}
    center_lr = rps["Center (Left/Right)"]
    center_fb = rps["Center (Front/Back)"]

    t = Transaction(fam, "Build parametric box")
    t.Start()
    try:
        # Parameters
        p_len = fm.AddParameter("Length", GroupTypeId.Geometry, SpecTypeId.Length, False)
        p_wid = fm.AddParameter("Width",  GroupTypeId.Geometry, SpecTypeId.Length, False)
        p_hgt = fm.AddParameter("Height", GroupTypeId.Geometry, SpecTypeId.Length, False)
        if fm.CurrentType is None:
            fm.NewType("Default")
        fm.Set(p_len, L)
        fm.Set(p_wid, W)
        fm.Set(p_hgt, H)

        # Reference planes
        ext = 10.0
        Z, Y = XYZ.BasisZ, XYZ.BasisY
        rp_left  = fc.NewReferencePlane(XYZ(-L / 2, -ext, 0), XYZ(-L / 2, ext, 0), Z, plan_view)
        rp_right = fc.NewReferencePlane(XYZ( L / 2, -ext, 0), XYZ( L / 2, ext, 0), Z, plan_view)
        rp_front = fc.NewReferencePlane(XYZ(-ext, -W / 2, 0), XYZ(ext, -W / 2, 0), Z, plan_view)
        rp_back  = fc.NewReferencePlane(XYZ(-ext,  W / 2, 0), XYZ(ext,  W / 2, 0), Z, plan_view)
        rp_top   = fc.NewReferencePlane(XYZ(-ext, 0, H), XYZ(ext, 0, H), Y, front_view)
        rp_left.Name, rp_right.Name = "Left", "Right"
        rp_front.Name, rp_back.Name, rp_top.Name = "Front", "Back", "Top"

        # Box extrusion
        pts = [XYZ(-L / 2, -W / 2, 0), XYZ(L / 2, -W / 2, 0),
               XYZ( L / 2,  W / 2, 0), XYZ(-L / 2, W / 2, 0)]
        loop = CurveArray()
        for i in range(4):
            loop.Append(Line.CreateBound(pts[i], pts[(i + 1) % 4]))
        profile = CurveArrArray()
        profile.Append(loop)
        sketch = SketchPlane.Create(fam, Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero))
        box = fc.NewExtrusion(True, profile, sketch, H)
        fam.Regenerate()

        # Lock faces to planes
        lock(fc, plan_view,  rp_left.GetReference(),  face_by_normal(box, XYZ(-1, 0, 0)))
        lock(fc, plan_view,  rp_right.GetReference(), face_by_normal(box, XYZ( 1, 0, 0)))
        lock(fc, plan_view,  rp_front.GetReference(), face_by_normal(box, XYZ(0, -1, 0)))
        lock(fc, plan_view,  rp_back.GetReference(),  face_by_normal(box, XYZ(0,  1, 0)))
        lock(fc, front_view, rp_top.GetReference(),   face_by_normal(box, XYZ(0, 0,  1)))
        lock(fc, front_view, ref_level.GetPlaneReference(), face_by_normal(box, XYZ(0, 0, -1)))

        # Dimensions + labels
        off = 1.5
        d = fc.NewDimension(plan_view,
            Line.CreateBound(XYZ(-L / 2, -W / 2 - off, 0), XYZ(L / 2, -W / 2 - off, 0)),
            ref_array(rp_left.GetReference(), rp_right.GetReference()))
        d.FamilyLabel = p_len
        d = fc.NewDimension(plan_view,
            Line.CreateBound(XYZ(-L / 2, -W / 2 - off / 2, 0), XYZ(L / 2, -W / 2 - off / 2, 0)),
            ref_array(rp_left.GetReference(), center_lr.GetReference(), rp_right.GetReference()))
        d.AreSegmentsEqual = True

        d = fc.NewDimension(plan_view,
            Line.CreateBound(XYZ(L / 2 + off, -W / 2, 0), XYZ(L / 2 + off, W / 2, 0)),
            ref_array(rp_front.GetReference(), rp_back.GetReference()))
        d.FamilyLabel = p_wid
        d = fc.NewDimension(plan_view,
            Line.CreateBound(XYZ(L / 2 + off / 2, -W / 2, 0), XYZ(L / 2 + off / 2, W / 2, 0)),
            ref_array(rp_front.GetReference(), center_fb.GetReference(), rp_back.GetReference()))
        d.AreSegmentsEqual = True

        d = fc.NewDimension(front_view,
            Line.CreateBound(XYZ(L / 2 + off, 0, 0), XYZ(L / 2 + off, 0, H)),
            ref_array(ref_level.GetPlaneReference(), rp_top.GetReference()))
        d.FamilyLabel = p_hgt

        fam.Regenerate()
        t.Commit()
    except Exception:
        if t.HasStarted() and not t.HasEnded():
            t.RollBack()
        raise


# ── Main ────────────────────────────────────────────────────────────────────
if not run:
    OUT = "Set Run (IN[5]) to True to build the family."
else:
    fam_doc = None
    try:
        template = find_template()
        if template is None:
            raise Exception("Generic Model template not found under " + app.FamilyTemplatePath)

        L, W, H = to_internal(length_in), to_internal(width_in), to_internal(height_in)
        if min(L, W, H) <= 0:
            raise Exception("Length, Width and Height must all be greater than zero.")

        # Dynamo keeps a transaction open on the active document; close it so
        # the new family can be loaded back into the project.
        TransactionManager.Instance.ForceCloseTransaction()

        fam_doc = app.NewFamilyDocument(template)
        build_family(fam_doc, L, W, H)

        if not os.path.isdir(folder):
            os.makedirs(folder)
        path = os.path.join(folder, fam_name + ".rfa")
        sao = SaveAsOptions()
        sao.OverwriteExistingFile = True
        fam_doc.SaveAs(path, sao)

        loaded = False
        if doc is not None and not doc.IsFamilyDocument:
            fam_doc.LoadFamily(doc, _OverwriteLoadOptions())
            loaded = True

        OUT = "Saved: {}{}".format(path, "  |  Loaded into project" if loaded else "")
    except Exception as e:
        OUT = "Error: {}".format(e)
    finally:
        if fam_doc is not None and fam_doc.IsValidObject:
            fam_doc.Close(False)
