# -*- 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