Files
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

533 lines
22 KiB
Python

# -*- 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)