# -*- 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 from pyrevit import revit, script, forms from vibe import designspec, knowledge, latextract, latmodel, latrisa 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.""" 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)) script.exit() # ------------------------------------------------------------------ context know = knowledge.load(doc) for problem in knowledge.config_problems(doc, know): 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])) 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.') 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.')