52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
408 lines
15 KiB
Python
408 lines
15 KiB
Python
# -*- 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
|