WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

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.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
"""The Revit half of lateral extraction. READ-ONLY. No Transaction, ever.
This is the `ticketchecks.py` of the pair: it imports the Revit API at module
scope and therefore cannot be unit tested, which is exactly why it does as
little thinking as possible. It reads parameters and geometry, hands plain
dicts to `vibe.latmodel`, and makes no judgement of its own beyond "the
model did not tell me".
WHAT WAS MEASURED IN THE LIVE MODEL, 2026-08-30
-----------------------------------------------
Two findings shaped this file, and both are the reason it refuses things
rather than inventing them.
1. ZERO elements carry the lateral marker today. Every string parameter of
every structural framing, wall, foundation and floor element, plus 1200
generic models, was read: nothing anywhere contains 'lateral' or
'resist'. The marker exists as a convention and has not been applied yet.
So the first honest run of this tool reports NOTHING MARKED, and that
must not look like an answer.
PARAMETER NAMES ARE NOT IN THIS FILE
------------------------------------
Not one Revit parameter is named here by its literal name. Every one is
reached through a ROLE resolved against the plant's knowledge file --
`member.identity_description` for the field the marker is typed into,
`member.mark` for the piece mark. That is not tidiness: a parameter name IS
this office's template, and a name written into shareable code means the
next plant cannot be served without editing code.
An unmapped role therefore makes this stand DOWN, with a sentence naming
the role. It never falls back to a name that happens to work here -- a
fallback would work on this model and silently find nothing on the next
one, which is precisely the failure this codebase is built against. The
only exception is Revit's own built-in Mark, used purely to label a row in
the report; it is a Revit concept, not a plant's, and nothing depends on it.
2. Every structural framing element in that model has a LocationPoint, not
a LocationCurve. They are point-placed precast families. A member axis
therefore cannot be read the usual way, and the bounding box is not a
substitute: a column whose COLUMN_LENGTH parameter reads 90'-5" has a
bounding box 140 ft tall, because the family carries geometry beyond the
member. Deriving an axis from that box would produce coordinates that
look completely reasonable and are wrong by 50 feet.
So point-placed elements are SKIPPED with that reason, unless a plant
explicitly opts in via `lateral.point_member_axis`. The default is None
-- refuse -- for the same reason `rails.left_rail_is_fixed` defaults to
None: a wrong default here is invisible.
IronPython 2.7.
"""
# pylint: disable=import-error,invalid-name,broad-except
from Autodesk.Revit.DB import (FilteredElementCollector, BuiltInCategory,
BuiltInParameter, ElementId, StorageType,
LocationCurve, LocationPoint, Line)
from vibe import knowledge as K
from vibe import latmodel
# The role the marker field is reached through. The NAME behind it lives in
# the plant's knowledge file and deliberately not here.
DESC_ROLE = 'member.identity_description'
MARK_ROLE = 'member.mark'
# Scanned when the user has selected nothing. Never unscoped -- an
# unfiltered collector took Revit down on 2026-08-18.
DEFAULT_CATEGORIES = [
'OST_StructuralFraming', 'OST_StructuralColumns', 'OST_Walls',
'OST_StructuralFoundation', 'OST_GenericModel',
]
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _sparam(el, name):
try:
pr = el.LookupParameter(name)
if pr is None or pr.StorageType != StorageType.String:
return None, False
v = pr.AsString()
return (v.strip() if v else None), True
except Exception:
return None, False
def read_description(el, param_names):
"""(text, source_param, any_such_param_exists).
`param_names` comes from the `member.identity_description` role. This
function never invents a name of its own.
The third value is the one that matters. If NO mapped parameter exists
on any element, the marker cannot have been written anywhere, and that
is 'could not judge' -- not 'nothing is marked'.
"""
exists = False
for name in param_names:
v, present = _sparam(el, name)
exists = exists or present
if v:
return v, name, True
return None, None, exists
def _mark(el, know):
"""The piece mark, for labelling report rows only.
The plant's own mark parameter first, through its role. Revit's built-in
Mark second -- that is a Revit concept rather than an office one, it is
the sanctioned general fallback (see knowledge.OPTIONAL_ROLES), and
nothing but a table cell depends on it.
"""
for name in K.param_names(know, MARK_ROLE):
v, _ = _sparam(el, name)
if v:
return v
try:
pr = el.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)
if pr is not None:
v = pr.AsString()
if v and v.strip():
return v.strip()
except Exception:
pass
return None
def _category(el):
try:
return el.Category.Name if el.Category else None
except Exception:
return None
def _type_name(el):
"""The Revit type name -- a section LABEL, never section properties.
`.Name` on a Revit type is ambiguous under IronPython on some bindings
(it came back empty for every element in the 2026-08-30 measurement
pass), so the built-in type parameter is tried first and reflection
second. Both are Revit's own, not this office's.
"""
try:
pr = el.get_Parameter(BuiltInParameter.ELEM_TYPE_PARAM)
if pr is not None:
v = pr.AsValueString()
if v and v.strip():
return v.strip()
except Exception:
pass
for getter in (lambda: el.Symbol.Name,
lambda: el.WallType.Name,
lambda: el.Name):
try:
v = getter()
if v and v.strip():
return v.strip()
except Exception:
continue
return None
def _material(el):
try:
pr = el.get_Parameter(BuiltInParameter.STRUCTURAL_MATERIAL_PARAM)
if pr is None:
return None
mid = pr.AsElementId()
if mid is None or mid == ElementId.InvalidElementId:
return None
me = el.Document.GetElement(mid)
if me is None:
return None
return me.Name
except Exception:
return None
def _pt(xyz):
return (xyz.X, xyz.Y, xyz.Z)
def axis_of(el, know):
"""((i, j), source, None) or (None, None, why_not). Feet. Read-only.
Feet because that is Revit's internal unit and nothing here converts.
The tuple carries no unit of its own; `latmodel.LENGTH_UNIT` is the one
place the unit is stated, and it is stated once.
"""
try:
loc = el.Location
except Exception:
loc = None
if isinstance(loc, LocationCurve):
try:
crv = loc.Curve
except Exception:
return None, None, 'the location curve could not be read'
if not isinstance(crv, Line):
return (None, None,
'the location curve is a {0}, not a straight line. A '
'curved member is not a single analysis member and is '
'not split into one here.'.format(type(crv).__name__))
try:
return ((_pt(crv.GetEndPoint(0)), _pt(crv.GetEndPoint(1))),
'location_curve', None)
except Exception as ex:
return None, None, 'end points unreadable: {0}'.format(ex)
if isinstance(loc, LocationPoint):
mode = K.get(know, 'lateral.point_member_axis', None)
if not mode:
return (None, None,
'placed as a point-based family, so it has no location '
'curve and no member axis. Its bounding box is not used: '
'measured on this model, a 90 ft column has a 140 ft '
'bounding box because the family carries geometry beyond '
'the member, so an axis taken from it would be wrong by '
'tens of feet while looking entirely plausible. Set '
'lateral.point_member_axis in knowledge.yaml to opt in.')
if mode != 'bounding_box_longest':
return (None, None,
'lateral.point_member_axis is set to "{0}", which this '
'version does not implement.'.format(mode))
try:
bb = el.get_BoundingBox(None)
except Exception:
bb = None
if bb is None:
return None, None, 'point-based, and it has no bounding box either'
lo, hi = _pt(bb.Min), _pt(bb.Max)
spans = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
k = spans.index(max(spans))
mid = [(lo[n] + hi[n]) / 2.0 for n in range(3)]
a, b = list(mid), list(mid)
a[k], b[k] = lo[k], hi[k]
return (tuple(a), tuple(b)), 'bounding_box', None
return (None, None,
'no Location of a kind that carries an axis (got {0})'
.format(type(loc).__name__ if loc is not None else 'None'))
def _wall_skip_reason(el):
"""Why a marked wall is not turned into a line member.
A shear wall is a panel. RISA models it as a wall panel or a mesh of
plates, and this tool emits no plates at all -- so turning the wall's
base curve into a horizontal line member would put a beam where the
engineer put a wall. The geometry is recorded in the skip row so that
nothing is lost, but no member is invented.
"""
bits = []
try:
loc = el.Location
if isinstance(loc, LocationCurve):
c = loc.Curve
a, b = _pt(c.GetEndPoint(0)), _pt(c.GetEndPoint(1))
bits.append('base line ({0:.3f},{1:.3f},{2:.3f}) to '
'({3:.3f},{4:.3f},{5:.3f}) ft'
.format(a[0], a[1], a[2], b[0], b[1], b[2]))
except Exception:
pass
try:
pr = el.get_Parameter(BuiltInParameter.WALL_USER_HEIGHT_PARAM)
if pr is not None:
bits.append('height {0:.3f} ft'.format(pr.AsDouble()))
except Exception:
pass
geom = '; '.join(bits) or 'geometry not readable'
return ('a wall is a shear PANEL, not a line member. This tool emits no '
'plates or wall panels, and will not put a beam where the '
'engineer put a wall. Recorded geometry: ' + geom)
def candidates(doc, selection=None, know=None):
"""The elements to inspect. Always scoped -- never a bare collector."""
if selection:
return list(selection)
cats = K.get(know, 'lateral.categories', None) or DEFAULT_CATEGORIES
out = []
for cn in cats:
bic = getattr(BuiltInCategory, cn, None)
if bic is None:
continue
try:
out.extend(FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception:
continue
return out
NOT_RUN_UNMAPPED = 'role_unmapped'
NOT_RUN_ABSENT = 'parameter_absent'
def scan(doc, selection=None, know=None):
"""Inspect candidates for the marker. Returns a dict, or (None, reason).
A tuple `(None, reason)` means COULD NOT JUDGE, and there are exactly
two ways to get there:
NOT_RUN_UNMAPPED the `member.identity_description` role has no
parameter name behind it, so this does not know
where the marker would be written.
NOT_RUN_ABSENT the role is mapped, but no candidate element
actually carries that parameter.
In both, the absence of marked members says nothing at all. A returned
dict with empty `marked` means the opposite: the question was asked
properly and the answer is that nothing is marked. The button reports
the two differently, which is the whole reason this is not just an
empty result.
"""
know = know or {}
params = K.param_names(know, DESC_ROLE)
if not params:
return None, NOT_RUN_UNMAPPED
tokens = K.get(know, 'lateral.marker_tokens', None) \
or latmodel.DEFAULT_MARKER_TOKENS
els = candidates(doc, selection=selection, know=know)
any_field = False
marked, ambiguous, unmarked = [], [], 0
for el in els:
text, source, present = read_description(el, params)
any_field = any_field or present
verdict, token = latmodel.match_marker(text, tokens)
if verdict == latmodel.MATCH_NO:
unmarked += 1
continue
row = {'element': el, 'element_id': _eid(el), 'desc': text,
'desc_param': source, 'token': token,
'category': _category(el), 'mark': _mark(el, know)}
(marked if verdict == latmodel.MATCH_YES else ambiguous).append(row)
if not any_field:
return None, NOT_RUN_ABSENT
return {'params_tried': params, 'tokens': tokens,
'candidates': len(els), 'marked': marked,
'ambiguous': ambiguous, 'unmarked': unmarked}, None
def gather(doc, selection=None, know=None):
"""(model, scan, None) -- or (None, None, reason) when it could not judge.
Read-only from start to finish. No Transaction is opened anywhere in
this module and none should ever be: the production model this runs on
is somebody's live work.
"""
found, why = scan(doc, selection=selection, know=know)
if found is None:
return None, None, why
raws = []
for row in found['marked']:
el = row['element']
cat = row['category'] or ''
if 'Wall' in cat:
raws.append({'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'],
'token': row['token'], 'i': None, 'j': None,
'axis_source': None, 'section': None,
'material': _material(el),
'note': _wall_skip_reason(el)})
continue
ends, source, why = axis_of(el, know)
tname = _type_name(el)
raws.append({
'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'], 'token': row['token'],
'i': ends[0] if ends else None,
'j': ends[1] if ends else None,
'axis_source': source,
# A type NAME, flagged unverified. See latmodel.build for why no
# dimension from GetStructuralSection() is trusted on this model.
'section': ({'name': tname, 'source': 'revit_type_name',
'verified': False} if tname else None),
'material': _material(el),
'note': why,
})
tol = K.get(know, 'lateral.node_tolerance_ft', None)
model = latmodel.build(raws, node_tol_ft=tol, model_title=doc.Title)
# latmodel.build only knows "no axis". The REASON lives out here, where
# the Revit object was, so put it back on the skip row -- a skip without
# a reason is the thing this codebase keeps refusing to ship.
by_id = {}
for r in raws:
if r.get('note'):
by_id[r['element_id']] = r['note']
for s in model['skipped']:
reason = by_id.get(s.get('element_id'))
if reason:
s['reason'] = reason
return model, found, None