WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
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:
@@ -0,0 +1,414 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user