Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

280 lines
9.0 KiB
Python

# -*- coding: utf-8 -*-
"""Small, boring helpers that remove the usual Revit API boilerplate.
Written for IronPython 2.7 (pyRevit's default engine) but 3.x-safe:
no f-strings, no walrus.
"""
# pylint: disable=import-error,invalid-name,broad-except
from contextlib import contextmanager
from Autodesk.Revit import DB, UI # noqa: F401
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, BuiltInParameter,
ElementId, StorageType, Transaction, TransactionGroup,
UnitUtils,
)
# UnitTypeId arrived in Revit 2021 and DisplayUnitType was removed in 2022, so
# the two never overlap for long. Resolving the unit handles once here keeps a
# host outside that window from failing the import of the whole library --
# which would take every button down, not just the four conversions below.
try:
from Autodesk.Revit.DB import UnitTypeId
_MILLIMETERS = UnitTypeId.Millimeters
_METERS = UnitTypeId.Meters
_SQUARE_METERS = UnitTypeId.SquareMeters
except ImportError: # Revit 2020 and older
from Autodesk.Revit.DB import DisplayUnitType
_MILLIMETERS = DisplayUnitType.DUT_MILLIMETERS
_METERS = DisplayUnitType.DUT_METERS
_SQUARE_METERS = DisplayUnitType.DUT_SQUARE_METERS
from pyrevit import revit, script, HOST_APP
# ------------------------------------------------------------ session handles
#
# revit.doc / revit.uidoc are LIVE @property accessors -- they always return
# whichever document is active RIGHT NOW. Assigning one to a module global
# snapshots its value ONCE, at the first import of `vibe`, and freezes it to
# whatever document happened to be active then. With two models open and the
# user switching between them that snapshot goes stale, and any helper that
# read it would silently act on the wrong model -- a transaction opened on the
# stale doc, a collector enumerating the stale doc's elements with no error.
#
# So the helpers below NEVER read these globals; they call _doc()/_uidoc()/
# _uiapp(), which re-resolve on every call. The globals are kept only for
# backward compatibility: `vibe/__init__.py` re-exports them, and some callers
# do `from vibe.helpers import doc`. IronPython 2.7 has no module-level
# __getattr__ (that is Python 3.7+), so these names cannot be made to
# re-resolve on plain attribute access -- a caller that reads helpers.doc /
# vibe.doc still gets the frozen snapshot. Any such caller in a
# model-reading or transaction path must be repointed to _doc() (or read
# revit.doc directly) instead of these names.
uiapp = HOST_APP.uiapp
uidoc = revit.uidoc
doc = revit.doc
def _uiapp():
"""The live UIApplication, re-resolved on each call (never a snapshot)."""
return HOST_APP.uiapp
def _uidoc():
"""The active UIDocument, re-resolved on each call (never a snapshot)."""
return revit.uidoc
def _doc():
"""The active Document, re-resolved on each call (never a snapshot)."""
return revit.doc
# ----------------------------------------------------------------- collecting
def _as_bic(category):
"""Accept BuiltInCategory, 'OST_Walls', or 'Walls'."""
if isinstance(category, BuiltInCategory):
return category
name = str(category)
if not name.startswith('OST_'):
name = 'OST_' + name.replace(' ', '')
bic = getattr(BuiltInCategory, name, None)
if bic is None:
raise ValueError('unknown BuiltInCategory: ' + str(category))
return bic
def q(category=None, cls=None, view=None, types=False, document=None):
"""One-liner element query. Returns a list.
q('OST_Walls') -> all wall instances
q(cls=DB.Wall) -> same, by class
q('OST_Doors', view=doc.ActiveView)-> only what's visible in the view
q('OST_Walls', types=True) -> wall types
"""
d = document or _doc()
col = FilteredElementCollector(d, view.Id) if view is not None \
else FilteredElementCollector(d)
if category is not None:
col = col.OfCategory(_as_bic(category))
if cls is not None:
col = col.OfClass(cls)
col = col.WhereElementIsElementType() if types \
else col.WhereElementIsNotElementType()
return list(col.ToElements())
def all_of(category, document=None):
"""All instances of a category."""
return q(category, document=document)
def types_of(category, document=None):
"""All element types of a category."""
return q(category, types=True, document=document)
def by_name(elements, name):
"""First element whose .Name matches, else None."""
for el in elements:
try:
if el.Name == name:
return el
except Exception:
continue
return None
def selected(document=None):
"""Currently selected elements as a list."""
d = document or _doc()
return [d.GetElement(i) for i in _uidoc().Selection.GetElementIds()]
def pick(message='Pick elements', multiple=True):
"""Interactive pick. Returns a list of elements (empty if cancelled)."""
from Autodesk.Revit.UI.Selection import ObjectType
_uidoc_ = _uidoc()
_doc_ = _doc()
try:
if multiple:
refs = _uidoc_.Selection.PickObjects(ObjectType.Element, message)
return [_doc_.GetElement(r) for r in refs]
ref = _uidoc_.Selection.PickObject(ObjectType.Element, message)
return [_doc_.GetElement(ref)]
except Exception:
return []
# ---------------------------------------------------------------- parameters
def _find_param(el, name):
"""Resolve a parameter by BuiltInParameter, exact name, then case-insensitive."""
if isinstance(name, BuiltInParameter):
return el.get_Parameter(name)
prm = el.LookupParameter(str(name))
if prm is not None:
return prm
lowered = str(name).lower()
for candidate in el.Parameters:
if candidate.Definition.Name.lower() == lowered:
return candidate
return None
def p(el, name, default=None):
"""Read a parameter value, typed. Falls back to `default` if missing."""
prm = _find_param(el, name)
if prm is None:
return default
st = prm.StorageType
if st == StorageType.String:
return prm.AsString()
if st == StorageType.Integer:
return prm.AsInteger()
if st == StorageType.Double:
return prm.AsDouble()
if st == StorageType.ElementId:
return prm.AsElementId()
return default
def p_display(el, name, default=None):
"""Read a parameter as the string Revit shows in the UI (with units)."""
prm = _find_param(el, name)
if prm is None:
return default
val = prm.AsValueString()
return val if val is not None else prm.AsString()
def set_p(el, name, value):
"""Write a parameter. Must be called inside a transaction.
Returns True on success, False if the parameter is missing or read-only.
"""
prm = _find_param(el, name)
if prm is None or prm.IsReadOnly:
return False
return bool(prm.Set(value))
# -------------------------------------------------------------- transactions
@contextmanager
def tx(name='vibe'):
"""Transaction context manager - commits on success, rolls back on error.
with tx('Rename walls'):
set_p(wall, 'Comments', 'checked')
"""
t = Transaction(_doc(), name)
t.Start()
try:
yield t
t.Commit()
except Exception:
if t.HasStarted() and not t.HasEnded():
t.RollBack()
raise
@contextmanager
def txg(name='vibe group'):
"""Transaction group - lets you undo several transactions as one step."""
g = TransactionGroup(_doc(), name)
g.Start()
try:
yield g
g.Assimilate()
except Exception:
if g.HasStarted() and not g.HasEnded():
g.RollBack()
raise
# --------------------------------------------------------------------- units
# Revit stores lengths in decimal FEET internally, always.
def ft(millimeters):
"""mm -> internal feet."""
return UnitUtils.ConvertToInternalUnits(millimeters, _MILLIMETERS)
def mm(internal_feet):
"""internal feet -> mm."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _MILLIMETERS)
def m(internal_feet):
"""internal feet -> meters."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _METERS)
def sqft_to_sqm(internal_area):
"""internal square feet -> square meters."""
return UnitUtils.ConvertFromInternalUnits(internal_area, _SQUARE_METERS)
# -------------------------------------------------------------------- output
def out():
"""The pyRevit output window for the running script."""
return script.get_output()
def log(*args):
"""print() that always lands in the pyRevit output window."""
print(' '.join(str(a) for a in args))
def table(rows, columns=None, title=None):
"""Render a list of lists as a table in the pyRevit output window."""
output = script.get_output()
if title:
output.print_md('### ' + title)
output.print_table(table_data=[[str(c) for c in r] for r in rows],
columns=columns or [])