25592fa6c2
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.
656 lines
26 KiB
Python
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
|