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

656 lines
26 KiB
Python

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