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