# -*- coding: utf-8 -*- """Read the strand matrix off the drawing geometry a summary view already scraped. Design Extract's mechanical reader has, until now, always left `reinforcing.strand_layout` blank -- a wrong grid is worse than an honest blank -- because nothing tied the strand count on the schedule to where the strands actually sit on the drawing. This module does that tie, and only that tie: it clusters the strand symbols a summary view carries into COLUMNS (x, across the piece) and ROWS (y, through the thickness), validates each horizontal dimension chain against those columns, validates the union of the vertical chains against those rows, and refuses -- returns `(None, why)` -- the moment any of those identities does not hold. Nothing here guesses; every number in a successful result traces back to a dimension the drafter actually placed. Pure Python, no Revit import anywhere, so it loads on host CPython 3 (see `tests/conftest.py`) exactly as it will inside IronPython 2.7 in Revit -- same rules as `specformat.py` and `designspec.py`: no f-strings, no `pathlib`, no dataclasses, no typing syntax, `'{0}'.format()` only. The shape of the problem, and why a chain does not map 1:1 to a row ---------------------------------------------------------------------- A horizontal dimension chain in one of these drafting views is a PLAN PROJECTION: it locates strands across the piece's width, independent of which depth (row) each of those strands sits at. A slab can easily carry two strands at the same x but different depths, both witnessed by the very same chain. So columns (x) are read straight off the strand SYMBOLS, once, for the whole matrix; a horizontal chain is then checked against that column set rather than against any one row. Depths work the same way in reverse: a vertical chain gives some of the row depths and another vertical chain gives the rest, and it is their UNION that has to match the rows the symbols actually show -- not any single vertical chain on its own. The identities checked, in order: 1. symbol count == qty (`_strand_symbols`) 2. every column (from symbol x) is measured by at least one horizontal chain, and every chain that measures a given column agrees on its label (`_qualify_chain`) 3. the UNION of every vertical chain's interior points equals the row set the symbols show, in both count and spacing (`_vertical_union`) plus the housekeeping identity that the strands actually marked True still sum to the symbol count and to `qty`. """ import re # --------------------------------------------------------------- tolerances # # All come straight out of the spec this module was written against: a # strand column or row-depth position is trusted to the nearest 1/8", a # printed label is preferred over the drawn geometry once they part by more # than 1/16", and a row / label band gets 0.5" / 0.2 ft of slack for real # dimension-line and detail-component jitter -- never for reading a # genuinely different position as the same one. _TOL_IN = 1.0 / 8.0 # identity / merge tolerance, inches _TOL_PRINTED_IN = 1.0 / 16.0 # printed vs. drawn disagreement _ROW_TOL_FT = 0.5 / 12.0 # symbol-y clustering into rows _COL_TOL_FT = 0.5 / 12.0 # symbol-x clustering into columns _BAND_TOL_FT = 0.2 # dimension label-y banding # ------------------------------------------------------------- text parsing _NUM_RE = re.compile(r'^\d+(\.\d+)?$') def _frac(token): """'1/4' -> 0.25, or None if it is not a plain integer fraction.""" if '/' not in token: return None num, _sep, den = token.partition('/') if not _NUM_RE.match(num) or not _NUM_RE.match(den): return None d = float(den) if d == 0: return None return float(num) / d def parse_inches(text): """Printed dimension text -> inches, or None if it cannot be read. Handles the shapes this drawing set actually uses: '2"', '6"', '1\\' - 0"', '1\\'-6"', '9 1/4"', '2\\' - 0 1/2"'. Anything else -- a blocking-diagram tag like '.104L', an empty override string, plain prose -- is refused rather than guessed at: it returns None. """ if text is None: return None s = text.strip() if not s: return None feet = 0.0 if "'" in s: feet_text, _sep, rest = s.partition("'") feet_text = feet_text.strip() if not _NUM_RE.match(feet_text): return None feet = float(feet_text) rest = rest.strip() if rest.startswith('-'): rest = rest[1:].strip() else: rest = s if not rest: inches = 0.0 else: if not rest.endswith('"'): return None rest = rest[:-1].strip() if not rest: inches = 0.0 else: tokens = rest.split() if len(tokens) == 1: token = tokens[0] if '/' in token: frac_val = _frac(token) if frac_val is None: return None inches = frac_val else: if not _NUM_RE.match(token): return None inches = float(token) elif len(tokens) == 2: whole, frac = tokens if not _NUM_RE.match(whole): return None frac_val = _frac(frac) if frac_val is None: return None inches = float(whole) + frac_val else: return None return round(feet * 12.0 + inches, 4) def _fmt_in(value): """4.0 -> '4', 9.25 -> '9.25' -- the same trailing-zero trim as specformat.""" text = '{0:.4f}'.format(value) text = text.rstrip('0').rstrip('.') return text if text else '0' # ------------------------------------------------------------------ cluster def cluster(values, tol): """Chain-group a list of numbers: consecutive sorted values within `tol` of each other land in the same group. Returns a list of groups, each a list of INDICES into `values` (not the values themselves, so a caller can carry along whatever the number was attached to), sorted by group ascending. This is deliberately a chaining tolerance (a-b<=tol<=b-c chains a and c together even if a-c>tol), which is what "cluster symbol x/y into columns/rows" and "band dimensions by label y" all actually mean here -- there is no fixed grid to snap to, only nearby real values. """ order = sorted(range(len(values)), key=lambda i: values[i]) groups = [] current = [] prev = None for i in order: v = values[i] if current and prev is not None and (v - prev) > tol: groups.append(current) current = [] current.append(i) prev = v if current: groups.append(current) groups.sort(key=lambda g: sum(values[i] for i in g) / float(len(g))) return groups # ------------------------------------------------------------- chain banding def band_chains(dimensions): """Group a view's dimensions into horizontal chain candidates. A candidate is a dimension with `axis == 'horizontal'` (a real multi-segment chain -- each of its segments contributes) or one with no 'segments' at all (a single dimension, read as a one-segment chain of itself -- this is how a lone '2\\'-0"' dimension standing in for a run of identical bays ends up spliced into the chain it belongs to). Candidates land in the same chain when their dimension's own label y is within `_BAND_TOL_FT` of each other (transitively); within a chain every segment, however many dimension elements contributed it, is sorted by its OWN x ascending -- that is the strand-to-strand order on the drawing, regardless of which element drew which piece of it. """ candidates = [] for d in dimensions or []: if not isinstance(d, dict): continue label_y = d.get('y') segs = d.get('segments') if segs: if d.get('axis') != 'horizontal': continue # A multi-segment dimension carries NO top-level origin on a # drafting view (`Dimension.Origin` throws there, the scrape # records only each segment's own label point -- measured live # 2026-09-04 on three real summaries), so each segment bands by # its OWN label y. A top-level y, when present, is only a # fallback for a segment that lost its point. for s in segs: if not isinstance(s, dict) or 'x' not in s: continue seg_y = s.get('y', label_y) if seg_y is None: continue candidates.append({'value': s.get('value'), 'value_ft': s.get('value_ft'), 'x': s['x'], 'label_y': seg_y}) else: if 'x' not in d or label_y is None: continue candidates.append({'value': d.get('value'), 'value_ft': d.get('value_ft'), 'x': d['x'], 'label_y': label_y}) if not candidates: return [] ys = [c['label_y'] for c in candidates] groups = cluster(ys, _BAND_TOL_FT) chains = [] for g in groups: segs = [candidates[i] for i in g] segs.sort(key=lambda s: s['x']) mean_y = sum(s['label_y'] for s in segs) / float(len(segs)) chains.append({'y': mean_y, 'segments': segs}) return chains # --------------------------------------------------------- printed vs drawn def _resolve_length_in(seg, disagreements): """One segment's length in inches: printed text wins over drawn geometry. Returns None only when NEITHER the printed text nor the drawn value_ft can be read -- callers treat that as "this chain cannot be used", not as zero. """ printed = parse_inches(seg.get('value')) drawn_ft = seg.get('value_ft') drawn_in = drawn_ft * 12.0 if drawn_ft is not None else None if printed is None: if drawn_in is None: return None disagreements.append( 'printed text "{0}" is unreadable -- used the drawn {1}"'.format( seg.get('value'), _fmt_in(drawn_in))) return round(drawn_in, 4) if drawn_in is not None and abs(printed - drawn_in) > _TOL_PRINTED_IN: disagreements.append( 'printed {0} disagrees with drawn {1}" (used printed)'.format( seg.get('value'), _fmt_in(drawn_in))) return round(printed, 4) def _resolve_chain_lengths(chain, disagreements): lengths = [] for s in chain['segments']: li = _resolve_length_in(s, disagreements) if li is None: return None lengths.append(li) return lengths # ------------------------------------------------------------ symbol rows def _strand_symbols(view, symbol_kind): syms = view.get('symbols') or [] return [s for s in syms if isinstance(s, dict) and s.get('kind') == symbol_kind] def _cluster_rows(symbols): ys = [s['y'] for s in symbols] groups = cluster(ys, _ROW_TOL_FT) rows = [] for g in groups: members = sorted((symbols[i] for i in g), key=lambda s: s['x']) mean_y = sum(s['y'] for s in members) / float(len(members)) rows.append({'y': mean_y, 'symbols': members}) return rows # ---------------------------------------------------------------- columns def _build_columns(rows): """Union of every row's own symbol x, clustered into columns. Returns (columns_ft, row_to_cols): columns_ft is the column list in VIEW FEET, sorted ascending (so columns_ft[0] is the piece's leftmost strand); row_to_cols[i] is, for row i, the column index of each of its own symbols, in the same x-ascending order `_cluster_rows` already put them in. """ flat = [] owner = [] for ri, row in enumerate(rows): for s in row['symbols']: flat.append(s['x']) owner.append(ri) groups = cluster(flat, _COL_TOL_FT) columns_ft = [] index_of = {} for gi, g in enumerate(groups): vals = [flat[i] for i in g] columns_ft.append(sum(vals) / float(len(vals))) for i in g: index_of[i] = gi row_to_cols = [[] for _ in rows] for i in range(len(flat)): row_to_cols[owner[i]].append(index_of[i]) return columns_ft, row_to_cols # ------------------------------------------------------- chain qualification def _chain_shape_positions(lengths, shape): """Cumulative sums for one shape: 'closed' drops the trailing last-strand-to-far-edge segment, 'open' keeps every segment because the chain never draws that trailing segment at all.""" subset = lengths[:-1] if shape == 'closed' else lengths[:] positions = [] running = 0.0 for li in subset: running += li positions.append(round(running, 4)) return positions def _rel_positions_if_all_match(positions, col_rel_in): """positions, re-based on their own first entry, IF every one of those relative values lands on a real column -- else None.""" if not positions: return None origin = positions[0] rel = [round(p - origin, 4) for p in positions] for r in rel: if not any(abs(r - cr) <= _TOL_IN for cr in col_rel_in): return None return rel def _qualify_chain(chain, col_rel_in, width_in): """Does this chain's strand pattern -- either shape -- match the real columns? Positions are compared RELATIVE TO THE CHAIN'S OWN FIRST STRAND, which is also how `col_rel_in` is expressed (relative to the piece's leftmost column) -- every horizontal strand-position dimension in this domain starts from the same piece edge, so a chain whose relative pattern matches is, by construction, anchored at that same edge ("contains the leftmost column"): its own first position is relative-zero, which is exactly where the piece's leftmost column sits too. A chain whose positions do not all land on real columns -- a mild-bar chain, a blocking diagram -- matches neither shape and is dropped, silently and harmlessly. The 'closed' shape (chain spans edge to edge) is preferred whenever it validates AND -- when width_in is known -- its own total agrees with it; a chain whose closed reading matches the columns but NOT the known width is not silently re-read as 'open' EVEN IF an open reading would also validate the columns, because a chain that geometrically reaches the far edge but disagrees with the stated width is a contradiction worth surfacing, not a coincidence worth hiding. Only when NEITHER a width-consistent closed reading NOR an open reading validates against the columns does the chain drop out silently as a non-strand chain. Returns (match_or_None, disagreements, hard_refusal_reason_or_None). `hard_refusal_reason` is set only for that contradiction -- the chain unambiguously closes to the far edge, its positions are all real columns, but its total does not match a KNOWN width_in and no 'open' reading rescues it. """ disagreements = [] lengths = _resolve_chain_lengths(chain, disagreements) if lengths is None or len(lengths) < 2: return None, [], None closed_positions = _chain_shape_positions(lengths, 'closed') closed_rel = _rel_positions_if_all_match(closed_positions, col_rel_in) closed_total = round(sum(lengths), 4) closed_width_ok = width_in is None or abs(closed_total - width_in) <= _TOL_IN if closed_rel is not None and closed_width_ok: return ({'chain': chain, 'shape': 'closed', 'positions': closed_positions, 'rel': closed_rel, 'total_in': closed_total}, disagreements, None) open_positions = _chain_shape_positions(lengths, 'open') open_rel = _rel_positions_if_all_match(open_positions, col_rel_in) if open_rel is not None: return ({'chain': chain, 'shape': 'open', 'positions': open_positions, 'rel': open_rel, 'total_in': closed_total}, disagreements, None) if closed_rel is not None and not closed_width_ok: return None, [], ( 'a chain closes to the far edge (positions {0} in) totalling ' '{1}" but width_ft says {2}" -- refusing rather than guessing ' 'which is right'.format(closed_positions, closed_total, width_in)) return None, [], None def _nearest_column(rel_value, col_rel_in): best_j, best_d = None, None for j, cr in enumerate(col_rel_in): d = abs(cr - rel_value) if d <= _TOL_IN and (best_d is None or d < best_d): best_j, best_d = j, d return best_j # --------------------------------------------------------- vertical depths def _vertical_candidates(dimensions, thickness_in): out = [] for d in dimensions or []: if not isinstance(d, dict) or d.get('axis') != 'vertical': continue segs = d.get('segments') or [] if len(segs) < 2: continue vals_ft = [s.get('value_ft') for s in segs] if any(v is None for v in vals_ft): continue total_in = round(sum(vals_ft) * 12.0, 4) if thickness_in is not None and abs(total_in - thickness_in) > _TOL_IN: continue ordered = sorted(segs, key=lambda s: s.get('y', 0.0)) out.append({'segments': ordered}) return out def _vertical_union(rows, dimensions, thickness_in): """Row depths from the UNION of every qualifying vertical chain's interior points, or (None, None, why). A single vertical chain need not (and in a multi-row slab usually will not) carry every row's depth on its own -- '2"|8"' gives the 2" row and '6"|2"|2"' gives the 6" and 8" rows, and only their union is the whole row set. Each candidate is already restricted to chains whose OWN total equals thickness_in when that is known. """ n_rows = len(rows) row_ys = [r['y'] for r in rows] row_gaps_in = [round((row_ys[i + 1] - row_ys[i]) * 12.0, 4) for i in range(n_rows - 1)] candidates = _vertical_candidates(dimensions, thickness_in) all_points = [] disagreements = [] chain_texts = [] for cand in candidates: scratch = [] lengths = _resolve_chain_lengths(cand, scratch) if lengths is None: continue interior = [] running = 0.0 for li in lengths[:-1]: running += li interior.append(round(running, 4)) all_points.extend(interior) disagreements.extend(scratch) chain_texts.append([s.get('value') or '?' for s in cand['segments']]) if not all_points: return None, None, None, ( '{0} symbol row(s) but the vertical chains give 0 depths -- ' 'refusing rather than guessing the heights'.format(n_rows)) groups = cluster(all_points, _TOL_IN) union = [round(sum(all_points[i] for i in g) / float(len(g)), 4) for g in groups] if len(union) != n_rows: return None, None, None, ( '{0} symbol row(s) but the vertical chains give {1} depth(s) ' '({2} in) -- refusing rather than guessing the heights'.format( n_rows, len(union), union)) for i in range(n_rows - 1): if abs((union[i + 1] - union[i]) - row_gaps_in[i]) > _TOL_IN: return None, None, None, ( 'the vertical chains give depths {0} in but the symbol rows ' 'are spaced {1} in apart -- refusing rather than guessing ' 'the heights'.format(union, row_gaps_in)) return union, disagreements, chain_texts, None # ---------------------------------------------------------------- read_layout def read_layout(view, qty, thickness_in=None, width_ft=None, symbol_kind='FilledRegion'): """Read the strand matrix from one summary view's evidence, or refuse. Returns (layout, why): layout is `{'columns_in': [...], 'rows': [{'from_bottom_in':, 'strands': [bool,...]}], 'total_in': n, 'note': text}` on success and `why` is None; on refusal layout is None and `why` is one sentence naming which identity failed and the numbers behind it. Never both None, never both set. See the module docstring for the identities this checks and why a dimension chain is validated against the whole COLUMN set (read off the strand symbols) rather than against any one row. """ if not qty: return None, 'no strand quantity given (qty is {0}) -- nothing to check the drawing against'.format(qty) if 'symbols' not in view: return None, 'evidence carries no symbol positions -- re-run Design Extract' strand_symbols = _strand_symbols(view, symbol_kind) if len(strand_symbols) != qty: return None, ( 'found {0} {1} symbol(s) in the view but strand_groups qty is ' '{2} -- refusing rather than guessing which is right'.format( len(strand_symbols), symbol_kind, qty)) rows = _cluster_rows(strand_symbols) columns_ft, row_to_cols = _build_columns(rows) n_columns = len(columns_ft) col_rel_in = [round((c - columns_ft[0]) * 12.0, 4) for c in columns_ft] dimensions = view.get('dimensions') or [] chains = [c for c in band_chains(dimensions) if len(c['segments']) >= 2] width_in = round(width_ft * 12.0, 4) if width_ft is not None else None disagreements = [] qualifying = [] for chain in chains: match, chain_disagreements, hard_refusal = _qualify_chain( chain, col_rel_in, width_in) if hard_refusal is not None: return None, hard_refusal if match is not None: qualifying.append(match) disagreements.extend(chain_disagreements) if not qualifying: return None, ( 'no horizontal dimension chain matches the {0} strand column(s) ' 'found in the symbols -- refusing rather than guessing the ' 'spacing'.format(n_columns)) col_labels = [[] for _ in range(n_columns)] for q in qualifying: for k, pos in enumerate(q['positions']): j = _nearest_column(q['rel'][k], col_rel_in) if j is not None: col_labels[j].append(pos) for j in range(n_columns): if not col_labels[j]: return None, ( 'no dimension chain measures the strand column {0} in from ' 'the leftmost strand (view x={1:.4f} ft) -- refusing rather ' 'than guessing its position'.format(col_rel_in[j], columns_ft[j])) first = col_labels[j][0] for other in col_labels[j][1:]: if abs(other - first) > _TOL_IN: return None, ( 'two dimension chains disagree on the strand column {0} ' 'in from the leftmost strand: {1}" vs {2}" -- refusing ' 'rather than guessing which is right'.format( col_rel_in[j], first, other)) columns_in = [round(sum(v) / float(len(v)), 4) for v in col_labels] closed_totals = [q['total_in'] for q in qualifying if q['shape'] == 'closed'] if width_in is not None: # every closed chain was already checked against width_in inside # _qualify_chain (a disagreement there is a hard refusal, not a # silent drop), so a closed match here is width-consistent already. total_in = width_in else: if not closed_totals: return None, ( 'no chain closes to the far edge and no width_ft was given ' '-- total_in could not be judged') total_in = closed_totals[0] for t in closed_totals[1:]: if abs(t - total_in) > _TOL_IN: return None, ( 'closing chains disagree on the total width: {0}" vs ' '{1}" -- refusing rather than guessing which is ' 'right'.format(total_in, t)) depths_in, vertical_disagreements, vertical_texts, why = _vertical_union( rows, dimensions, thickness_in) if depths_in is None: return None, why disagreements.extend(vertical_disagreements) out_rows = [] total_marked = 0 for ri, row in enumerate(rows): marks = [False] * n_columns for ci in row_to_cols[ri]: marks[ci] = True total_marked += sum(1 for v in marks if v) out_rows.append({'from_bottom_in': depths_in[ri], 'strands': marks}) if total_marked != qty or total_marked != len(strand_symbols): return None, ( 'internal check failed: {0} strand(s) marked but qty is {1} and ' '{2} symbols were found -- refusing'.format( total_marked, qty, len(strand_symbols))) header = '{0} strand symbols in {1} row{2}'.format( len(strand_symbols), len(rows), '' if len(rows) == 1 else 's') row_descs = [] for row in out_rows: label = _fmt_in(row['from_bottom_in']) + '"' at = [_fmt_in(columns_in[ci]) + '"' for ci, is_true in enumerate(row['strands']) if is_true] row_descs.append('row {0}: strands at {1}'.format(label, ', '.join(at))) chain_descs = ['"' + ', '.join(s.get('value') or '?' for s in q['chain']['segments']) + '"' for q in qualifying] vert_descs = ['"' + ', '.join(texts) + '"' for texts in (vertical_texts or [])] note = ('read from the drawing geometry: {0}; {1}; horizontal chains: ' '{2}; vertical chains: {3}'.format( header, '; '.join(row_descs), '; '.join(chain_descs), '; '.join(vert_descs))) if disagreements: note += '; ' + '; '.join(disagreements) layout = { 'columns_in': columns_in, 'rows': out_rows, 'total_in': total_in, 'note': note, } return layout, None