Files
willtools-demo/WillTools.extension/lib/vibe/latrisa.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

233 lines
9.9 KiB
Python

# -*- coding: utf-8 -*-
"""Intermediate lateral model -> files. Revit-free, no I/O at module scope.
THREE OUTPUTS, AND WHY THE RISA ONE IS A DRAFT
----------------------------------------------
`emit_json` the intermediate structure verbatim. This is the ground
truth and the only output that claims to be complete.
`emit_csv` nodes and members as two plain tables. Any engineer can open
these, and any script can read them. No format risk at all.
`emit_risa_draft`
RISA-3D .r3d text. **DRAFT. NOT VERIFIED.**
What was established about the RISA-3D format, and from where:
RISA's own Appendix D - File Format
https://help.risa.com/risahelp/risa3d/Content/Appendices/Appendix%20D%20(3D).htm
* .r3d is plain ASCII, keyword driven, editable in Notepad.
* Data is divided into segments delimited by bracketed keyword labels:
[NODES], [MEMBERS], [PLATES], [BOUNDARY_CONDITIONS], [BASIC_LOAD_CASES],
[UNITS], [VERSION_NO], [PROGRAM_INFO], and the file opens with
[RISA-3D_INPUT_DATA].
* The record count follows the keyword label in angle brackets: `<n>`.
* Every record ends with a semicolon, except the project-description
records between [.PROJECT_DESCRIPTION] and [.END_PROJECT_DESCRIPTION].
* Labels are quoted strings. A member label may be up to 20 characters.
* A node record is "LABEL" x y z ... ;
* A member record is "LABEL" "DESIGN_LIST" "SHAPE_LABEL" iNode jNode ... ;
with later fields covering K node, rotation, section-set offset,
tension/compression, design rules, material, end releases, end offsets.
What was NOT established, and this is the reason for the word DRAFT:
The documentation designates the fields of each record by letter (a, b, c,
...) and the table that maps those letters to meanings could not be
retrieved -- the mirror that carries it returns 403, and no complete
worked example of a [NODES] or [MEMBERS] record was found in public
sources. So the field COUNT, the field ORDER, and the encoding of the
[UNITS] line are not known with confidence. Nor is which RISA version's
schema a given install expects.
Guessing them would produce a file that very plausibly opens and is
quietly wrong about which column is which -- the single worst outcome
available to this tool. So the RISA output is written with a
`.r3d.DRAFT.txt` extension. That is deliberate and is not a naming
accident: it cannot be double-clicked into RISA, and anybody who wants to
try it has to rename it, which is the moment they read the header.
To finish this properly: open any existing .r3d from this office in
Notepad, paste its [UNITS], [NODES] and [MEMBERS] segments into the repo
issue, and the emitter becomes a real one in about an hour. Until then the
CSV and the JSON are the outputs to use.
IronPython 2.7: no f-strings.
"""
# pylint: disable=invalid-name,broad-except
import json
from vibe import latmodel
# A shape label RISA cannot possibly resolve. Used wherever a section is
# unknown. The point is that RISA fails the member rather than analysing it
# with whatever the current default shape happens to be -- an unanalysable
# file beats a wrong answer.
UNRESOLVED_SHAPE = 'WILLTOOLS_UNRESOLVED_SECTION'
UNRESOLVED_MATERIAL = 'WILLTOOLS_UNRESOLVED_MATERIAL'
def emit_json(model):
"""The intermediate structure. Complete, and the only output that is."""
return json.dumps(model, indent=2, sort_keys=True)
def _csv_cell(v):
if v is None:
return ''
s = v if isinstance(v, str) else str(v)
if any(c in s for c in ',"\n'):
return '"' + s.replace('"', '""') + '"'
return s
def _csv(header, rows):
lines = [','.join(header)]
for r in rows:
lines.append(','.join([_csv_cell(c) for c in r]))
return '\n'.join(lines) + '\n'
def emit_nodes_csv(model):
"""label, x, y, z, unit, how many members meet here."""
rows = [[n['label'], '{0:.6f}'.format(n['x']), '{0:.6f}'.format(n['y']),
'{0:.6f}'.format(n['z']), model['length_unit'],
len(n['members']), ' '.join(n['members'])]
for n in model['nodes']]
return _csv(['label', 'x', 'y', 'z', 'unit', 'member_count', 'members'],
rows)
def emit_members_csv(model):
"""One row per member, with the blanks LEFT BLANK.
An empty `section` cell is the honest representation of a section this
could not read. It is never filled with a plausible-looking default, and
`unresolved` on the same row says the same thing in words so that a
reader skimming for problems finds them without comparing columns.
"""
rows = []
for m in model['members']:
sec = m.get('section') or {}
rows.append([
m['label'], m['i_node'], m['j_node'],
'{0:.6f}'.format(m['length_ft']), model['length_unit'],
sec.get('name'), sec.get('source'),
m.get('material'),
'', # end releases: never extracted
m.get('axis_source'),
m.get('category'), m.get('mark'), m.get('element_id'),
m.get('desc'), m.get('token'),
'; '.join(m.get('unresolved') or []),
])
return _csv(['label', 'i_node', 'j_node', 'length', 'unit',
'section', 'section_source', 'material', 'end_releases',
'axis_source', 'revit_category', 'mark', 'revit_element_id',
'identity_description', 'marker_matched', 'unresolved'],
rows)
DRAFT_HEADER = """\
! =====================================================================
! DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE. DO NOT LOAD AND TRUST.
! =====================================================================
!
! Written by WillTools lateral extraction from a Revit model.
!
! The section keywords, the <n> record counts, the semicolon terminators
! and the quoted labels below follow RISA's published Appendix D. The
! FIELD ORDER inside each record does NOT: Appendix D names those fields
! only by letter and the table mapping letters to meanings could not be
! obtained. The numbers below are in the right sections and are very
! likely in the wrong columns.
!
! This file is named .r3d.DRAFT.txt so that it cannot be opened into RISA
! by accident. Renaming it to .r3d is a decision, not a convenience.
!
! WHAT IS NOT IN THIS FILE, AT ALL:
! * end releases -- not extractable from these Revit families
! * boundary conditions / supports -- none. This model is unrestrained.
! * loads, load combinations, diaphragms, mass, seismic parameters
! * plates, walls as shells (walls appear as line members only)
! * member offsets, K-node orientation, unbraced lengths, design rules
!
! Use the .json and the two .csv files beside this one. They are complete
! statements of what was extracted; this file is not.
!
! Length unit: {unit} (Revit internal decimal feet, project coordinates)
! Node weld tolerance: {tol} ft
! ---------------------------------------------------------------------
"""
def emit_risa_draft(model):
"""RISA-3D-shaped text, clearly and repeatedly labelled as a draft."""
L = []
L.append(DRAFT_HEADER.format(unit=model['length_unit'],
tol=model['node_tol_ft']))
L.append('[RISA-3D_INPUT_DATA]')
L.append('')
L.append('[.PROJECT_DESCRIPTION]')
L.append('DRAFT lateral extraction -- field order unverified')
L.append('Model: {0}'.format(model.get('model_title') or '(unnamed)'))
L.append('[.END_PROJECT_DESCRIPTION]')
L.append('')
# [UNITS] is deliberately NOT emitted as data. Its field encoding is the
# part of the format least established, and a units line in the wrong
# order is the most dangerous single line the file could contain -- it
# rescales everything silently. A comment says what the numbers are in.
L.append('! [UNITS] omitted on purpose -- the encoding of this line is')
L.append('! not established, and a wrong units line rescales the whole')
L.append('! model without any visible sign. All lengths below are FEET.')
L.append('')
L.append('[NODES] <{0}>'.format(len(model['nodes'])))
for n in model['nodes']:
L.append(' "{0}" {1:.6f} {2:.6f} {3:.6f} ;'
.format(n['label'], n['x'], n['y'], n['z']))
L.append('[END_NODES]')
L.append('')
L.append('[MEMBERS] <{0}>'.format(len(model['members'])))
for m in model['members']:
sec = m.get('section') or {}
shape = sec.get('name') or UNRESOLVED_SHAPE
mat = m.get('material') or UNRESOLVED_MATERIAL
L.append(' "{0}" "" "{1}" "{2}" "{3}" "{4}" ; ! {5}'
.format(m['label'][:20], shape, mat,
m['i_node'], m['j_node'],
'unresolved: ' + ', '.join(m['unresolved'])
if m['unresolved'] else 'complete'))
L.append('[END_MEMBERS]')
L.append('')
L.append('! [BOUNDARY_CONDITIONS] intentionally absent -- no supports')
L.append('! were extracted. Nothing here is restrained.')
L.append('')
L.append('! ---- what was skipped, and why -----------------------------')
if model['skipped']:
for s in model['skipped']:
L.append('! skipped element {0} ({1}, mark {2}): {3}'
.format(s.get('element_id'), s.get('category'),
s.get('mark'), s.get('reason')))
else:
L.append('! nothing was skipped.')
L.append('')
for note in model['notes']:
L.append('! NOTE: ' + note)
return '\n'.join(L) + '\n'
def file_set(model, stem):
"""[(filename, text)] -- everything this tool writes, in one place.
The RISA file is last and its name carries the warning, so that a
directory listing sorted any way still shows the complete outputs first.
"""
return [
(stem + '-lateral.json', emit_json(model)),
(stem + '-lateral-nodes.csv', emit_nodes_csv(model)),
(stem + '-lateral-members.csv', emit_members_csv(model)),
(stem + '-lateral.r3d.DRAFT.txt', emit_risa_draft(model)),
]