Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

819 lines
32 KiB
Python

# -*- coding: utf-8 -*-
"""Design summaries as reviewable data.
A design summary is a drafting view full of free text that a human wrote. The
checking scripts want numbers. Turning one into the other is the only part of
the job that genuinely needs judgement, so it happens once, up front, and the
result is written to disk where you can read and correct it:
<model folder>/_ticketqc/
evidence/<DESIGN>.json everything we could scrape off the summary
specs/<DESIGN>.json the structured spec <-- the thing to review
knowledge.yaml hand-written rules that outlive one project
log/<DESIGN>.txt what the extractor printed
Evidence is gathered deterministically here. So is the spec now: the shape
of a spec, and how it reads and writes as text, live in `specformat.py` --
which touches no Revit API at all, on purpose, so that shape can be unit
tested without a licence or a model (see its docstring). This module is the
Revit side of that boundary: it reads the model, and it reads and writes
specformat's shapes to disk beside it. Everything downstream reads only the
spec, so a checking run is pure, repeatable arithmetic.
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
whole point of this file is that an engineer reads it and corrects it by hand.
An older spec written as plain JSON is still read -- see load_spec.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
import re
import json
import datetime
from Autodesk.Revit.DB import (
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
BrowserOrganization, Element, StorageType,
)
from vibe import specformat
from vibe import specsave
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
normalize_spec, new_spec, strand_positions, strand_row_marks,
strand_diagram, spec_text, parse_spec_text, design_key,
)
from vibe.compat import as_text
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
# path arithmetic and lives in a module with no Revit import, so it can be
# tested on host CPython -- but every caller has always asked designspec for
# it, and a second spelling of the same question is how two answers start.
from vibe.plantpath import ( # noqa: F401 -- re-exported
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
)
# Control characters and format characters that ride along in Revit text and
# crash downstream printers / parsers. Measured: a model parameter whose
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
# way into evidence/WP1.json and broke a tool that printed it. Everything
# that scrapes model text goes through clean_text at the boundary; the
# function itself lives in ticketpage (the Revit-free module, unit-tested).
from vibe.ticketpage import clean_text
# The strand-matrix geometry reader. A sibling module, developed in parallel
# -- guarded, and PERMANENTLY guarded even once it always ships, because a
# machine missing the file must get an honest refusal from build_spec rather
# than an ImportError that takes the whole extraction down with it.
try:
from vibe import strandmatrix
except ImportError:
strandmatrix = None
FOLDER = '_ticketqc'
JOBS_ENV = 'WILLTOOLS_JOBS'
# ------------------------------------------------------------------- layout
def root_dir(doc):
"""Where this project's QC data lives.
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
underneath it. Everything a project knows then sits in one place the
engineer chose, rather than in two that the tool chose for him.
Without it, beside the model, falling back to APPDATA when the model has
no path yet or its folder cannot be written. That fallback is the reason
the override exists. Saving a detached copy gives it a path, so the
lookup silently moves from APPDATA to the model folder, and a job
knowledge file written before the save stops being read -- measured
2026-08-30, and from the outside it looks exactly like edits that did not
take.
"""
override = os.environ.get(JOBS_ENV)
if override:
return _ensure(os.path.join(override, doc.Title or 'untitled'))
model = doc.PathName
if model:
base = os.path.join(os.path.dirname(model), FOLDER)
try:
_ensure(base)
return base
except Exception:
pass
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
'pyrevit-ticketqc', doc.Title or 'untitled')
_ensure(fallback)
return fallback
def _ensure(path):
if not os.path.isdir(path):
os.makedirs(path)
return path
def sub_dir(doc, name):
return _ensure(os.path.join(root_dir(doc), name))
def evidence_path(doc, design):
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
def spec_path(doc, design, ext='yaml'):
"""Where one design's spec lives. New specs are written as '.yaml'.
A spec written before this file carried comments is still on disk as
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
whichever one is actually there.
"""
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
def find_spec_path(doc, design):
"""(path, ext) of the spec actually on disk for this design.
'.yaml' wins when both exist -- it is the one a human could have
corrected, and load_spec must not silently prefer the one they didn't
touch. Neither existing, this is where a new spec would be written.
"""
yaml_path = spec_path(doc, design, 'yaml')
if os.path.isfile(yaml_path):
return yaml_path, 'yaml'
json_path = spec_path(doc, design, 'json')
if os.path.isfile(json_path):
return json_path, 'json'
return yaml_path, 'yaml'
def log_path(doc, design):
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
def knowledge_path(doc):
return os.path.join(root_dir(doc), 'knowledge.yaml')
# ------------------------------------------------------- the project config
#
# A project's configuration file is written wherever the engineer wants it --
# the job folder on a share, usually, where the rest of the job's paperwork
# lives and where a reviewer will look for it. That is the whole point of
# letting them choose, and it means the tool cannot go and find it by
# convention afterwards.
#
# So a one-line pointer stays behind, beside the model with the rest of the QC
# data. It holds the path and nothing else. Losing it costs one button press;
# putting the config itself here would defeat the choice.
CONFIG_POINTER = 'active-config.txt'
def config_pointer_path(doc):
return os.path.join(root_dir(doc), CONFIG_POINTER)
def active_config_path(doc):
"""The project config file in force for this model, or None.
Returns the path even when the file has gone missing. A pointer to
nothing is a fact the caller has to be told about -- resolving it to None
would silently fall back to the layered files, and the run would use a
different configuration than the one the engineer set, without saying so.
"""
pointer = config_pointer_path(doc)
if not os.path.isfile(pointer):
return None
try:
with io.open(pointer, 'r', encoding='utf-8') as fh:
path = fh.read().strip()
except Exception:
return None
return path or None
def set_active_config(doc, path):
"""Make this file the configuration this model runs on."""
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
fh.write(as_text(path))
return path
def clear_active_config(doc):
"""Go back to the layered files. Returns what was in force, or None."""
pointer = config_pointer_path(doc)
previous = active_config_path(doc)
if os.path.isfile(pointer):
os.remove(pointer)
return previous
def _safe(name):
"""A design number as a filename.
One rule, and it lives in `specsave` -- the Extract Design button names
the COPY it saves elsewhere with the same function, so a copy can never
end up called something the canonical file is not.
"""
return specsave.safe_name(name)
# ---------------------------------------------------------- design numbers
#
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
# file, alongside the rest of the spec shape. It is the same function under
# the same name; nothing that calls `designspec.design_key` has to change.
#
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
# asking "are these two design numbers the same design?" needed a licence.
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
# out of a suggestion -- and it must answer both inside Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
# the reduction there would have been a second spelling of one question, which
# is how two answers start; so the function went to the Revit-free module and
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
# `specformat.FALLBACK_DESIGN_RE`.
def _nomen(know, key, default):
"""One nomenclature setting, tolerating a knowledge dict that is not there.
Imported late: knowledge imports this module, so a module-level import
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
and resolves `nomenclature.<key>` at each call site, so do not rename it
without teaching the scanner.
"""
if know is None:
return default
from vibe import knowledge as K
return K.get(know, 'nomenclature.' + key, default)
def list_specs(doc):
"""Design numbers that already have a spec on disk.
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
wins, same rule as find_spec_path, so a spec a human has never touched
(still '.json') does not shadow one they have.
"""
folder = sub_dir(doc, 'specs')
by_design = {}
for fn in sorted(os.listdir(folder)):
low = fn.lower()
if low.endswith('.yaml'):
rank = 0
elif low.endswith('.json'):
rank = 1
else:
continue
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
if not spec or not spec.get('design_number'):
continue
design = spec['design_number']
prior = by_design.get(design)
if prior is None or rank < prior[0]:
by_design[design] = (rank, spec)
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
# --------------------------------------------------------------------- io
def read_json(path):
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
except Exception:
return None
def write_json(path, data):
_ensure(os.path.dirname(path))
text = as_text(json.dumps(data, indent=2, sort_keys=True,
ensure_ascii=False))
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(text)
return path
def read_yaml(path):
"""A spec file written by save_spec, or None. Comments are just dropped."""
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
text = fh.read()
except Exception:
return None
try:
return specformat.parse_spec_text(text)
except Exception:
return None
def read_spec_file(path):
"""A spec, whichever of the two formats it was written in."""
if path.lower().endswith('.json'):
return read_json(path)
return read_yaml(path)
def load_spec(doc, design):
path, _ext = find_spec_path(doc, design)
return normalize_spec(read_spec_file(path))
def save_spec(doc, design, spec):
"""Write a spec as hand-editable YAML.
Always '.yaml', regardless of what was there before -- a spec that gets
re-extracted goes back to being machine-written, and a stale '.json'
left beside it would otherwise look like an untouched original. Nothing
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
here on, same as list_specs.
"""
path = spec_path(doc, design, 'yaml')
_ensure(os.path.dirname(path))
text = specformat.spec_text(spec)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(as_text(text))
return path
# ------------------------------------------------------------ element utils
def _name(el):
try:
return Element.Name.GetValue(el)
except Exception:
return ''
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _params(el, wanted=None):
out = {}
for p in el.Parameters:
pname = p.Definition.Name
if wanted is not None and pname not in wanted:
continue
try:
if p.StorageType == StorageType.String:
val = p.AsString()
elif p.StorageType == StorageType.Integer:
val = p.AsInteger()
elif p.StorageType == StorageType.Double:
val = p.AsValueString()
else:
val = p.AsValueString()
except Exception:
val = None
if val not in (None, ''):
out[pname] = val
return out
def _dimension(d):
"""One dimension, with enough geometry to say what it measures.
A bare string is not enough for a strand chain. Where the strands sit is
dimensioned as a multi-segment chain -- rail to first strand, then strand
to strand, then last strand to the far edge -- so the segments in order,
their exact lengths, and which way the chain runs are all part of the
reading. N segments locate N-1 strands and the total is the piece width,
which is what makes the reading checkable.
Two traps, both met on real summaries:
* Dimension.Curve throws on these drafting views, so the direction is
inferred from how the segment origins are spread instead.
* segment.Value is the drawn geometry and segment.ValueString is what is
printed. On an overridden segment they disagree, and only the printed
text is the instruction -- so both are recorded and neither is
silently preferred.
"""
entry = {}
try:
entry['value'] = clean_text(d.ValueString)
except Exception:
entry['value'] = None
for key, attr in (('above', 'Above'), ('below', 'Below'),
('prefix', 'Prefix'), ('suffix', 'Suffix')):
try:
val = getattr(d, attr, None)
except Exception:
val = None
if val:
entry[key] = val
try:
raw = d.Value
if raw is not None:
entry['value_ft'] = round(float(raw), 6)
except Exception:
pass
try:
entry['references'] = d.References.Size
except Exception:
pass
try:
origin = d.Origin
entry['x'] = round(origin.X, 4)
entry['y'] = round(origin.Y, 4)
except Exception:
pass
segments = []
try:
if d.NumberOfSegments > 1:
for seg in d.Segments:
item = {}
try:
item['value'] = clean_text(seg.ValueString)
except Exception:
item['value'] = None
try:
if seg.Value is not None:
item['value_ft'] = round(float(seg.Value), 6)
except Exception:
pass
try:
point = seg.Origin
item['x'] = round(point.X, 4)
item['y'] = round(point.Y, 4)
except Exception:
pass
segments.append(item)
except Exception:
pass
if segments:
entry['segments'] = segments
entry['segment_count'] = len(segments)
xs = [s['x'] for s in segments if 'x' in s]
ys = [s['y'] for s in segments if 'y' in s]
if xs and ys:
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
else 'vertical')
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
if len(lengths) == len(segments):
entry['total_ft'] = round(sum(lengths), 6)
if entry.get('value') or segments:
return entry
return None
def _view_content(doc, view):
"""Text, dimensions and drawn components of one view, positions included.
Positions matter: the min/max table in a summary is a label note and a
value note sitting side by side, and only the geometry links them.
"""
notes = []
for t in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
try:
c = t.Coord
notes.append({'text': t.Text.replace('\r', '').strip(),
'x': round(c.X, 4), 'y': round(c.Y, 4)})
except Exception:
notes.append({'text': t.Text.replace('\r', '').strip()})
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_Dimensions)
.WhereElementIsNotElementType().ToElements())]
dims = [d for d in dims if d]
components = {}
symbols = []
symbols_skipped = 0
for e in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
try:
key = e.Symbol.Family.Name
except Exception:
key = type(e).__name__
components[key] = components.get(key, 0) + 1
# Positions, for the strand-matrix reader: which symbol is drawn
# where. A FilledRegion (the usual strand marker -- see
# knowledge.DEFAULTS['design_summary']) has no .Symbol, so `key`
# above is already its class name; the same key is reused here so
# the two counts describe the same population.
try:
bbox = e.get_BoundingBox(view)
if bbox is None:
symbols_skipped += 1
continue
cx = (bbox.Min.X + bbox.Max.X) / 2.0
cy = (bbox.Min.Y + bbox.Max.Y) / 2.0
w = bbox.Max.X - bbox.Min.X
h = bbox.Max.Y - bbox.Min.Y
symbols.append({'kind': key, 'x': round(cx, 4), 'y': round(cy, 4),
'w': round(w, 4), 'h': round(h, 4)})
except Exception:
symbols_skipped += 1
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
'text_notes': notes, 'dimensions': dims, 'components': components,
'symbols': symbols, 'symbols_skipped': symbols_skipped}
# ------------------------------------------------------------ the gathering
def _sheet_folder(org, sheet):
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
if org is None:
return ''
try:
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
return ''
def find_summary_views(doc, design, know=None):
"""The views and sheets that make up one design's packet.
Driven by SHEETS, not by view names. A view belongs to the design of the
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
would file it under the wrong design without anything looking wrong.
Sheets are reduced through design_key, so the whole packet -- cover sheet
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
order, which puts the cover sheet (the design summary itself) first.
"""
key = design_key(design, know)
if not key:
return [], []
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
ignore = [str(p).upper()
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
stale = _nomen(know, 'stale_view_re', None)
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
sheets = []
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if s.IsTemplate:
continue
number = (s.SheetNumber or '').strip()
if any(number.upper().startswith(p) for p in ignore):
continue
if design_key(number, know) != key:
continue
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
continue
sheets.append(s)
sheets.sort(key=lambda s: (s.SheetNumber or ''))
views, seen = [], set()
for sheet in sheets:
for vid in sheet.GetAllPlacedViews():
if vid in seen:
continue
seen.add(vid)
view = doc.GetElement(vid)
if view is None:
continue
if stale:
try:
if re.search(stale, view.Name):
continue
except Exception:
pass
views.append(view)
return views, sheets
def gather_evidence(doc, design, know=None):
"""Scrape everything the extractor could possibly need. No interpretation."""
views, sheets = find_summary_views(doc, design, know)
evidence = {
'schema': SCHEMA_VERSION,
'design_number': design,
'model': doc.Title,
'model_path': doc.PathName,
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'summary_views': [_view_content(doc, v) for v in views],
'sheets': [],
}
for sheet in sheets:
entry = {
'number': sheet.SheetNumber,
'name': sheet.Name,
'id': _eid(sheet),
'parameters': _params(sheet),
'views': [],
}
for vid in sheet.GetAllPlacedViews():
view = doc.GetElement(vid)
if any(v.Id == view.Id for v in views):
continue
entry['views'].append(_view_content(doc, view))
evidence['sheets'].append(entry)
# A worked example of a real piece helps the extractor ground its units.
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
return evidence
def _sample_piece(doc, design, know=None):
"""One assembly on this design, so the extractor can see real magnitudes."""
from Autodesk.Revit.DB import AssemblyInstance
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
from vibe.ticketqc import rstr
# Compared as reduced keys: the piece may be stamped PNL1 while
# the summary sheet is PNL1.0, and they are the same design.
if design_key(rstr(member, know, 'panel.design_number'),
know) != design_key(design, know):
break
bom = {}
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
try:
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
except Exception:
pass
return {
'mark': asm.AssemblyTypeName,
'family': member.Symbol.Family.Name,
'panel_parameters': _params(member),
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
'bill_of_material': bom,
}
else:
continue
return None
# ------------------------------------------------------------- spec shape
#
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
# in specformat.py now -- see the imports at the top of this file -- because
# none of them touch Revit and every one of them is worth testing without it.
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
# code written against this module.
def build_spec(doc, design, know=None, evidence=None):
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
Everything below either matched one of ticketqc's rules or it did not --
there is no third option, and nothing here guesses. What matched is
filled in; every text note on the design summary that nothing matched is
recorded in 'unresolved' rather than silently dropped. The strand matrix
is read from GEOMETRY, not guessed at from text -- the drawn strand
symbols and the dimension chain that locates them -- and only when every
identity check on that reading passes: the symbol count equals the
stated strand quantity, each row's spacing matches a dimension chain, and
row depths match a vertical chain. Any one of those failing leaves
columns_in and rows empty with the reason on 'unresolved' rather than a
grid nobody can trust; a wrong grid is worse than an honest blank one,
and the AI pass and a human both fill what stays blank. datum comes from
knowledge (rails.strand_datum) because that much genuinely is known
already, independent of whether the geometry read succeeds.
Returns None when this design has no summary view to read.
"""
from vibe import knowledge as K
from vibe import ticketqc
if know is None:
know = K.load(doc)
views, sheets = find_summary_views(doc, design, know)
if not views:
return None
if evidence is None:
evidence = gather_evidence(doc, design, know=know)
view = views[0] # the design's own summary view --
# the rest of the packet's views
# are sub-details, not this design
reading = ticketqc.parse_design_summary(doc, view)
spec = new_spec(design, evidence)
spec['source']['extractor'] = 'regex'
spec['piece_description'] = sheets[0].Name if sheets else None
spec['concrete']['fc_psi'] = reading.get('fc')
spec['concrete']['fci_psi'] = reading.get('fci')
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
spec['reinforcing']['strand_groups'] = [
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
'it_lb': g.get('it'), 'role': None, 'note': None}
for g in (reading.get('strands') or [])
]
spec['reinforcing']['strand_layout']['datum'] = K.get(
know, 'rails.strand_datum', 'left_rail')
envelope = reading.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
if entry:
val, raw = entry
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
# which does not land on a clean decimal even when the
# drawing dimension is exact -- 34'-11 1/2" is really
# 34.958333333333336 in binary float. Round to the nearest
# 1/16" (6 decimal places comfortably clears that) so the
# spec shows the number a person would actually write, not
# float noise nobody typed.
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
spec['envelope'][dim][bound + '_text'] = raw
# ---- the strand matrix, read from geometry -------------------------
#
# `strandmatrix.read_layout` is the identity-checked reader: it counts
# the drawn strand symbols against the stated quantity, matches each
# row's spacing to a dimension chain and each row's depth to a vertical
# chain, and refuses -- one sentence saying which identity failed --
# rather than fill in a grid that only half matches. `symbol_kind` is
# plant knowledge (design_summary.strand_symbol) because the drafting
# standard's choice of marker is not universal: a filled region on this
# default, a detail-component family name on another plant. Computed
# here, applied below once `spec['unresolved']` exists to carry a
# refusal reason -- never raised out of this function, because a bad
# geometry read must lose only the matrix, not the whole spec.
matrix_layout, matrix_why = None, 'strandmatrix module not installed'
if strandmatrix is not None:
try:
qty = sum([g.get('qty') for g in spec['reinforcing']['strand_groups']
if g.get('qty') is not None])
view_ev = (evidence.get('summary_views') or [None])[0]
symbol_kind = K.get(know, 'design_summary.strand_symbol', 'FilledRegion')
width_ft = spec['envelope']['width']['max_ft']
matrix_layout, matrix_why = strandmatrix.read_layout(
view_ev, qty, thickness_in=spec.get('thickness_in'),
width_ft=width_ft, symbol_kind=symbol_kind)
except Exception as ex:
matrix_layout = None
matrix_why = 'strand matrix read failed: {0}: {1}'.format(
type(ex).__name__, ex)
if matrix_layout:
layout = spec['reinforcing']['strand_layout']
layout['columns_in'] = matrix_layout.get('columns_in')
layout['rows'] = matrix_layout.get('rows')
layout['total_in'] = matrix_layout.get('total_in')
layout['note'] = matrix_layout.get('note')
# Every note the generic rules could not place. The plant's declared rule
# packs then get one look at that list and may move a note into `notes`
# (prose a person reads) or leave it where it is with a better sentence
# beside it -- never remove one. `apply_to` returns both halves and their
# lengths always sum back to what went in, which is the property that
# keeps a house-style rule from becoming a silent exclusion.
#
# No packs declared, or a declaration this build refuses, means this is
# exactly the single assignment it used to be. A refusal is carried on the
# spec rather than raised: a spec that lost its extraction because a
# knowledge file named an unknown pack would be a worse outcome than one
# that says so on its face and leaves every note visible.
from vibe import extractrules
entries = ticketqc.unresolved_in_view(doc, view)
packs, problems = extractrules.load(know)
notes, unresolved = extractrules.apply_to(entries, packs)
spec['notes'] = notes
spec['unresolved'] = unresolved
if problems:
spec['source']['rule_pack_problems'] = problems
# Appended AFTER extractrules.apply_to so the rule packs never see this
# line -- it is not a note off the drawing, it is this function's own
# refusal, and folding it into `entries` above would let a house-style
# rule pack move or reinterpret it like ordinary drawing text.
if not matrix_layout:
spec['unresolved'].append({'text': 'STRAND PATTERN (matrix)',
'reason': matrix_why})
return spec