25592fa6c2
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.
352 lines
17 KiB
Python
352 lines
17 KiB
Python
# -*- 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
|