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.
415 lines
17 KiB
Python
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
|