WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,10 @@
title: Lateral (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Half-built, dropdown only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,351 @@
# -*- coding: utf-8 -*-
#
# Extract the lateral-resisting members and write an analysis input file.
#
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
# when somebody has typed the marker into its identity description -- by
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
# Nothing here infers it from geometry, category or name.
#
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
# the output files go beside the model, never into the repository.
#
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
# loaded by accident. See vibe.latrisa for what is established about the RISA
# format and what is not.
#
# The selection idiom follows Ticket QC and Panel Lift Check: what is
# preselected is what is checked; nothing preselected scans the configured
# categories across the model. The difference from both is that this tool
# never asks the user to pick pieces from a list -- the marker IS the pick,
# and offering a second way to choose would let the two disagree.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Lateral (WIP)'
import io
import os
import traceback
from pyrevit import revit, script, forms
from vibe import designspec, knowledge, latextract, latmodel, latrisa
from vibe import runlog
from vibe import scope as scopelib
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = 'Lateral Extract'
def die(message):
"""The user has not given us something yet. Not a refusal to extract."""
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused',
report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
def not_run(message):
"""'Could not judge' -- a RESULT, and it must survive on the report.
The distinction this whole tool turns on: NOT RUN is not the same as
'ran and found nothing marked'. NOT RUN means the question could not be
asked at all, so the absence of marked members says nothing. A run that
asked and found nothing gets the ordinary report, with a nothing-found
banner and no files written.
"""
output.print_md('# Lateral Extract — NOT RUN')
output.print_md('**NOT RUN** — the extraction did not run.')
output.print_md('> {0}'.format(message))
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run',
report_text=message)
script.exit()
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# -------------------------------------------------------------- context
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused', report_text=problem)
forms.alert(problem, title=TITLE, exitscript=True)
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
scope = ('the {0} element(s) you selected'.format(len(selection))
if selection else 'the whole model, in the configured categories')
# --------------------------------------------------------------------- run
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
pb.update_progress(0, 2)
model, found, why = latextract.gather(doc, selection=selection or None,
know=know)
pb.update_progress(2, 2)
cats = knowledge.get(know, 'lateral.categories', None) \
or latextract.DEFAULT_CATEGORIES
if why == latextract.NOT_RUN_UNMAPPED:
not_run(
'The `parameters.{0}` role is not mapped, so this does not know '
'which parameter holds the identity description the marker is '
'typed into.\n\n'
'It will not guess a name. A guessed name would work on the '
'template it was guessed from and silently find nothing on every '
'other one, and "found nothing" is indistinguishable from "nothing '
'is marked" once it is on the screen.\n\n'
'Map it in the plant knowledge file and run again. Self Check lists '
'which layers are in play.'.format(latextract.DESC_ROLE))
if why == latextract.NOT_RUN_ABSENT:
# A literal role path, not a variable, so knowledge_drift can see it.
names = knowledge.param_names(know, 'member.identity_description')
not_run(
'The `parameters.{0}` role is mapped to **{1}**, but not one '
'candidate element actually carries that parameter.\n\n'
'That is not the same as "nothing is marked". The field is not '
'there, so nothing could have been marked, and this tool has no '
'opinion of its own about which members resist lateral load.\n\n'
'Scope searched: {2}. Categories: `{3}`.\n\n'
'Either add the parameter to these families, or point the role at '
'whatever field this office really uses.'
.format(latextract.DESC_ROLE, ', '.join(names), scope,
', '.join(cats)))
# ------------------------------------------------------------------ report
output.print_md('# Lateral extraction')
output.print_md(
'Model **{0}** · scope: {1} · {2} candidate element(s) read'
.format(doc.Title, scope, found['candidates']))
output.print_md(
'Marker tokens accepted: {0}. Read from: {1}.'
.format(', '.join(['`' + t + '`' for t in found['tokens']]),
', '.join(['`' + p + '`' for p in found['params_tried']])))
# ---- the declared scope, and why it is NOT applied here ------------------
#
# Every other button on this panel narrows what it judges to the job's
# declared scope (`vibe.scope`). This one deliberately does not, and says so
# rather than leaving the reader to assume either way.
#
# The reason is that the marker IS the pick. A member is lateral-resisting
# because somebody typed it into the identity description, one element at a
# time -- that is a person choosing, not the tool choosing, and this whole
# tool's rule is that a filter never overrides what the user picked. Beyond
# that, a lateral system is columns, beams and shear walls by nature; scoping
# it to one discipline's prefix would delete most of the frame and produce an
# analysis model that looks complete and is not. The line below is printed
# every run so the difference is visible on the page.
_declared = scopelib.of(know)
output.print_md(
'> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is '
'deliberately NOT applied here: a member is in this extraction because '
'somebody marked it, and a lateral system crosses every discipline on '
'the job. Every marked member is exported whatever its mark, and the '
'members another engineer owns are as much a part of the frame as '
'yours.'.format(_declared.label()))
n_marked = len(found['marked'])
n_amb = len(found['ambiguous'])
if not n_marked and not n_amb:
# Asked, and the answer was nothing. Distinct from NOT RUN above, and
# said in words rather than left as an empty table -- and critically,
# NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer.
output.print_md('## Nothing is marked')
output.print_md(
'> {0} element(s) were read and **not one** carries the marker. '
'The identity-description field exists, so the question was asked '
'properly and the answer is that no member has been marked as '
'lateral-resisting yet.\n\n'
'> **No files have been written.** An empty analysis file on disk '
'would look like a result.\n\n'
'> To mark a member, type `{1}` into its identity description '
'(`{2}`) and run this again.'
.format(found['candidates'], found['tokens'][0],
found['params_tried'][0]))
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Nothing is marked - no files written.')
script.exit()
if n_amb:
output.print_md('## Ambiguous marks — not counted either way')
output.print_md(
'The marker appears in these fields but is preceded by a negating '
'word, so it may mean the opposite. They are **excluded** from the '
'export and named here for you to settle.')
output.print_table(
[[str(r['element_id']),
r['mark'] or '', r['category'] or '', r['desc'] or '']
for r in found['ambiguous']],
columns=['element id', 'mark', 'category', 'identity description'])
# ---- what was selected, element by element -------------------------------
output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked))
output.print_table(
[[str(r['element_id']), r['mark'] or '', r['category'] or '',
r['desc_param'] or '', r['token'] or '', r['desc'] or '']
for r in found['marked']],
columns=['element id', 'mark', 'category', 'read from', 'matched',
'identity description'])
# ---- what was skipped, element by element, with the reason ---------------
output.print_md('## Skipped ({0})'.format(len(model['skipped'])))
if model['skipped']:
output.print_table(
[[str(s.get('element_id')), s.get('mark') or '',
s.get('category') or '', s.get('reason') or '']
for s in model['skipped']],
columns=['element id', 'mark', 'category', 'why it is not a member'])
else:
output.print_md('> Nothing was skipped.')
# ---- what became members -------------------------------------------------
output.print_md('## Members written ({0})'.format(len(model['members'])))
if model['members']:
output.print_table(
[[m['label'], m['i_node'], m['j_node'],
'{0:.3f}'.format(m['length_ft']),
(m.get('section') or {}).get('name') or '(none)',
m.get('material') or '(none)',
m.get('axis_source') or '',
'; '.join(m['unresolved'])]
for m in model['members']],
columns=['member', 'i', 'j', 'length ft', 'section label',
'material', 'axis from', 'NOT determined'])
else:
output.print_md(
'> **No member survived extraction.** Every marked element is in the '
'skipped table above with its reason. Nothing analysable came out of '
'this run.')
# ---- what could not be determined ----------------------------------------
rows = latmodel.unresolved_rows(model)
output.print_md('## Not determined ({0} item(s))'.format(len(rows)))
output.print_md(
'Every one of these is **left empty** in the output. None of them is '
'given a default value. A member with no section is not quietly assigned '
'one; the draft file writes `{0}` for it, which no analysis program can '
'resolve, so it fails to analyse rather than analysing wrongly.'
.format(latrisa.UNRESOLVED_SHAPE))
if rows:
output.print_table(
[[r['label'], str(r['element_id']), r['mark'] or '', r['missing']]
for r in rows],
columns=['member', 'element id', 'mark', 'what is missing'])
# ---- connectivity --------------------------------------------------------
conn = model['connectivity']
output.print_md('## Connectivity')
output.print_md(
'{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only '
'one member reaching them. This tool does not extract supports, so it '
'cannot tell a support from a disconnected member -- both look like this.'
.format(conn['node_count'], conn['member_count'], model['node_tol_ft'],
len(conn['free_ends'])))
if not conn.get('near_miss_checked'):
output.print_md(
'> The near-miss search was **not run** ({0} nodes is too many for an '
'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no '
'near misses" -- nobody looked.'.format(conn['node_count']))
elif conn['near_misses']:
output.print_md(
'**{0} pair(s) of nodes sit within 6 inches of each other but further '
'apart than the weld tolerance.** These are almost certainly meant to '
'be the same point. They are **not** merged here.'
.format(conn['near_miss_total']))
output.print_table(
[[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])]
for r in conn['near_misses'][:40]],
columns=['node', 'node', 'gap ft'])
output.print_md(
'> A member ending part-way along another member is **not** connected to '
'it and no node is inserted to make it so.')
# ---- the standing caveats ------------------------------------------------
output.print_md('## What is deliberately not in the output')
for note in model['notes']:
output.print_md('- ' + note)
# ------------------------------------------------------------ where it goes
#
# Beside the MODEL, never in the repository.
start_dir = knowledge.get(know, 'lateral.output_dir', None) or \
designspec.sub_dir(doc, 'lateral')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
if not doc.PathName:
output.print_md(
'## Not written\n> This model has never been saved, so there is no '
'folder beside it to write to. Save the model and run again.')
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Model has never been saved - nothing written.')
script.exit()
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
written = []
for name, text in latrisa.file_set(model, stem):
path = os.path.join(start_dir, name)
try:
f = io.open(path, 'w', encoding='utf-8')
try:
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
finally:
f.close()
written.append(path)
except Exception as ex:
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
output.print_md('## Files written')
for path in written:
output.print_md('- `{0}`'.format(path))
output.print_md(
'> The `.json` and the two `.csv` files are complete statements of what '
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
'file -- its section keywords follow RISA\'s published Appendix D but '
'the field order inside each record is unverified, so it is named so '
'that it cannot be opened into RISA by accident. Read its header before '
'renaming it.')
if not latmodel.is_analysable(model):
output.print_md(
'> **This model would not analyse correctly as written**, and that '
'is on purpose: it has unresolved sections, free ends, no supports '
'and no releases. Fix those in the analysis package, not here.')
# The extraction ran and its files are on disk -- the record is 'ok' with
# the report text kept short, since record() caps at 20000 characters.
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok',
report_path=written[0] if written else None,
report_text=('{0} marked member(s), {1} skipped, '
'{2} node(s), {3} member(s) written; files: {4}'
.format(n_marked, len(model['skipped']),
conn['node_count'],
conn['member_count'],
'; '.join(written))))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise