Files
willtools-demo/WillTools.extension/lib/vibe/latmodel.py
T
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

415 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""The Revit-free half of lateral extraction: marker, model, node graph.
This file is the `ticketpage.py` of the lateral tool -- no Revit import at
module scope, no I/O, no `doc`. `vibe.latextract` is the `ticketchecks.py`
side: it walks the model and hands raw dicts in here. `vibe.latrisa` turns
what comes out into files.
WHY THE SHAPE IS THIS CAREFUL
-----------------------------
Every other tool in this repository produces something a human reads and
judges. This one produces an input file for structural analysis software.
The failure mode is therefore new: a file that OPENS and is subtly wrong is
much worse than no file at all, because the wrongness arrives wearing the
authority of a finished model.
So the rule throughout: **nothing this cannot determine is ever defaulted.**
A member whose section could not be read does not get a section -- it gets
`section = None`, its label goes in `member['unresolved']`, and the report
names it. The RISA draft writes an obviously-bogus shape label for it so
that RISA refuses the member rather than analysing a guess.
And the standing distinction this codebase already draws (see the NOT RUN
discussion in `ticketchecks.py`): "could not judge" is not "judged and found
nothing". `collect()` returns `None` for a run that could not happen at all
-- no marker configured, nothing selectable -- and an empty model for a run
that happened and found nothing marked. The button reports those two
differently, on purpose.
IronPython 2.7: no f-strings, no dataclasses, no typing syntax.
"""
# pylint: disable=invalid-name,broad-except
import math
import re
# --------------------------------------------------------------- the marker
#
# The user marks lateral-resisting members BY HAND, by typing into a
# free-text identity field. The owner's own spelling is "lateral-resistent";
# the conventional English is "lateral-resistant". Both are accepted, because
# the field is typed by a person and a tool that rejects the spelling its own
# owner uses is a tool nobody marks anything for.
#
# These are the DEFAULTS. The live values come from the plant layer under
# `lateral.marker_tokens` (declared in knowledge.DEFAULTS) so a plant that
# writes something else -- "LFRS", "seismic" -- changes a YAML line rather
# than this file.
DEFAULT_MARKER_TOKENS = ['lateral-resistent', 'lateral-resistant']
# Words that, immediately in front of the marker, invert it. "NOT
# LATERAL-RESISTENT" is a phrase a detailer plausibly types, and a plain
# substring match would read it as a mark. We neither include it nor drop it
# silently: it comes back as AMBIGUOUS and the report names the element and
# quotes the text, for a human to settle. Guessing either way here is the
# kind of quiet error this whole file exists to avoid.
NEGATORS = ('not', 'non', 'no', 'never', 'excl', 'exclude', 'excluded')
MATCH_NO = 'no'
MATCH_YES = 'yes'
MATCH_AMBIGUOUS = 'ambiguous'
_NONWORD = re.compile(r'[^a-z0-9]+')
def normalise(text):
"""Lowercase; every run of non-alphanumerics becomes one space.
This is what makes the match tolerant of how the token was typed:
'Lateral-Resistent', 'LATERAL RESISTANT', 'lateral_resistent' and
'lateral resistent' all normalise to the same thing.
It deliberately does NOT remove the separator entirely, so the run-together
'lateralresistent' does not match. That is a judgement call: accepting it
would mean 'lateral' followed by anything starting with 'resistent' also
matches, and the tool cannot tell a typo from a different phrase. An
unmatched element is visible in the report as "not marked"; a wrongly
matched one is invisible.
"""
if text is None:
return ''
try:
low = text.lower()
except Exception:
low = str(text).lower()
return _NONWORD.sub(' ', low).strip()
def match_marker(text, tokens=None):
"""(verdict, matched_token_or_None) for one free-text field.
verdict is MATCH_YES / MATCH_NO / MATCH_AMBIGUOUS.
ACCEPTS (verdict YES):
'lateral-resistent' exactly, any case
'lateral-resistant' the conventional spelling
'LATERAL RESISTENT' any separator run: - _ space . /
'shear wall, lateral-resistent' surrounded by other text
'lateral-resistent (see S2.1)' trailing text
REJECTS (verdict NO):
'lateral' on its own -- half a token
'resistant' on its own
'lateralresistent' no separator at all (see normalise)
'laterally resistant' 'laterally' is not 'lateral'
'' / None an empty field is not a mark
FLAGS (verdict AMBIGUOUS -- never counted either way, always reported):
'NOT lateral-resistent'
'non lateral resistant'
"""
hay = normalise(text)
if not hay:
return MATCH_NO, None
padded = ' ' + hay + ' '
for raw in (tokens or DEFAULT_MARKER_TOKENS):
needle = normalise(raw)
if not needle:
continue
# Whole-token match: the marker must sit on word boundaries, so
# 'laterally resistant' does not match 'lateral resistant'.
at = padded.find(' ' + needle + ' ')
if at < 0:
continue
before = padded[:at].strip().split(' ')
if before and before[-1] in NEGATORS:
return MATCH_AMBIGUOUS, raw
return MATCH_YES, raw
return MATCH_NO, None
# ------------------------------------------------------------------- units
#
# Revit's internal length unit is decimal FEET. Always. Handing inches to
# XYZ() is a 12x error that has already cost this project real time, so the
# unit travels WITH the number everywhere in this structure rather than
# being remembered. Nothing in this file converts anything: feet in, feet
# out. RISA's own default length unit is feet too, which is why the emitter
# has no conversion either -- but it states the unit in the file instead of
# relying on that.
LENGTH_UNIT = 'ft'
# How close two member endpoints must be to count as the same node.
# 1/8 inch. Not a modelling tolerance -- a "did the detailer snap these
# together" tolerance. Anything looser starts welding members that a
# structural engineer means to be separate.
DEFAULT_NODE_TOL_FT = 0.0104167 # 1/8"
# ----------------------------------------------------------- raw member in
#
# What `latextract` hands over per element. Every field may be None, and
# None means "the model did not tell us", never "zero" or "default":
#
# element_id int
# category 'OST_Walls'
# mark piece mark / control mark, or None
# desc the identity-description text the marker was found in
# token which marker spelling matched
# i, j (x, y, z) tuples in FEET, or None if no axis could be found
# axis_source 'location_curve' | 'bounding_box' | None
# section {'name':..,'source':..} or None
# material str or None
# note free text from the gatherer
def _key(pt, tol):
"""Grid key for endpoint welding, at the tolerance."""
return (int(round(pt[0] / tol)), int(round(pt[1] / tol)),
int(round(pt[2] / tol)))
def _dist(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
+ (a[2] - b[2]) ** 2)
def build(raw_members, node_tol_ft=None, model_title=None):
"""Raw gathered members -> the intermediate structure. Pure.
Returns a dict. Never raises on a bad member: a member that cannot be
turned into two nodes is moved into `skipped` with a reason, because a
silently dropped member is exactly the failure this tool must not have.
"""
tol = node_tol_ft or DEFAULT_NODE_TOL_FT
nodes = []
index = {}
members = []
skipped = []
def node_for(pt):
"""Weld to an existing node within tol, else make a new one.
Snapping is to a GRID at the tolerance, then confirmed by true
distance against the candidates in the 27 neighbouring cells -- a
plain grid alone would fail to weld two points a hair either side of
a cell boundary.
"""
k = _key(pt, tol)
best = None
best_d = None
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for dz in (-1, 0, 1):
for idx in index.get((k[0] + dx, k[1] + dy, k[2] + dz), ()):
d = _dist(pt, (nodes[idx]['x'], nodes[idx]['y'],
nodes[idx]['z']))
if d <= tol and (best_d is None or d < best_d):
best, best_d = idx, d
if best is not None:
return best
idx = len(nodes)
nodes.append({'label': 'N{0}'.format(idx + 1),
'x': pt[0], 'y': pt[1], 'z': pt[2],
'members': []})
index.setdefault(k, []).append(idx)
return idx
for n, raw in enumerate(raw_members):
i_pt, j_pt = raw.get('i'), raw.get('j')
label = 'M{0}'.format(n + 1)
if not i_pt or not j_pt:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'no member axis could be established '
'(no location curve, and no usable bounding box)'})
continue
if _dist(i_pt, j_pt) <= tol:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'the two ends are the same point to within the node '
'tolerance -- a zero-length member is not analysable'})
continue
ii, jj = node_for(i_pt), node_for(j_pt)
if ii == jj:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'both ends welded to the same node at the '
'{0} ft tolerance'.format(tol)})
continue
unresolved = []
sec = raw.get('section')
if not sec:
unresolved.append('section')
elif not sec.get('verified'):
# A Revit type NAME is not a section property. It is the thing an
# engineer maps to a RISA shape by hand, and it is worth carrying
# -- but calling it a section would be the exact silent default
# this file exists to refuse. Measured on this model 2026-08-30:
# a type named 28" x 48" reports a parameterised rectangle 28"
# wide and 20 FEET deep. The API's own section object is wrong
# here, so no dimension read from it is trusted.
unresolved.append('verified section properties '
'(only a type name was read)')
if not raw.get('material'):
unresolved.append('material')
# End releases are NEVER extracted -- see `notes` below. Listing it
# per member keeps it in front of whoever reads one member's row,
# not only in the preamble.
unresolved.append('end releases')
mem = {
'label': label,
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'desc': raw.get('desc'),
'token': raw.get('token'),
'i_node': nodes[ii]['label'],
'j_node': nodes[jj]['label'],
'length_ft': _dist(i_pt, j_pt),
'axis_source': raw.get('axis_source'),
'section': raw.get('section'),
'material': raw.get('material'),
'releases': None,
'unresolved': unresolved,
'note': raw.get('note'),
}
members.append(mem)
nodes[ii]['members'].append(label)
nodes[jj]['members'].append(label)
model = {
'schema': 'willtools.lateral/1',
'model_title': model_title,
'length_unit': LENGTH_UNIT,
'coordinate_note': 'Revit project internal coordinates, decimal feet. '
'Not shared coordinates, not a survey datum.',
'node_tol_ft': tol,
'nodes': nodes,
'members': members,
'skipped': skipped,
}
model['connectivity'] = connectivity(model)
model['notes'] = notes(model)
return model
def connectivity(model):
"""What the node graph does and does not say. Facts, not a verdict.
Two things a structural model needs that a Revit precast model does not
hand over, both reported here rather than fixed:
free_ends a node only one member reaches. Fine at a support, a
disconnected member anywhere else -- and this cannot
tell which, because supports are not extracted.
near_misses two endpoints close enough to be suspicious but further
apart than the weld tolerance. Almost always a detailer
gap the analysis model should close; NEVER closed here.
"""
free = [n['label'] for n in model['nodes'] if len(n['members']) == 1]
tol = model['node_tol_ft']
# "Suspicious" = up to 6 inches. Wide enough to catch a real gap, narrow
# enough that it is not just listing the whole model.
near = []
ns = model['nodes']
# The near-miss search is O(n^2). Above this it would freeze Revit's UI
# thread, which is a hard rule here. Skipping it is reported as skipped
# (near_miss_checked False) rather than as "no near misses found" --
# the same could-not-judge / judged-and-found-nothing distinction the
# rest of this file keeps.
checked = len(ns) <= 1200
if not checked:
return {'node_count': len(ns), 'member_count': len(model['members']),
'free_ends': free, 'near_misses': [], 'near_miss_total': None,
'near_miss_checked': False,
'interior_intersections_checked': False}
for a in range(len(ns)):
for b in range(a + 1, len(ns)):
d = _dist((ns[a]['x'], ns[a]['y'], ns[a]['z']),
(ns[b]['x'], ns[b]['y'], ns[b]['z']))
if tol < d <= 0.5:
near.append({'a': ns[a]['label'], 'b': ns[b]['label'],
'gap_ft': d})
near.sort(key=lambda r: r['gap_ft'])
return {
'node_count': len(ns),
'member_count': len(model['members']),
'free_ends': free,
'near_misses': near[:200],
'near_miss_total': len(near),
'near_miss_checked': True,
# A member end landing part-way along another member is NOT a
# connection here and is not searched for. Saying so is the point.
'interior_intersections_checked': False,
}
def notes(model):
"""The standing caveats, as sentences, carried inside the structure.
They travel with the data rather than living only in the button's report,
so that the JSON handed to somebody three months from now still says what
it does not contain.
"""
out = [
'Coordinates are Revit internal decimal FEET, in project coordinates.',
'END RELEASES ARE NOT EXTRACTED. Revit carries no reliable release '
'information for these precast families, so every member here is '
'released nowhere. Loaded as-is, an analysis program will treat every '
'joint as fully fixed, which for a precast lateral system is very '
'likely wrong. Set releases by hand.',
'SUPPORTS ARE NOT EXTRACTED. No boundary conditions are emitted at '
'all. The model as written is unrestrained and will not solve until '
'supports are added.',
'LOADS ARE NOT EXTRACTED. No load cases, combinations, diaphragms, '
'masses or seismic parameters are present.',
'Members are connected only where their END POINTS coincide within '
'{0} ft. A member ending part-way along another member is NOT '
'connected to it, and no node is inserted to make it so.'
.format(model['node_tol_ft']),
]
unres_sec = [m['label'] for m in model['members']
if any(u.startswith('section') or u.startswith('verified section')
for u in m['unresolved'])]
if unres_sec:
out.append('{0} member(s) have no verified section property. They are '
'not given one. See the unresolved list.'.format(len(unres_sec)))
unres_mat = [m['label'] for m in model['members'] if 'material' in m['unresolved']]
if unres_mat:
out.append('{0} member(s) have no material. They are not given one.'
.format(len(unres_mat)))
return out
def unresolved_rows(model):
"""One row per member per missing thing -- element by element, not counts."""
rows = []
for m in model['members']:
for what in m['unresolved']:
rows.append({'label': m['label'], 'element_id': m['element_id'],
'mark': m['mark'], 'missing': what})
return rows
def is_analysable(model):
"""False whenever loading this straight into analysis software would lie.
Never used to suppress the export -- the export is always written and
always labelled. Used to decide how loudly the report shouts.
"""
if not model or not model['members']:
return False
if any(m['unresolved'] for m in model['members']):
return False
if model['connectivity']['free_ends']:
return False
return True