WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user