WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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:
@@ -0,0 +1,532 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The shape of a design spec, and how it reads and writes as text.
|
||||
|
||||
Nothing in this module touches Revit, a document, or the filesystem -- it is
|
||||
pure data in, text or data out. That is deliberate: it is the part of the
|
||||
design-spec pipeline that can be exercised by an ordinary CPython test with no
|
||||
licence, no model and no running Revit, the same way `panellift` is the pure
|
||||
side of the handling-check boundary (see CLAUDE.md). `designspec.py` is the
|
||||
Revit side of THIS boundary -- it gathers evidence from the model and reads
|
||||
and writes these shapes to disk. Anything about what a spec MEANS, or how it
|
||||
prints, belongs here; anything about reading THIS model belongs there.
|
||||
|
||||
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib, no
|
||||
dataclasses.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from vibe import yamlkit
|
||||
from vibe.compat import string_types
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------- spec shape
|
||||
#
|
||||
# The contract every downstream check relies on. Anything the reader is
|
||||
# unsure about belongs in 'unresolved' rather than being guessed at.
|
||||
#
|
||||
# reinforcing.strand_layout is a MATRIX, not four parallel lists: columns_in
|
||||
# is where the strands sit across the piece (cumulative distance from datum,
|
||||
# same numbers a detailer would read off the dimension chain), and rows is
|
||||
# one entry per layer through the thickness, each carrying which of those
|
||||
# columns actually holds a strand at that height. A design where every column
|
||||
# has a strand at every row -- the ordinary case -- just has 'strands' filled
|
||||
# solid; a design that is not a full grid says so directly, column by column,
|
||||
# rather than forcing a human to reverse-engineer it from a total count.
|
||||
SPEC_TEMPLATE = {
|
||||
'schema': SCHEMA_VERSION,
|
||||
'design_number': None,
|
||||
'piece_description': None,
|
||||
'source': {'view': None, 'view_id': None, 'sheet': None,
|
||||
'model': None, 'extracted': None, 'extractor': None,
|
||||
'reviewed_by': None, 'reviewed_on': None},
|
||||
'concrete': {'fc_psi': None, 'fci_psi': None,
|
||||
'unit_weight_pcf': None, 'weight_class': None},
|
||||
'reinforcing': {
|
||||
'mild_grade_ksi': None,
|
||||
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
|
||||
'strand_groups': [], # {qty, dia_in, grade_ksi, it_lb, role, note}
|
||||
'strand_layout': {
|
||||
'datum': None, # 'left_rail' | 'right_rail' | 'centerline'
|
||||
'columns_in': [], # strand positions across the piece,
|
||||
# cumulative distance from the datum
|
||||
'rows': [], # [{'from_bottom_in':, 'strands': [...]}]
|
||||
# one entry per layer through the
|
||||
# thickness; 'strands' marks which column
|
||||
# holds a strand at that height
|
||||
'total_in': None, # the chain total; should equal the piece
|
||||
# width
|
||||
'note': None,
|
||||
},
|
||||
},
|
||||
'envelope': {'length': {'min_ft': None, 'max_ft': None,
|
||||
'min_text': None, 'max_text': None},
|
||||
'width': {'min_ft': None, 'max_ft': None,
|
||||
'min_text': None, 'max_text': None}},
|
||||
'thickness_in': None,
|
||||
'notes': [], # instructions that stay prose
|
||||
'unresolved': [], # [{text, reason}] nothing would guess at
|
||||
'confidence': {}, # field path -> high | medium | low
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_PATHS = (
|
||||
'design_number',
|
||||
'concrete.fc_psi',
|
||||
'concrete.fci_psi',
|
||||
)
|
||||
|
||||
|
||||
def get_path(spec, dotted, default=None):
|
||||
node = spec
|
||||
for part in dotted.split('.'):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return default
|
||||
node = node[part]
|
||||
return node if node is not None else default
|
||||
|
||||
|
||||
def validate_spec(spec):
|
||||
"""Cheap sanity check. Returns a list of complaints, empty when happy."""
|
||||
problems = []
|
||||
if not isinstance(spec, dict):
|
||||
return ['spec is not an object']
|
||||
if spec.get('schema') != SCHEMA_VERSION:
|
||||
problems.append('schema is {0}, expected {1}'
|
||||
.format(spec.get('schema'), SCHEMA_VERSION))
|
||||
for path in REQUIRED_PATHS:
|
||||
if get_path(spec, path) is None:
|
||||
problems.append('missing ' + path)
|
||||
|
||||
for i, group in enumerate(get_path(spec, 'reinforcing.strand_groups', []) or []):
|
||||
for key in ('qty', 'dia_in'):
|
||||
if group.get(key) is None:
|
||||
problems.append('strand_groups[{0}] missing {1}'.format(i, key))
|
||||
if group.get('qty') is not None and group['qty'] <= 0:
|
||||
problems.append('strand_groups[{0}] qty is {1}'.format(i, group['qty']))
|
||||
|
||||
fc = get_path(spec, 'concrete.fc_psi')
|
||||
fci = get_path(spec, 'concrete.fci_psi')
|
||||
if fc and fci and fci > fc:
|
||||
problems.append("f'ci ({0}) is higher than f'c ({1})".format(fci, fc))
|
||||
for field, lo, hi in (('concrete.fc_psi', 2000, 15000),
|
||||
('concrete.fci_psi', 1500, 12000),
|
||||
('concrete.unit_weight_pcf', 90, 200)):
|
||||
val = get_path(spec, field)
|
||||
if val is not None and not (lo <= val <= hi):
|
||||
problems.append('{0} = {1} is outside {2}..{3}'.format(field, val, lo, hi))
|
||||
return problems
|
||||
|
||||
|
||||
# ---------------------------------------------------------- design numbers
|
||||
#
|
||||
# THE ONE PLACE TWO DESIGN NUMBERS ARE COMPARED. It used to live in
|
||||
# `designspec.py`, which imports `Autodesk.Revit.DB` at module scope, so the
|
||||
# only way to ask "are these the same design?" was through a module that needs
|
||||
# a licence. `reinforcebind` has to ask it -- both in Revit and in
|
||||
# `tools/reinforce_offline.py`, which runs with no Revit at all -- so the
|
||||
# function moved down here, where nothing imports Revit, and `designspec`
|
||||
# re-exports it. Same rule, same name, one implementation: a second spelling
|
||||
# of this question is how two answers start.
|
||||
|
||||
# Used only when nomenclature is unmapped. Letters plus the first number is
|
||||
# the commonest convention, but it is a fallback, not a rule -- the plant's
|
||||
# knowledge file is where the real pattern belongs.
|
||||
FALLBACK_DESIGN_RE = r'^([A-Za-z]+)\s*(\d+)'
|
||||
|
||||
|
||||
def _design_pattern(know, pattern=None):
|
||||
"""The plant's design-number pattern, or the fallback.
|
||||
|
||||
`pattern` wins outright when it is given: it is how a caller that already
|
||||
resolved the key -- `reinforcebind.DonorFilter`, which must stay free of
|
||||
any knowledge import -- hands the same rule in without re-reading it.
|
||||
|
||||
Imported late: `knowledge` reaches for `designspec`, which imports this
|
||||
module, so a module-level import would be circular.
|
||||
"""
|
||||
if pattern:
|
||||
return pattern
|
||||
if know is None:
|
||||
return FALLBACK_DESIGN_RE
|
||||
from vibe import knowledge as K
|
||||
return K.get(know, 'nomenclature.design_number_re', FALLBACK_DESIGN_RE)
|
||||
|
||||
|
||||
def design_key(value, know=None, pattern=None):
|
||||
"""Reduce anything that names a design to the key everything matches on.
|
||||
|
||||
A design packet's cover sheet is numbered with a page suffix, so the same
|
||||
design is written PNL1 on a piece and PNL1.0 on its summary sheet, with
|
||||
PNL1.A and PNL1.B1 further into the packet. Comparing those raw splits one
|
||||
design into several that each fail to find the others -- so every design
|
||||
number, wherever it comes from, passes through here first.
|
||||
|
||||
Returns None for anything the pattern does not recognise, which keeps
|
||||
junk out of the picker rather than inventing a design for it. None is
|
||||
COULD NOT READ THIS, never "no design": a caller that treats it as a
|
||||
value will happily match two pieces whose design numbers nobody parsed.
|
||||
"""
|
||||
text = (value or '')
|
||||
try:
|
||||
text = text.strip()
|
||||
except AttributeError:
|
||||
return None
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
m = re.match(_design_pattern(know, pattern), text)
|
||||
except Exception: # pylint: disable=W0703
|
||||
m = re.match(FALLBACK_DESIGN_RE, text)
|
||||
if not m:
|
||||
return None
|
||||
groups = [g for g in m.groups() if g] or [m.group(0)]
|
||||
return ''.join([g.strip() for g in groups]).upper()
|
||||
|
||||
|
||||
def new_spec(design, evidence=None):
|
||||
"""A blank spec pre-filled with provenance."""
|
||||
spec = json.loads(json.dumps(SPEC_TEMPLATE)) # deep copy
|
||||
spec['design_number'] = design
|
||||
if evidence:
|
||||
views = evidence.get('summary_views') or []
|
||||
sheets = evidence.get('sheets') or []
|
||||
spec['source']['view'] = views[0]['name'] if views else None
|
||||
spec['source']['view_id'] = views[0]['id'] if views else None
|
||||
spec['source']['sheet'] = ('{0} - {1}'.format(sheets[0]['number'],
|
||||
sheets[0]['name'])
|
||||
if sheets else None)
|
||||
spec['source']['model'] = evidence.get('model')
|
||||
spec['source']['extracted'] = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
|
||||
return spec
|
||||
|
||||
|
||||
# An extractor that renames a field breaks every check that reads it, silently
|
||||
# and in the safe-looking direction: the value reads as None and the check
|
||||
# just does not run. Known synonyms are mapped back onto the canonical names
|
||||
# on load, and validate_spec still complains about anything left missing.
|
||||
_ALIASES = {
|
||||
'qty': ('count', 'quantity', 'number', 'n'),
|
||||
'dia_in': ('diameter_in', 'dia', 'diameter'),
|
||||
'grade_ksi': ('grade', 'grade_k', 'ksi', 'strength_ksi'),
|
||||
'it_lb': ('it_lbs', 'initial_tension_lb', 'it', 'tension_lb',
|
||||
'initial_tension', 'it_lb_force'),
|
||||
}
|
||||
|
||||
|
||||
def _first_number(value):
|
||||
"""270, '270K', '270 ksi' -> 270.0."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
m = re.search(r'[\d.]+', str(value).replace(',', ''))
|
||||
return float(m.group(0)) if m else None
|
||||
|
||||
|
||||
def normalize_spec(spec):
|
||||
"""Map known field synonyms back onto the canonical names, in place."""
|
||||
if not isinstance(spec, dict):
|
||||
return spec
|
||||
groups = get_path(spec, 'reinforcing.strand_groups', []) or []
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
for canonical, synonyms in _ALIASES.items():
|
||||
if group.get(canonical) is None:
|
||||
for alt in synonyms:
|
||||
if group.get(alt) is not None:
|
||||
group[canonical] = group[alt]
|
||||
break
|
||||
for numeric in ('qty', 'dia_in', 'grade_ksi', 'it_lb'):
|
||||
if group.get(numeric) is not None:
|
||||
group[numeric] = _first_number(group[numeric])
|
||||
if group.get('qty') is not None:
|
||||
group['qty'] = int(group['qty'])
|
||||
|
||||
concrete = spec.get('concrete')
|
||||
if isinstance(concrete, dict):
|
||||
for canonical, alt in (('fc_psi', 'fc'), ('fci_psi', 'fci'),
|
||||
('unit_weight_pcf', 'pcf')):
|
||||
if concrete.get(canonical) is None and concrete.get(alt) is not None:
|
||||
concrete[canonical] = concrete[alt]
|
||||
if concrete.get(canonical) is not None:
|
||||
concrete[canonical] = _first_number(concrete[canonical])
|
||||
return spec
|
||||
|
||||
|
||||
# --------------------------------------------------------- the strand matrix
|
||||
|
||||
_BLANK_MARKS = ('', 'null', 'none', '.', '-', '0')
|
||||
|
||||
|
||||
def strand_positions(layout):
|
||||
"""Cumulative distances from the datum, whichever spec shape wrote them.
|
||||
|
||||
New specs carry 'columns_in'; a spec written before the matrix shape
|
||||
carries the same numbers under 'positions_in'. Either way this is what
|
||||
check_strand_layout compares against what was actually modelled.
|
||||
"""
|
||||
if not isinstance(layout, dict):
|
||||
return []
|
||||
cols = layout.get('columns_in')
|
||||
if cols is None:
|
||||
cols = layout.get('positions_in')
|
||||
return [c for c in (cols or []) if c is not None]
|
||||
|
||||
|
||||
def strand_row_marks(row, n_columns):
|
||||
"""Which of n_columns holds a strand in this row.
|
||||
|
||||
A row from a spec written before the matrix shape has no 'strands' list
|
||||
at all -- it only ever said WHERE the row sits, not which columns it
|
||||
fills -- so it is read as a full row, which is what every such spec
|
||||
actually was.
|
||||
"""
|
||||
marks = row.get('strands') if isinstance(row, dict) else None
|
||||
if marks is None:
|
||||
return [True] * n_columns
|
||||
out = []
|
||||
for i in range(n_columns):
|
||||
value = marks[i] if i < len(marks) else None
|
||||
out.append(value is not None and
|
||||
str(value).strip().lower() not in _BLANK_MARKS)
|
||||
return out
|
||||
|
||||
|
||||
def _fmt_in(value):
|
||||
if value is None:
|
||||
return '?'
|
||||
text = '{0:.3f}'.format(float(value))
|
||||
text = text.rstrip('0').rstrip('.')
|
||||
return text if text else '0'
|
||||
|
||||
|
||||
_DIAGRAM_WIDTH = 68 # target character width of the scaled ruler
|
||||
|
||||
|
||||
def strand_diagram(layout):
|
||||
"""An ASCII elevation of the strand matrix, drawn from the data itself.
|
||||
|
||||
Datum on the left, the chain's segment lengths along a scaled ruler, and
|
||||
one row of marks per strand row at its height -- the same three things a
|
||||
detailer reads off the dimension chain on the drawing, in the same left-
|
||||
to-right order, so the two can be held side by side and compared. There
|
||||
is nowhere for the picture to get a number the data does not have: every
|
||||
character position is computed from columns_in / total_in / rows.
|
||||
"""
|
||||
if not isinstance(layout, dict):
|
||||
return ['(no strand layout)']
|
||||
columns = strand_positions(layout)
|
||||
total = layout.get('total_in')
|
||||
rows = [r for r in (layout.get('rows') or []) if isinstance(r, dict)
|
||||
and r.get('from_bottom_in') is not None]
|
||||
datum = layout.get('datum') or '(datum not set)'
|
||||
|
||||
if not columns or not total:
|
||||
return ['datum: {0}'.format(datum),
|
||||
'(not enough data to draw yet -- fill in columns_in, '
|
||||
'total_in and rows)']
|
||||
|
||||
scale = min(1.0, float(_DIAGRAM_WIDTH) / float(total))
|
||||
|
||||
def col(pos):
|
||||
return int(round(pos * scale))
|
||||
|
||||
width = col(total) + 1
|
||||
ticks = [0] + [col(c) for c in columns] + [col(total)]
|
||||
lengths = []
|
||||
prev = 0.0
|
||||
for c in columns:
|
||||
lengths.append(c - prev)
|
||||
prev = c
|
||||
lengths.append(total - prev)
|
||||
|
||||
ruler = [u'-'] * width
|
||||
for t in ticks:
|
||||
ruler[t] = u'+'
|
||||
for i in range(len(ticks) - 1):
|
||||
a, b = ticks[i], ticks[i + 1]
|
||||
label = _fmt_in(lengths[i])
|
||||
gap = b - a - 1
|
||||
if gap >= len(label):
|
||||
start = a + 1 + (gap - len(label)) // 2
|
||||
for j, ch in enumerate(label):
|
||||
ruler[start + j] = ch
|
||||
|
||||
lines = [u'datum: {0} (rail on the left, {1}" total)'.format(datum,
|
||||
_fmt_in(total)),
|
||||
u''.join(ruler)]
|
||||
|
||||
for r in sorted(rows, key=lambda row: -row['from_bottom_in']):
|
||||
marks = strand_row_marks(r, len(columns))
|
||||
line = [u' '] * width
|
||||
for pos, has in zip([col(c) for c in columns], marks):
|
||||
line[pos] = u'X' if has else u'.'
|
||||
n = sum(1 for m in marks if m)
|
||||
lines.append(u'{0} {1}" from bottom ({2} strand{3})'.format(
|
||||
u''.join(line), _fmt_in(r['from_bottom_in']), n,
|
||||
u'' if n == 1 else u's'))
|
||||
|
||||
lines.append(u'columns (in, from datum): ' +
|
||||
u', '.join(_fmt_in(c) for c in columns))
|
||||
return lines
|
||||
|
||||
|
||||
# --------------------------------------------------------- human-readable IO
|
||||
|
||||
SPEC_HEADER = u"""# WillTools design spec -- {design}
|
||||
#
|
||||
# A HAND-CORRECTABLE reading of the design summary sheet, not a certified
|
||||
# transcription. Every value below was read by machine (see source.extractor)
|
||||
# and NONE of it has been checked by a person unless source.reviewed_by is
|
||||
# set. Ticket QC and Panel Lift Check read this file, not the drawing --
|
||||
# compare the two before trusting either one.
|
||||
#
|
||||
# Edit it by hand where the machine got it wrong. It is read back exactly as
|
||||
# written: comments and blank lines are ignored, nothing here is re-derived
|
||||
# from anything else in the file.
|
||||
"""
|
||||
|
||||
_SOURCE_NOTE = u'''# Where this reading came from, and whether anyone has checked it since.
|
||||
# extractor says HOW it was read: 'regex' is the mechanical text/geometry
|
||||
# reader -- no judgement applied, every value either matched a rule or is
|
||||
# null. 'dsv4f-vision' is the second-pass AI reader (tools/aiextract_run.py,
|
||||
# launched by vibe.aiextract) that fills whatever the mechanical reader left
|
||||
# null or unresolved, plus the strand matrix, on DSV4F with vision --
|
||||
# owner's ruling, 2026-09-01. It never overwrites a value 'regex' already
|
||||
# placed; source.ai_filled lists which fields it actually added. 'opus' and
|
||||
# 'claude-code' are what an earlier AI reader stamped, in that order, before
|
||||
# this ruling -- read either one the same way you would 'dsv4f-vision'.
|
||||
# reviewed_by / reviewed_on are blank until a person sets them by hand after
|
||||
# checking this file against the drawing.'''
|
||||
|
||||
_CONCRETE_NOTE = u'''# f'c and f'ci feed the lift check's concrete-strength gates directly
|
||||
# (concrete.fc_psi, concrete.fci_psi) -- get one wrong and every handling
|
||||
# stage is checked against the wrong strength, in whichever direction makes
|
||||
# the piece look better than it is.'''
|
||||
|
||||
_MILD_NOTE = u'''# Mild reinforcing grade, where the design summary states one.'''
|
||||
|
||||
_WWF_NOTE = u'''# Welded wire fabric: the mesh designation as printed, how many layers, and
|
||||
# whether it is one layer per face or shared.'''
|
||||
|
||||
_STRAND_GROUPS_NOTE = u'''# One entry per strand group the design summary calls out separately --
|
||||
# shielded or de-tensioned strands get their own line. it_lb (initial
|
||||
# tension) feeds the lift check's prestress force directly
|
||||
# (reinforcing.strand_groups[].it_lb): a wrong qty or it_lb over- or
|
||||
# understates the precompression credited on every handling stage.'''
|
||||
|
||||
_ENVELOPE_NOTE = u'''# The min/max piece size this design is good for, read by pairing each
|
||||
# MAX./MIN. LENGTH/WIDTH label with the dimension text beside it on the
|
||||
# drawing. Checked against the piece actually modelled.'''
|
||||
|
||||
_UNRESOLVED_NOTE = u'''# Every text note on the design summary that no rule above could place,
|
||||
# verbatim, with why. This is not optional and it is not a bug list -- it is
|
||||
# the mechanism that keeps a line of the drawing from being silently dropped
|
||||
# just because nothing recognised it. A rule that does not match returns
|
||||
# nothing on its own; landing here is what makes that visible instead of
|
||||
# quiet. Read every line before trusting this spec, and fold anything real
|
||||
# into the fields above by hand.'''
|
||||
|
||||
_NOTES_NOTE = u'''# Instructions that stay prose rather than being encoded as a field --
|
||||
# stripping/blocking callouts, cut-down rules, anything that is an
|
||||
# instruction rather than a number.'''
|
||||
|
||||
_CONFIDENCE_NOTE = u'''# Per-field confidence, field path -> high | medium | low. Only ever set by
|
||||
# an AI reader (source.extractor = 'dsv4f-vision' now, 'opus' or
|
||||
# 'claude-code' on an older spec) -- and only for a field that same reader
|
||||
# actually just filled, never for one the mechanical reader had already
|
||||
# placed. The mechanical reader makes no judgement calls to rate, so this
|
||||
# stays empty on a 'regex' spec.'''
|
||||
|
||||
|
||||
def _strand_layout_note(layout):
|
||||
diagram = strand_diagram(layout if isinstance(layout, dict) else {})
|
||||
lines = [u'# Where the strands sit, as a matrix: columns_in is the position of',
|
||||
u'# each strand across the piece (cumulative distance from datum, the',
|
||||
u"# same numbers a detailer reads off the dimension chain); each row is",
|
||||
u'# one layer through the thickness, marking which columns it fills. A',
|
||||
u'# correct strand COUNT in the wrong PLACE is still a wrong piece, which',
|
||||
u'# is why this is a grid rather than a total.',
|
||||
u'#',
|
||||
u'# The picture below is drawn from the data beneath it and cannot drift',
|
||||
u'# from it -- if a number here looks wrong, the picture will look wrong',
|
||||
u'# the same way. Hold it next to the drawing.',
|
||||
u'#']
|
||||
for line in diagram:
|
||||
lines.append(u'# ' + line if line else u'#')
|
||||
return u'\n'.join(lines)
|
||||
|
||||
|
||||
_REINFORCING_ORDER = ('mild_grade_ksi', 'wwf', 'strand_groups', 'strand_layout')
|
||||
_REINFORCING_NOTES = {
|
||||
'mild_grade_ksi': _MILD_NOTE,
|
||||
'wwf': _WWF_NOTE,
|
||||
'strand_groups': _STRAND_GROUPS_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def _reinforcing_block(reinforcing):
|
||||
reinforcing = reinforcing or {}
|
||||
lines = [u'reinforcing:']
|
||||
for key in _REINFORCING_ORDER:
|
||||
if key not in reinforcing:
|
||||
continue
|
||||
lines.append(u'')
|
||||
if key == 'strand_layout':
|
||||
lines.append(_strand_layout_note(reinforcing.get('strand_layout')))
|
||||
else:
|
||||
for note_line in _REINFORCING_NOTES[key].split(u'\n'):
|
||||
lines.append(u' ' + note_line)
|
||||
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
||||
for key in reinforcing:
|
||||
if key not in _REINFORCING_ORDER:
|
||||
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
||||
return u'\n'.join(lines)
|
||||
|
||||
|
||||
_SECTION_ORDER = ('schema', 'design_number', 'piece_description', 'source',
|
||||
'concrete', 'reinforcing', 'envelope', 'thickness_in',
|
||||
'notes', 'unresolved', 'confidence')
|
||||
|
||||
_SECTION_NOTES = {
|
||||
'source': _SOURCE_NOTE,
|
||||
'concrete': _CONCRETE_NOTE,
|
||||
'envelope': _ENVELOPE_NOTE,
|
||||
'notes': _NOTES_NOTE,
|
||||
'unresolved': _UNRESOLVED_NOTE,
|
||||
'confidence': _CONFIDENCE_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def spec_text(spec):
|
||||
"""The whole spec, as hand-editable, commented YAML."""
|
||||
spec = spec or {}
|
||||
out = [SPEC_HEADER.format(design=spec.get('design_number') or '?')]
|
||||
for key in _SECTION_ORDER:
|
||||
if key not in spec:
|
||||
continue
|
||||
out.append(u'')
|
||||
if key == 'reinforcing':
|
||||
out.append(_reinforcing_block(spec.get('reinforcing')))
|
||||
continue
|
||||
note = _SECTION_NOTES.get(key)
|
||||
if note:
|
||||
out.append(note)
|
||||
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
||||
for key in spec:
|
||||
if key not in _SECTION_ORDER:
|
||||
out.append(u'')
|
||||
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
||||
return u'\n'.join(out) + u'\n'
|
||||
|
||||
|
||||
def parse_spec_text(text):
|
||||
"""The inverse of spec_text(): hand-edited YAML back to a spec dict."""
|
||||
return yamlkit.parse(text)
|
||||
Reference in New Issue
Block a user