Files
willtools-demo/WillTools.extension/lib/vibe/ticketqc.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

3532 lines
147 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Shop-ticket vs design-summary compliance checking.
The engineer writes a design summary -- a drafting view named exactly after the
design number, e.g. 'PNL1.0' -- stating f'c, f'ci, strand groups, W.W.F., unit
weight and a min/max size envelope. A detailer then turns each reinforced
assembly into a shop ticket: a sheet named after the piece mark, e.g. 'PNL101'.
This module re-reads both sides and reports where they disagree.
**No office's parameter names appear in this file.** Values are read by role
-- 'panel.length', 'sheet.design_number' -- and knowledge.yaml, which lives
beside the model rather than in this repository, says what each role is called
here. An unmapped role reads as absent and Self Check lists it; nothing
guesses. That is what makes this usable by a shop other than the one it was
written in.
Where the values live, structurally:
design summary drafting view, free text notes. The min/max table is a
label note and a value note sitting side by side, so it is
paired by Y position rather than parsed from one string.
ticket sheet roles under 'sheet.*' -- design number, length, width,
depth, and optionally the producing plant.
panel member roles under 'panel.*', plus 'panel_type.thickness' on the
type rather than the instance.
strand one family instance per strand; 'strand_type.diameter' and
'strand_type.description' ('... - 270 KSI') on the type.
W.W.F. 'wwf_type.description', e.g.
'SHTS (D) 6 X 6 - D3 / D3 (8'-4" X 11'-10")'
f'c, f'ci, I.T. NOT parameters anywhere. They are printed as text inside
the pattern legend placed on the ticket, so the only way to
check them is to compare summary text against legend text.
Two things that look like rules but are not:
* the trailing segment of a legend name is the *pattern* letter (A, B, A1)
-- the legend body says STRAND PATTERN "B" -- not a plant code.
* a ticket's printed length is often nominal and runs a fraction over the
modelled length on stemmed pieces, so that comparison is off by default
(see check_dimension_params).
IronPython 2.7: no f-strings, no pathlib, no typing.
"""
# pylint: disable=import-error,invalid-name,broad-except
import io
import json
import os
import re
import time
from fractions import Fraction
from vibe import compat
from Autodesk.Revit.DB import (
FilteredElementCollector, AssemblyInstance, View, ViewSheet, ViewType,
BuiltInCategory, Element, StorageType,
)
# Pattern-legend prefixes are now read from knowledge.yaml
# (legend.pattern_prefixes) in read_ticket -- see its comment. A plant that
# draws no legend (prefixes empty) has nothing to collect and nothing to fail
# on. WWF and the dimensional tolerance:
WWF_PREFIX = 'WWF'
DIM_TOL = 1.0 / 32.0 # dimensional comparisons, in decimal feet
def is_strand_family(name):
"""True for families whose instances each represent one strand.
Lifting loops carry 'STRAND' in the name too, so they are excluded.
"""
up = (name or '').upper()
return 'STRAND' in up and 'LOOP' not in up and 'GROMMET' not in up
def strand_diameter(family_name, bar_diameter_text):
"""Diameter of one strand, in inches.
The family name encodes it ('9-16_STRAND' is 9/16") and is trusted ahead
of the parameter, because a diameter parameter left at the wrong value is
a real and observed condition -- one library had the 9/16" family carrying
5/8" -- and the family name is what the plant actually pulls from.
"""
m = re.match(r"^(\d+)-(\d+)_", family_name or '')
if m:
denom = float(m.group(2))
if denom:
return float(m.group(1)) / denom
return parse_inches(bar_diameter_text)
# --------------------------------------------------------------- small utils
def name_of(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 parse_feet(text):
"""Parse "2'-11 1/4\"" into decimal feet. None when unparseable."""
if not text:
return None
s = text.replace(u'’', "'").replace(u'”', '"').strip()
m = re.search(r"(\d+)\s*'", s)
if not m:
return None
feet = float(m.group(1))
rest = s[m.end():]
inches = 0.0
mixed = re.search(r"(\d+)\s+(\d+)\s*/\s*(\d+)", rest)
if mixed:
inches = float(mixed.group(1)) + float(mixed.group(2)) / float(mixed.group(3))
else:
frac = re.search(r"(\d+)\s*/\s*(\d+)", rest)
if frac:
inches = float(frac.group(1)) / float(frac.group(2))
else:
whole = re.search(r"(\d+)", rest)
if whole:
inches = float(whole.group(1))
return feet + inches / 12.0
def fmt_feet(value):
"""Decimal feet -> "34'-5 1/4\"" for display."""
if value is None:
return '-'
sign = '-' if value < 0 else ''
value = abs(value)
feet = int(value)
sixteenths = int(round((value - feet) * 192.0))
inch, rem = divmod(sixteenths, 16)
if inch == 12:
feet += 1
inch = 0
out = "{0}'-{1}".format(feet, inch)
if rem:
out += ' {0}'.format(Fraction(rem, 16))
return sign + out + '"'
def parse_inches(text):
"""Parse a bare inch dimension such as '9/16' or '1/2' into inches."""
if not text:
return None
s = str(text).replace('"', '').strip()
m = re.match(r"^\s*(\d+)\s+(\d+)\s*/\s*(\d+)\s*$", s)
if m:
return float(m.group(1)) + float(m.group(2)) / float(m.group(3))
m = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", s)
if m:
return float(m.group(1)) / float(m.group(2))
m = re.match(r"^\s*([\d.]+)\s*$", s)
if m:
return float(m.group(1))
return None
def fmt_inches(value):
if value is None:
return '-'
frac = Fraction(value).limit_denominator(32)
return '{0}"'.format(frac)
def as_number(text):
if text is None:
return None
try:
return float(str(text).replace(',', '').strip())
except Exception:
return None
def _fmt_num(v):
if v is None:
return '-'
if abs(v - round(v)) < 1e-9:
return '{0:,.0f}'.format(v)
return '{0:,.2f}'.format(v)
def _type_param(el, pname):
"""The same-named parameter on el's ELEMENT TYPE, for the fallback below.
`Element.LookupParameter` only ever sees parameters that live on the
INSTANCE -- a role the plant layer maps to a TYPE parameter (wall
thickness and the like) reads back None forever, silently, because a
role reading None just means the check stands down. This is that
fallback's one job: find the type's own Parameter object, or fail to
None without raising.
Exception-safe end to end on purpose. A fake element built for a test
-- or a real one Revit hands back mid-edit -- may have no `GetTypeId`,
no `.Document`, an invalid/None type id, or a `.Document` that cannot
resolve it; every one of those has to degrade to "no type reachable",
not to a traceback, or a test double with no Revit behind it at all
starts raising the moment this function is added.
"""
if el is None:
return None
try:
type_id = el.GetTypeId()
except Exception:
return None
if type_id is None:
return None
try:
el_type = el.Document.GetElement(type_id)
except Exception:
return None
if el_type is None:
return None
try:
return el_type.LookupParameter(pname)
except Exception:
return None
def _pstr_raw(p):
if not p:
return None
if p.StorageType == StorageType.String:
return p.AsString()
return p.AsValueString()
def pstr(el, pname):
"""Read `pname` off el's INSTANCE first, falling back to its TYPE.
Instance wins whenever both carry a value: the instance is what was set
for this particular piece, and the type value is only what the piece
inherited -- so an instance override, even an unusual one, is the
answer this piece actually has. The type is consulted only when the
instance has nothing to say (no such parameter, or present but blank),
which is exactly the case a type-only role (wall thickness, and
anything else this office maps to a type parameter) used to read as a
silent None.
When no type is reachable -- see `_type_param` -- this returns exactly
what it always returned: the instance's raw value, including None or
the empty string for "found the parameter but it carries nothing".
"""
instance_value = _pstr_raw(el.LookupParameter(pname) if el is not None else None)
if instance_value not in (None, ''):
return instance_value
type_value = _pstr_raw(_type_param(el, pname))
if type_value not in (None, ''):
return type_value
return instance_value
def _pdouble_raw(p):
if not p or p.StorageType != StorageType.Double:
return None
return p.AsDouble()
def pdouble(el, pname):
"""AsDouble() -- Revit's raw internal value, in Revit's internal unit.
Correct for a length/area/volume BuiltInParameter, where the internal
unit (decimal feet / sq ft / cu ft) is what the rest of this codebase
already computes in. NOT SAFE for an arbitrary custom shared parameter:
measured live 2026-09-02 on WPA545E's cast-weight shared parameter (the
one `panel.weight` maps to on that plant), AsDouble() returned
560537.22 while AsValueString() -- what Revit actually shows the
engineer -- read "38,409" lb, a 14.5939029x units artifact of how that
one parameter stores its raw value (verified 451/451 against
AsValueString in `_scratch/match_weights.py`; the true weight checks
out at exactly 150 pcf against the panel's own volume).
Weight and unit-weight are read through `rstr`/`pstr` below
(AsValueString, parsed by `weight_lb`/`as_number`) for exactly this
reason -- do not "simplify" a weight or unit-weight role onto
`rdouble`/`pdouble` without re-verifying AsDouble against
AsValueString on a live parameter first.
Same INSTANCE-first, then-TYPE fallback as `pstr` above, for the same
reason (a type-only role reading None forever) -- and the same
exception-safety guarantee: no type reachable means this returns
exactly what it always returned, `None`.
"""
instance_value = _pdouble_raw(el.LookupParameter(pname) if el is not None else None)
if instance_value is not None:
return instance_value
return _pdouble_raw(_type_param(el, pname))
def pstr_instance_only(el, pname):
"""Read `pname` off el's INSTANCE only. No type fallback, ever.
`pstr` above unions instance and type on purpose -- that is the whole
point of the 2026-09-02 fix, and every ordinary caller wants the union.
This exists for the one caller that must NOT have it:
`ticketchecks._reinforcing_values`, which keeps an instance read and a
type read as two SEPARATE values because they are reported as
mutually exclusive ('source': 'instance' or 'type') and a plant's
`read_from` config states which one it trusts. If `pstr` unioned them,
a type-only value could silently surface as the "instance" value and
the report would name the wrong source -- a wrong attribution that
looks exactly like a right one, which is the class of bug this
codebase treats as the serious one (CLAUDE.md Sec.1). Do not point a
new caller at this function unless it has the same genuinely-exclusive
requirement; everything else wants `pstr`.
"""
return _pstr_raw(el.LookupParameter(pname) if el is not None else None)
def pstr_type_only(el, pname):
"""Read `pname` off el's TYPE only. No instance fallback, ever.
The symmetric twin of `pstr_instance_only` above, for the same caller
and the same reason -- see its docstring.
"""
return _pstr_raw(_type_param(el, pname))
def rstr(el, know, role):
"""Read a parameter by role, trying each name this office maps to it.
Returns None when the role is unmapped or no candidate exists on the
element. Both cases are 'we do not know', and the check that wanted the
value is expected to say so rather than treat absence as agreement.
This is the safe path for a role backed by a custom shared parameter
whose display value can differ from its raw AsDouble() -- see the
warning on `pdouble` above. `panel.weight` and `panel.unit_weight` are
read this way on purpose, never via `rdouble`.
"""
from vibe import knowledge as K
for pname in K.param_names(know, role):
value = pstr(el, pname)
if value not in (None, ''):
return value
return None
def rdouble(el, know, role):
"""AsDouble() by role -- see the warning on `pdouble` above before
pointing this at a weight or unit-weight role."""
from vibe import knowledge as K
for pname in K.param_names(know, role):
value = pdouble(el, pname)
if value is not None:
return value
return None
def rstr_instance_only(el, know, role):
"""INSTANCE-only `rstr` -- see `pstr_instance_only` above for why this
exists at all. Used only by `ticketchecks._reinforcing_values`; every
other caller wants the unioning `rstr`."""
from vibe import knowledge as K
for pname in K.param_names(know, role):
value = pstr_instance_only(el, pname)
if value not in (None, ''):
return value
return None
def rstr_type_only(el, know, role):
"""TYPE-only `rstr` -- the symmetric twin of `rstr_instance_only`."""
from vibe import knowledge as K
for pname in K.param_names(know, role):
value = pstr_type_only(el, pname)
if value not in (None, ''):
return value
return None
def role_label(know, role):
"""What to call a role in a message: the mapped name, else the role."""
from vibe import knowledge as K
names = K.param_names(know, role)
return names[0] if names else role
from vibe import ticketpage
from vibe.ticketpage import clean_text # noqa: F401 -- re-exported
def _text_notes(doc, view_id):
return list(FilteredElementCollector(doc, view_id)
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements())
def _flat_text(doc, view_id):
blob = ' '.join([clean_text(t.Text).replace('\r', ' ')
.replace('\n', ' ')
for t in _text_notes(doc, view_id)])
return re.sub(r'\s+', ' ', blob).strip()
def normalise_design(value):
"""'PNL1..0' and 'PNL1.0' name the same design; the doubled dot is a typo."""
return re.sub(r'\.+', '.', (value or '').strip().upper())
def _point_of(element):
"""Placement point of a family instance, as an XYZ in model coordinates."""
try:
return element.Location.Point
except Exception:
return None
def _panel_frame(panel):
"""The panel's own coordinate system and extents within it.
Precast panels are placed rotated -- a vertical wall panel's length runs up
the model Z -- so a world-axis bounding box says nothing useful about where
a lifter sits along the piece. Working in the instance's local frame makes
'distance from the end' mean what it says.
Returns (inverse_transform, local_min, local_max) or None.
"""
from Autodesk.Revit.DB import Options, GeometryInstance
try:
transform = panel.GetTransform()
geometry = panel.get_Geometry(Options())
box = None
for item in geometry:
if isinstance(item, GeometryInstance):
box = item.GetSymbolGeometry().GetBoundingBox()
break
if box is None:
box = geometry.GetBoundingBox()
if box is None:
return None
return transform.Inverse, box.Min, box.Max
except Exception:
return None
def to_local(frame, point):
"""A model-coordinate point expressed in the panel's own frame."""
if frame is None or point is None:
return None
try:
p = frame[0].OfPoint(point)
return (p.X, p.Y, p.Z)
except Exception:
return None
def concrete_extents(panel, frame):
"""The panel's largest solid, as local (min, max) triples in feet.
Not the bounding box: on at least one wall-panel family the box runs an
inch past the concrete on one side, and an inch is the entire bed slack on
a full-width piece. The strand chain is dimensioned from where the
concrete starts, so the concrete is what has to be measured.
"""
from Autodesk.Revit.DB import Options, GeometryInstance, Solid
if frame is None:
return None
try:
best = None
stack = [panel.get_Geometry(Options())]
while stack:
geometry = stack.pop()
if geometry is None:
continue
for item in geometry:
if isinstance(item, Solid):
if item.Volume > 0.01 and (best is None or
item.Volume > best.Volume):
best = item
elif isinstance(item, GeometryInstance):
try:
stack.append(item.GetInstanceGeometry())
except Exception:
pass
return extents_of_solid(best, frame)
except Exception:
return None
def extents_of_solid(solid, frame):
"""One solid's corners as local (min, max) triples in feet.
Split out of concrete_extents so a caller that already HAS the panel's
solid does not pay for a second geometry read to get the same numbers.
The weight check (checklist 2.2) is that caller: it reads the solid for
its volume and needs the same solid's extents for the reconstruction,
and a second get_Geometry per ticket is not free.
"""
if solid is None or frame is None:
return None
try:
inv = frame[0]
lo = [None] * 3
hi = [None] * 3
for edge in solid.Edges:
curve = edge.AsCurve()
for pt in (curve.GetEndPoint(0), curve.GetEndPoint(1)):
q = inv.OfPoint(pt)
for axis, value in enumerate((q.X, q.Y, q.Z)):
if lo[axis] is None or value < lo[axis]:
lo[axis] = value
if hi[axis] is None or value > hi[axis]:
hi[axis] = value
if lo[0] is None:
return None
return tuple(lo), tuple(hi)
except Exception:
return None
def element_solids(el):
"""Every solid an element draws, flattened out of its instance geometry."""
from Autodesk.Revit.DB import Options, GeometryInstance, Solid
found = []
stack = [el.get_Geometry(Options())]
while stack:
geometry = stack.pop()
if geometry is None:
continue
for item in geometry:
if isinstance(item, GeometryInstance):
try:
stack.append(item.GetInstanceGeometry())
except Exception:
pass
elif isinstance(item, Solid) and item.Volume > 1e-9:
found.append(item)
return found
def _one_solid_box(solid, inv):
"""Local-frame box of a single solid, in inches, or None.
Solid.GetBoundingBox() rather than the edge vertices. A strand is a
cylinder and a cylinder's edges are circles, whose two "end" points are
the same point on the seam -- sampling those collapses the box to a sliver
in two axes, and a sliver never overlaps anything. A clash test built on
that passes every time, which is the worst way for it to be wrong.
The solid states its box in its own coordinates, so that Transform is
applied before the panel's inverse.
"""
from Autodesk.Revit.DB import XYZ
box = solid.GetBoundingBox()
if box is None:
return None
local = box.Transform
lo = [None] * 3
hi = [None] * 3
for i in (0, 1):
for j in (0, 1):
for k in (0, 1):
corner = XYZ(box.Min.X if i else box.Max.X,
box.Min.Y if j else box.Max.Y,
box.Min.Z if k else box.Max.Z)
q = inv.OfPoint(local.OfPoint(corner))
for axis, value in enumerate((q.X * 12.0, q.Y * 12.0,
q.Z * 12.0)):
if lo[axis] is None or value < lo[axis]:
lo[axis] = value
if hi[axis] is None or value > hi[axis]:
hi[axis] = value
if lo[0] is None:
return None
return lo, hi
def intersection_depth(solids_a, solids_b):
"""How deep two elements actually interfere, in inches. None when clear.
Returns (thickness_in, volume_in3, box_least_in).
THICKNESS, NOT A BOUNDING BOX. For a thin body, Volume = Area x t and
SurfaceArea = 2 x Area, so
t = 2 * Volume / SurfaceArea
is the mean thickness of the shared material, and it does not care how the
sliver is oriented or how curved it is.
That last part is the whole reason. When two round bars graze, the shared
material is a curved lens, and a lens is no better described by its
bounding box than a bent bar is: measured on a real piece, these
intersections fill 10 to 15 percent of their own boxes. Boxing them
reported 0.51" of interference where the shared volume was 0.0166 cubic
inches -- a shape a thousandth of an inch thick would have measured the
same. Every disputed reading on that piece came from this.
Known bias: mean thickness under-reports a chunky intersection, giving
side/3 for a cube and diameter/3 for two bars crossing dead centre. It
errs towards saying an interference is shallower than it is, so the
tolerance it is compared against should be set knowing that.
A boolean that throws -- coincident faces are the usual cause -- is
skipped rather than counted either way.
"""
from Autodesk.Revit.DB import BooleanOperationsUtils, BooleanOperationsType
best = None
for a in solids_a:
for b in solids_b:
try:
shared = BooleanOperationsUtils.ExecuteBooleanOperation(
a, b, BooleanOperationsType.Intersect)
except Exception:
continue
if shared is None or shared.Volume <= 1e-12:
continue
volume = shared.Volume * 1728.0
area = shared.SurfaceArea * 144.0
if area <= 0:
continue
thickness = 2.0 * volume / area
box = shared.GetBoundingBox()
least = None
if box is not None:
least = min([(getattr(box.Max, ax) - getattr(box.Min, ax)) * 12.0
for ax in ('X', 'Y', 'Z')])
if best is None or thickness > best[0]:
best = (round(thickness, 4), round(volume, 5),
round(least, 4) if least is not None else None)
return best
def _world_box(el, frame):
"""The element's own bounding box, cheaply, expressed in the panel frame.
get_BoundingBox is a cached read; get_Geometry is a solve. This is the
broad phase: it is world-axis-aligned, so on a rotated panel it is a loose
over-approximation -- which is exactly what a broad phase must be. It may
say 'maybe', never 'no' when the answer is yes.
"""
from Autodesk.Revit.DB import XYZ
box = el.get_BoundingBox(None)
if box is None:
return None
inv = frame[0]
lo = [None] * 3
hi = [None] * 3
for i in (0, 1):
for j in (0, 1):
for k in (0, 1):
corner = XYZ(box.Min.X if i else box.Max.X,
box.Min.Y if j else box.Max.Y,
box.Min.Z if k else box.Max.Z)
q = inv.OfPoint(corner)
for axis, value in enumerate((q.X * 12.0, q.Y * 12.0,
q.Z * 12.0)):
if lo[axis] is None or value < lo[axis]:
lo[axis] = value
if hi[axis] is None or value > hi[axis]:
hi[axis] = value
return lo, hi
def member_boxes(doc, asm, panel, frame, know=None):
"""Every member of the assembly as a cheap local-frame box, in inches.
Broad-phase boxes only -- see _world_box. The precise solid box is solved
later, for the few members that survive. know is passed through for the
member.mark role (the plant's name for the mark parameter).
"""
if frame is None:
return []
out = []
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if member is None or member.Id == panel.Id:
continue
try:
family = member.Symbol.Family.Name
except Exception:
continue
box = None
try:
box = _world_box(member, frame)
except Exception:
box = None
if box is None:
continue
out.append({'id': eid(member), 'family': family,
'category': member.Category.Name if member.Category else '',
'mark': _member_mark(member, know),
'lo': box[0], 'hi': box[1], 'element': member})
return out
def _member_mark(member, know=None):
"""The member's mark, as printed (None when unset).
Parameter names live in KNOWLEDGE, not code: the plant maps the
role `member.mark` to whatever its template calls the mark parameter
(measured: the role resolves to the mark parameter, which holds
'SR612' on rebar and 'P106' on anchors). The built-in 'Mark' stays
as the general fallback -- that is Revit's standard property, not a
plant fact. None means genuinely no mark exists; '' and None are
distinct.
"""
if know is not None:
from vibe import knowledge as K
names = K.param_names(know, 'member.mark')
for param_name in names:
v = _read_mark_param(member, param_name)
if v:
return v
return _read_mark_param(member, 'Mark')
def _read_mark_param(member, param_name):
try:
p = member.LookupParameter(param_name)
except Exception:
return None
if p is None:
return None
try:
v = p.AsString()
except Exception:
v = None
if v is None:
try:
v = p.AsValueString()
except Exception:
v = None
return v
def refine_box(entry, frame):
"""Solve the element's geometry, once, in place.
Adds a box PER SOLID rather than one box for the element. An embed drawn
as a plate at one end and anchor bars at the other is nine solids, and
merging them into one box claims all the air between as occupied -- on one
real piece those merged boxes were 3% steel by volume. Per-solid boxes cut
the reported interferences from 265 to 223 before any boolean ran.
The merged box is kept too, as the quick reject for the pair.
"""
if entry.get('exact'):
return entry
entry['exact'] = True
element = entry.get('element')
if element is None:
return entry
try:
solids = element_solids(element)
boxes = [_one_solid_box(s, frame[0]) for s in solids]
boxes = [b for b in boxes if b]
if not boxes:
return entry
entry['solids'] = solids
entry['solid_boxes'] = boxes
lo = [min([b[0][axis] for b in boxes]) for axis in (0, 1, 2)]
hi = [max([b[1][axis] for b in boxes]) for axis in (0, 1, 2)]
entry['lo'], entry['hi'] = lo, hi
except Exception:
pass
return entry
def strand_layout_of(panel, frame, strand_points, thickness_ft):
"""Where the strands sit in the piece, in inches.
Returns {'across': [...], 'rows': [...], 'width_in': w} measured from the
concrete, or None. 'across' runs the width and locates one strand per
entry; 'rows' runs the thickness and locates the layers.
The width axis is the one the strands actually spread along rather than
the one closest to a nominal width -- a nearly square panel makes the
second test pick the length.
"""
extents = concrete_extents(panel, frame)
if not extents or not strand_points:
return None
lo, hi = extents
span = [hi[i] - lo[i] for i in (0, 1, 2)]
thick_axis = 0
for axis in (1, 2):
if thickness_ft:
if abs(span[axis] - thickness_ft) < abs(span[thick_axis] - thickness_ft):
thick_axis = axis
elif span[axis] < span[thick_axis]:
thick_axis = axis
others = [i for i in (0, 1, 2) if i != thick_axis]
width_axis = others[0]
if (len(set([round(p[others[1]], 4) for p in strand_points])) >
len(set([round(p[others[0]], 4) for p in strand_points]))):
width_axis = others[1]
def inches(values):
return sorted(set([round(v * 12.0, 3) for v in values]))
return {
'across': inches([p[width_axis] - lo[width_axis] for p in strand_points]),
'across_from_far': inches([hi[width_axis] - p[width_axis]
for p in strand_points]),
'rows': inches([p[thick_axis] - lo[thick_axis] for p in strand_points]),
'width_in': round(span[width_axis] * 12.0, 3),
}
# ------------------------------------------------------- shared text parsing
# 'USE (10) 1/2" DIA. SP. STRAND-270K LL @ 31,600 #I.T.' and the several other
# ways the same sentence is written across piece types. The count is sometimes
# bare rather than parenthesised ('USE 10 1/2" DIA. SP STR.').
_STRAND_RE = re.compile(
r"USE\s*\(?(\d+)\)?[\s_]*" # count, sometimes '(14) _ 1/2"'
r"(\d+(?:\s+\d+)?\s*/\s*\d+)\s*\"?\s*" # diameter, e.g. 1/2 or 9/16
r"(?:DIA\.?|SP\.?|SPECIAL)" # noise before the grade
r"[^@]{0,60}?"
r"(\d{3})\s*K" # grade, 270K
r"[^@]{0,40}?@\s*([\d,]+)", # initial tension after the @
re.I)
def parse_strand_groups(text):
"""Every 'USE (n) d" ... 270K ... @ it' sentence in a block of text.
A piece can carry more than one group -- shielded strands are called out
separately -- so this returns a list rather than one value.
"""
if not text:
return []
groups = []
for m in _STRAND_RE.finditer(text):
groups.append({
'qty': int(m.group(1)),
'dia': parse_inches(m.group(2)),
'ksi': as_number(m.group(3)),
'it': as_number(m.group(4)),
})
return groups
def strand_signature(groups):
"""A comparable, order-independent rendering of a set of strand groups."""
parts = []
for g in sorted(groups, key=lambda x: (-(x['qty'] or 0), x['dia'] or 0)):
parts.append('({0}) {1} {2}K @ {3}'.format(
g['qty'], fmt_inches(g['dia']), _fmt_num(g['ksi']), _fmt_num(g['it'])))
return ' + '.join(parts) if parts else '-'
def parse_concrete(text):
"""f'c and f'ci out of either the summary or a legend body."""
out = {'fc': None, 'fci': None, 'pcf': None}
if not text:
return out
up = text.upper()
for pattern in (r"F'C\s*=?\s*([\d,]+)\s*PSI",
r"USE\s*([\d,]+)\s*PSI\s*CONCRETE\s*AT\s*28\s*DAYS"):
m = re.search(pattern, up)
if m:
out['fc'] = as_number(m.group(1))
break
for pattern in (r"F'CI\s*=?\s*([\d,]+)\s*PSI",
r"RELEASE\s*AT\s*([\d,]+)\s*PSI",
r"USE\s*([\d,]+)\s*PSI\s*CONCRETE\s*MINIMUM\s*@\s*RELEASE"):
m = re.search(pattern, up)
if m:
out['fci'] = as_number(m.group(1))
break
# 'WT. 150 PCF', 'WEIGHT 150# C.F.' and 'WEIGHT 150 PCF' all appear.
m = re.search(r"(?:WT\.?|WEIGHT)\s*([\d,]+)\s*#?\s*(?:PCF|C\.?\s*F\.?)", up)
if not m:
m = re.search(r"([\d,]+)\s*#?\s*PCF", up)
out['pcf'] = as_number(m.group(1)) if m else None
return out
def parse_wwf(text):
"""Mesh designation and layers-per-face, e.g. '6X6-D3.0/D3.0', 1."""
out = {'mesh': None, 'layers': None, 'per_face': False}
if not text:
return out
up = text.upper()
m = re.search(r"(\d+\s*X\s*\d+\s*-?\s*D[\d.]+\s*/\s*D[\d.]+)", up)
if m:
out['mesh'] = re.sub(r'\s+', '', m.group(1)).replace('.0', '')
m = re.search(r"\((\d+)\)\s*LAYER", up)
if m:
out['layers'] = int(m.group(1))
tail = up[m.end():m.end() + 20]
out['per_face'] = 'FACE' in tail
return out
# ------------------------------------------------------------ design summary
def parse_design_summary(doc, view):
"""Read one design-summary drafting view into a dict of stated intent.
Every field is optional; summaries for different piece types word things
differently and a missing value becomes None rather than an error.
"""
notes = _text_notes(doc, view.Id)
blob = ' '.join([t.Text.replace('\r', ' ').replace('\n', ' ') for t in notes])
blob = re.sub(r'\s+', ' ', blob)
out = {'name': view.Name, 'view_id': eid(view), 'note_count': len(notes),
'text': blob}
out.update(parse_concrete(blob))
out['strands'] = parse_strand_groups(blob)
wwf = parse_wwf(blob)
out['wwf_mesh'] = wwf['mesh']
out['wwf_layers'] = wwf['layers']
out['wwf_per_face'] = wwf['per_face']
m = re.search(r"USE\s*([\d,]+)\s*KSI\s*MILD", blob.upper())
out['mild_ksi'] = as_number(m.group(1)) if m else None
out['envelope'] = _parse_envelope(notes)
# THE READER'S OWN RECORD OF WHAT IT COULD NOT PLACE (2026-08-30).
#
# Every field above is what a rule DID match. Nothing here used to say
# what nothing matched, so a design with no spec on disk -- which is
# every design until Extract Design has been run on it -- reached
# checklist 8.1 with no list to enumerate, and 8.1 went quiet on the
# exact tickets it has most to say about. `unresolved_in_view` already
# computes that list for `designspec`; the regex reader keeps it too,
# so intent from either source answers the same question.
#
# It costs one more pass over the same notes and adds no new way to
# fail: its calls (`_text_notes`, `clean_text`) are a strict subset of
# the ones this function has already made by the time it gets here.
out['unresolved'] = unresolved_in_view(doc, view)
# THE SUMMARY VOCABULARY, ONE STEP ON (2026-08-31). `unresolved` above
# is the verbatim record and stays exactly that. `requirements` is what
# the vocabulary made of those lines -- typed, and consumable by a row
# that has a comparison to make: 5.9 reads a connector spacing out of
# it. See `ticketpage.classify_summary_lines`; the un-placed half is
# still 8.1's, and 8.1 re-derives the split rather than trusting a key
# written here, so an intent assembled by hand cannot smuggle a claim
# past it.
out['requirements'] = ticketpage.classify_summary_lines(
out['unresolved'])[0]
return out
def _parse_envelope(notes):
"""Pair 'MAX. LENGTH' style labels with the dimension note beside them.
Labels and values are independent text notes; the value sits to the right
of its label at very nearly the same Y.
"""
labels, values = [], []
for note in notes:
raw = clean_text(note.Text).replace('\r', '').strip()
pos = note.Coord
lm = re.match(r"^(MAX|MIN)\.?\s*(LENGTH|WIDTH)\.?$", raw.upper())
if lm:
labels.append((pos.Y, pos.X, lm.group(1), lm.group(2)))
elif re.match(r"^\s*\d+\s*'", raw) and parse_feet(raw) is not None:
values.append((pos.Y, pos.X, parse_feet(raw), raw))
envelope = {}
for ly, lx, kind, dim in labels:
best, best_dy = None, None
for vy, vx, val, raw in values:
if vx <= lx:
continue
dy = abs(vy - ly)
if dy < 0.20 and (best_dy is None or dy < best_dy):
best, best_dy = (val, raw), dy
if best:
envelope['{0}_{1}'.format(kind, dim)] = best
return envelope
_UNRESOLVED_REASON = ('no extraction rule matched this note -- it is not '
'reflected anywhere in the spec; fold it in by hand '
'if it belongs')
def unresolved_in_view(doc, view):
"""Every text note in this view no rule in parse_design_summary matched.
parse_design_summary runs its regexes against the whole view as one
blob, which is right for matching (a strand callout is one sentence, a
label and its value are two notes that never need joining textually) but
wrong for saying WHICH note fed a match. So this reruns the same rules
against each note in isolation instead: a note that matches nothing on
its own is genuinely unresolved, and one that does match is accounted
for somewhere in the spec built from this view.
There is no third category. A rule either matches a note or it does
not -- this function does not guess at what an unmatched note MEANS, only
records it verbatim with why it was not placed, which is the whole point:
an extractor that cannot silently drop a line is honest even when its
rules are narrow.
"""
out = []
for t in _text_notes(doc, view.Id):
raw = clean_text(t.Text).replace('\r', ' ').replace('\n', ' ').strip()
text = re.sub(r'\s+', ' ', raw)
if not text:
continue
if _matches_a_rule(text):
continue
out.append({'text': text, 'reason': _UNRESOLVED_REASON})
return out
def _matches_a_rule(text):
"""Whether some rule in parse_design_summary would place this note alone."""
concrete = parse_concrete(text)
if concrete['fc'] or concrete['fci'] or concrete['pcf']:
return True
if parse_strand_groups(text):
return True
wwf = parse_wwf(text)
if wwf['mesh'] or wwf['layers']:
return True
if re.search(r"USE\s*[\d,]+\s*KSI\s*MILD", text.upper()):
return True
if re.match(r"^(MAX|MIN)\.?\s*(LENGTH|WIDTH)\.?$", text.upper()):
return True
if re.match(r"^\s*\d+\s*'", text) and parse_feet(text) is not None:
return True
return False
# --------------------------------------------------------------- ticket side
# =========================================================================
# THE HALF OF 5.13 / 5.14 / 5.15 THAT IS NOT GATHERED, AND WHAT IT NEEDS
# =========================================================================
#
# DORMANT AND LOUD, ON PURPOSE. `read_ticket` does not set
# `recess_regions`, `recess_patterns`, `solid_zone_pattern` or
# `legend_patterns`, so those three rows read NOT RUN with the sentences
# their checks leave behind -- each of which names this gather. That is the
# established idiom here and it is chosen over the alternative: writing a
# Revit read nobody has run and letting the first live press find out.
#
# THE FOUR KEYS, AND EXACTLY WHAT EACH ONE COSTS TO FILL:
#
# `recess_regions` {type name: count} of drafted `FilledRegion`s on this
# ticket's views whose type name is in
# `insulation.recess.region_types`. ONE view-scoped
# collector per placed view -- the same shape
# `note_view_content_status` already uses, so no new
# idiom and no unfiltered collector. Cheap.
#
# `recess_patterns` {type name: pattern name} the regions ACTUALLY draw
# with. `FilledRegionType.ForegroundPatternId` ->
# `doc.GetElement(id).Name`, THEN any view filter in
# force on the view, because a filter override beats
# the type. Reading only the type would have reported
# every pattern correct on a job whose defect is
# precisely a filter override.
#
# `solid_zone_pattern` the pattern a model solid zone renders with on the
# insulation layout view. This is the one that needs
# `View.GetFilterOverrides(filter_id)
# .SurfaceForegroundPatternId` resolved against the
# view TEMPLATE, not the view -- the override measured
# 2026-08-31 sits on the template
# `TICKET - INSULATED PANEL INSULATION VIEW`.
#
# `legend_patterns` {concept: pattern} read off the legend view's own
# filled-region swatches. Needs the legend view named
# (`insulation.legend.view_name`, landed) and a
# view-scoped `FilledRegion` collector on it, mapped
# through `insulation.legend.swatch_types`. With it,
# the reference is the SHEET's own statement and
# self-updating; without it, the checks fall back to
# the plant map and SAY SO in the finding.
#
# NONE OF THESE HAS BEEN RUN AGAINST A LIVE DOCUMENT BY THIS AGENT. The
# measurements the rows are built on were taken by the bridge; the API path
# that would take them on every press has not been exercised, and guessing
# an enum or a property name is how Revit dies without an exception
# (CLAUDE.md §3). One bridge session lands all four.
def _pattern_name(doc, pattern_id):
"""A `FillPatternElement` id as its name, or None.
`ElementId.InvalidElementId` is "by object style", not a pattern, and it
must not come back as a name -- a view that inherits its hatch and one
that draws `<Solid fill>` are different answers.
"""
if pattern_id is None:
return None
try:
raw = pattern_id.IntegerValue
except Exception:
try:
raw = pattern_id.Value
except Exception:
raw = None
if raw is None or raw <= 0:
return None
try:
element = doc.GetElement(pattern_id)
except Exception:
return None
if element is None:
return None
try:
return clean_text(element.Name)
except Exception:
return None
def _type_foreground_pattern(doc, region_type):
"""A `FilledRegionType`'s foreground pattern name, or None.
THE PROPERTY MOVED. `ForegroundPatternId` is the current name and
`FillPatternId` is what the older binding carries; this extension ships
to machines whose Revit version we do not choose (CLAUDE.md, top), so
both are tried BY NAME -- never by reflection over a guessed string,
which is how a bad value reaches the native layer.
"""
for attribute in ('ForegroundPatternId', 'FillPatternId'):
try:
pattern_id = getattr(region_type, attribute)
except Exception:
continue
name = _pattern_name(doc, pattern_id)
if name:
return name
return None
def _override_foreground_pattern(doc, overrides):
"""An `OverrideGraphicSettings`' surface foreground pattern name.
Same version guard as above: `SurfaceForegroundPatternId` on the
current binding, `ProjectionFillPatternId` on the older one.
"""
if overrides is None:
return None
for attribute in ('SurfaceForegroundPatternId', 'ProjectionFillPatternId'):
try:
pattern_id = getattr(overrides, attribute)
except Exception:
continue
name = _pattern_name(doc, pattern_id)
if name:
return name
return None
def _governing_view(doc, view):
"""The view whose FILTERS actually govern -- its template where it has
one, itself where it has not.
MEASURED 2026-08-31: the override that hatches a solid zone sits on the
view TEMPLATE, not on the view. A reader that asked the view would find
no filter and report the pattern as un-overridden, which on this job is
the exact wrong answer -- the defect 5.13 exists to catch IS a filter
override.
"""
try:
template_id = view.ViewTemplateId
except Exception:
return view, None
try:
raw = template_id.IntegerValue
except Exception:
try:
raw = template_id.Value
except Exception:
raw = None
if raw is None or raw <= 0:
return view, None
try:
template = doc.GetElement(template_id)
except Exception:
template = None
if template is None:
return view, None
return template, clean_text(getattr(template, 'Name', '') or '')
def _filters_touching_detail_items(doc, view):
"""The filters in force on `view` that select the Detail Items category.
THE CONDITION UNDER WHICH A TYPE-LEVEL PATTERN READ IS AUTHORITATIVE,
and it is computed on every run rather than recorded once. A filled
region is a Detail Item; a filter that selects that category can
override its hatch, and then reading the TYPE tells you what the region
would have drawn with and not what it drew. Measured 2026-08-31: no
filter on this template carries the category, so the type read is
authoritative ON THIS JOB -- which is a fact about today's template and
not a property of Revit. The day a filter gains Detail Items this comes
back non-empty and 5.15 stands down naming it, instead of quietly
comparing the wrong thing.
Returns a list of filter names, or None when the question could not be
asked at all (which is not the same as "none", and must not read as it).
"""
from Autodesk.Revit.DB import BuiltInCategory
try:
filter_ids = list(view.GetFilters())
except Exception:
return None
try:
wanted = int(BuiltInCategory.OST_DetailComponents)
except Exception:
return None
hits = []
for filter_id in filter_ids:
try:
rule = doc.GetElement(filter_id)
categories = list(rule.GetCategories())
except Exception:
continue
for category_id in categories:
try:
raw = category_id.IntegerValue
except Exception:
try:
raw = category_id.Value
except Exception:
continue
if raw == wanted:
try:
hits.append(clean_text(rule.Name))
except Exception:
hits.append('an unnamed filter')
break
return hits
def _filled_regions_in(doc, view_id):
"""Every drafted `FilledRegion` on one view. VIEW-SCOPED, always.
An unfiltered collector killed Revit once (CLAUDE.md §3) and this is
the same shape `_text_notes` already uses: one view id, one class.
"""
from Autodesk.Revit.DB import FilteredElementCollector, FilledRegion
try:
return list(FilteredElementCollector(doc, view_id)
.OfClass(FilledRegion).ToElements())
except Exception:
return None
def _read_insulation_hatch(doc, t, sheets, know):
"""`legend_patterns`, `recess_regions`, `recess_patterns` and
`solid_zone_pattern` onto the ticket dict. Rows 5.13, 5.14 and 5.15.
UNTIL 2026-08-31 THIS WAS A COMMENT AND NOT A FUNCTION, and the comment
said why: writing a Revit read nobody has run and letting the first
live press find out is worse than three rows standing down loudly. The
reads have now been taken -- by the bridge, on the live model, id by id
-- so what ships here is the path those measurements exercised, and the
numbers each one returned are recorded beside it.
"""
from vibe import knowledge as K
t['legend_patterns'] = None
t['recess_regions'] = None
t['recess_patterns'] = None
t['solid_zone_pattern'] = None
t['insulation_hatch_notes'] = []
def note(text):
t['insulation_hatch_notes'].append(text)
if not sheets:
note('no sheet has been issued for this piece, so there was no '
'ticket to read a hatch off')
return
legend_name = as_text_or_empty(K.get(know, 'insulation.legend.view_name'))
layout_name = K.insulation_layout_view(know or {})[0]
recess_types = K.recess_region_types(know or {})
# EVERY LEGEND VIEW, NOT JUST THE ONE WE READ THE REFERENCE OFF.
# Measured 2026-08-31: the four filled regions of a recess type in this
# model are ALL legend swatches, spread over TWO legend views -- the
# named one and a second `... INSULATION LEGEND`. Counting swatches as
# placed recesses makes every job in the world look like it draws two.
legend_tokens = [as_text_or_empty(tok).upper()
for tok in (K.get(know, 'insulation.legend.name_tokens')
or [])]
legend_tokens = [tok for tok in legend_tokens if tok]
def is_legend(name):
upper = (name or '').upper()
if legend_name and upper == legend_name.upper():
return True
return bool([tok for tok in legend_tokens if tok in upper])
views, seen = [], set()
for sheet in sheets:
try:
placed = list(sheet.GetAllPlacedViews())
except Exception:
continue
for view_id in placed:
try:
view = doc.GetElement(view_id)
except Exception:
view = None
if view is None or eid(view) in seen:
continue
seen.add(eid(view))
views.append(view)
if not views:
note('no view could be read off this piece\'s sheet(s)')
return
# ---- the legend's own swatches: the SELF-UPDATING reference ----------
concepts = K.legend_swatch_concepts(know or {})
legend_views = [v for v in views if is_legend(clean_text(v.Name))]
if not legend_name:
note('insulation.legend.view_name is unset, so no legend was read '
'and the checks fall back to the plant\'s expected-pattern map')
elif not legend_views:
note('the legend view {0!r} is not placed on this piece\'s sheet(s), '
'so the reference came from the plant map instead of the sheet'
.format(legend_name))
else:
found = {}
for view in legend_views:
regions = _filled_regions_in(doc, view.Id)
if regions is None:
note('the filled regions on legend view {0!r} could not be '
'collected'.format(clean_text(view.Name)))
continue
for region in regions:
type_name, pattern = _region_type_and_pattern(doc, region)
concept = concepts.get((type_name or '').upper())
if concept and pattern:
found[concept] = pattern
t['legend_patterns'] = found
# ---- the recesses: drafted regions, counted OFF the legends ----------
if not recess_types:
note('insulation.recess.region_types is empty, so no drafted region '
'could be recognised as a recess')
else:
wanted = dict([(name.upper(), name) for name in recess_types])
counts = {}
unreadable = 0
for view in views:
if is_legend(clean_text(view.Name)):
continue
regions = _filled_regions_in(doc, view.Id)
if regions is None:
unreadable += 1
continue
for region in regions:
type_name = _region_type_and_pattern(doc, region)[0]
key = wanted.get((type_name or '').upper())
if key:
counts[key] = counts.get(key, 0) + 1
if unreadable and not counts:
note('{0} of this ticket\'s views would not yield their filled '
'regions, so a count of nothing is not a count of none'
.format(unreadable))
else:
t['recess_regions'] = counts
# ---- what those types DRAW with, and when the type may be asked --
governing = None
for view in views:
if layout_name and clean_text(view.Name) == layout_name:
governing = _governing_view(doc, view)[0]
break
overriding = (_filters_touching_detail_items(doc, governing)
if governing is not None else None)
if governing is None:
note('the insulation layout view {0!r} is not placed on this '
'ticket, so no view-filter override could be checked and '
'the recess patterns were not read from their types'
.format(layout_name or 'unset'))
elif overriding is None:
note('the filters in force on the insulation layout view could '
'not be listed, so whether a filter overrides a recess '
'hatch is unknown and the type-level pattern was not '
'trusted')
elif overriding:
note('filter(s) {0} select the Detail Items category on the '
'insulation layout view, so a filled region\'s TYPE no '
'longer says what it drew with -- the recess patterns were '
'not read'.format(', '.join(overriding)))
else:
patterns = {}
for view in views:
if is_legend(clean_text(view.Name)):
continue
regions = _filled_regions_in(doc, view.Id) or []
for region in regions:
type_name, pattern = _region_type_and_pattern(doc, region)
key = wanted.get((type_name or '').upper())
if key and pattern:
patterns[key] = pattern
# A TYPE NOBODY PLACED IS STILL A TYPE WITH A PATTERN, and 5.15
# asks about the types this plant draws recesses with -- not
# about the ones this piece happens to carry. Measured
# 2026-08-31: ZERO recesses are placed on any ticket in this
# job, so a patterns map built only from placed regions would be
# empty on every piece and 5.15 would never run.
for name in recess_types:
if name in patterns:
continue
pattern = _named_region_type_pattern(doc, name)
if pattern:
patterns[name] = pattern
t['recess_patterns'] = patterns
# ---- the solid zones: a view-filter override on the TEMPLATE ---------
filter_name = as_text_or_empty(
K.get(know, 'insulation.solid_zone.view_filter'))
if not filter_name:
note('insulation.solid_zone.view_filter is unset: no layer says '
'which view filter hatches a solid zone at this plant, so the '
'pattern one draws with was not read')
return
layout = None
for view in views:
if layout_name and clean_text(view.Name) == layout_name:
layout = view
break
if layout is None:
note('the insulation layout view {0!r} is not placed on this '
'ticket, so there was nothing to read a solid-zone hatch off'
.format(layout_name or 'unset'))
return
governing, template_name = _governing_view(doc, layout)
try:
filter_ids = list(governing.GetFilters())
except Exception:
filter_ids = []
for filter_id in filter_ids:
try:
rule = doc.GetElement(filter_id)
name = clean_text(rule.Name)
except Exception:
continue
if name.upper() != filter_name.upper():
continue
try:
overrides = governing.GetFilterOverrides(filter_id)
except Exception:
overrides = None
pattern = _override_foreground_pattern(doc, overrides)
if pattern:
t['solid_zone_pattern'] = pattern
note('the solid-zone hatch came from the {0!r} filter on {1}'
.format(name, ('view template ' + template_name)
if template_name else 'the layout view itself'))
else:
note('the {0!r} filter is in force on the insulation layout '
'view and sets no surface foreground pattern, so the zones '
'draw with whatever their own type says -- which this read '
'does not claim to know'.format(name))
return
note('no filter named {0!r} is in force on the insulation layout view '
'(or its template), so the solid zones are not hatched by a filter '
'and the pattern they draw with was not read'.format(filter_name))
def _region_type_and_pattern(doc, region):
"""(type name, foreground pattern name) for one drafted FilledRegion."""
try:
region_type = doc.GetElement(region.GetTypeId())
except Exception:
return None, None
if region_type is None:
return None, None
try:
type_name = clean_text(region_type.Name)
except Exception:
type_name = None
return type_name, _type_foreground_pattern(doc, region_type)
def _named_region_type_pattern(doc, type_name):
"""The foreground pattern of a `FilledRegionType` looked up by NAME.
Type-scoped collector, never a document walk: `OfClass` on one class is
the scoping CLAUDE.md §3 requires, and `FilledRegionType` is a small
population.
"""
from Autodesk.Revit.DB import FilteredElementCollector, FilledRegionType
try:
types = list(FilteredElementCollector(doc)
.OfClass(FilledRegionType).ToElements())
except Exception:
return None
for region_type in types:
try:
name = clean_text(region_type.Name)
except Exception:
continue
if name.upper() == (type_name or '').upper():
return _type_foreground_pattern(doc, region_type)
return None
def as_text_or_empty(value):
"""A knowledge value as stripped text, '' for absent.
Through `clean_text` and never `str()`: the YAML reader on the button
engine hands back `'null'` AS TEXT for an absent key, and a bare `str()`
would make that the name of a view.
"""
if value is None:
return ''
text = (clean_text(text_type(value)) or '').strip()
if text.lower() in ('none', 'null', ''):
return ''
return text
# Owner, verbatim: "lifter 自带 solid zone" -- the lifter carries its own
# solid zone.
def _read_nested_solid_zones(doc, lifter, t, family_name, asm_id, know):
"""The solid zones nested inside one lifter, onto the ticket dict.
ROWS 5.11 - 5.13, and every number in this docstring was measured on
2026-08-31 before a line of it was written:
* every recognised lifter on an insulated piece nests EXACTLY ONE
solid zone, reachable by `GetSubComponentIds()` -- 192 of 192 across
the 27 insulated pieces;
* only 21 of those 192 are assembly members, and 17 of the 27 pieces
have zero members;
* none of the 192 renders on any ticket view, while the 82 STANDALONE
zones at the openings do.
THE STANDALONE ZONES ARE DELIBERATELY NOT GATHERED HERE. 5.11 asks about
the zone a LIFTER carries -- that is the owner's sentence, "the lifter
carries its own solid zone" -- and a standalone zone at an opening has no lifter to be
missing from. Sweeping the family would mix the two populations into one
count, and since they share a single type nothing downstream could
separate them again.
A LIFTER WHOSE SUB-COMPONENTS CANNOT BE READ IS COUNTED, NOT SKIPPED.
`lifters_unread` reaches the finding, because "no zone is missing" and
"no zone was looked for" are the two answers this whole tool exists to
keep apart.
"""
from vibe import knowledge as K
try:
sub_ids = list(lifter.GetSubComponentIds())
except Exception:
t['lifters_unread'] = t.get('lifters_unread', 0) + 1
return
found = 0
for sub_id in sub_ids:
try:
sub = doc.GetElement(sub_id)
sub_family = sub.Symbol.Family.Name
except Exception:
continue
if str(sub_family).strip().upper() != str(family_name).strip().upper():
continue
found += 1
# MEMBERSHIP, AND None WHEN IT COULD NOT BE READ. `AssemblyInstanceId`
# is `ElementId.InvalidElementId` on a non-member, so an exception
# here is a READ failure and not a non-member -- the two must not
# arrive as the same value.
member = None
try:
owner = sub.AssemblyInstanceId
try:
owner_id = owner.IntegerValue
except Exception:
owner_id = owner.Value
member = (owner_id == asm_id)
except Exception:
member = None
t['solid_zones'].append({
'id': eid(sub), 'lifter_id': eid(lifter), 'member': member,
# The depth text 5.10's insulation-edge semantics need. Read
# through the same role every other description is read through,
# so a plant that renames the parameter does not have to
# remember a second key here.
'description': rstr(doc.GetElement(sub.GetTypeId()), know,
'member.identity_description'),
})
if not found:
t['lifters_without_zone'] = t.get('lifters_without_zone', 0) + 1
def read_ticket(doc, asm, sheets_by_name, know=None):
"""Collect everything measurable about one assembly and its shop ticket.
`know` is a loaded knowledge dict; when omitted the built-in family rules
apply, which is what the module did before the knowledge file existed.
"""
t = {'mark': asm.AssemblyTypeName, 'asm_id': eid(asm), 'bom': {},
'legends': [], 'legend_name': None, 'legend_text': None,
'strand_by_dia': {}, 'strand_qty': 0, 'wwf_count': 0,
'wwf_mesh': None, 'sheet': None, 'panel_id': None,
'lifters': [], 'weight_lb': None,
'ticket_strand_count': None,
# The document this ticket was read from, handed to the computed
# checks: vibe.helpers.doc is a button-session global that is not
# valid on a detached/saved copy (measured), so the checks read the
# document off the ticket itself instead.
'doc': doc}
def _is_strand(fam):
if know is None:
return is_strand_family(fam)
from vibe import knowledge as K
return K.is_strand(know, fam)
def _is_lifter(fam):
if know is None:
return False
from vibe import knowledge as K
return K.is_lifter(know, fam)
# CONNECTORS ARE RECOGNISED BY THE MARK ON THE TYPE, NOT BY FAMILY NAME.
# Measured 2026-08-31 and it is the whole reason this gather exists: the
# part number is absent from the pins' family name entirely, and a DECOY
# family named for the part number carries the same mark with ZERO
# instances. A family-name matcher finds the decoy and misses every real
# pin -- a silent zero on the one check whose job is to notice zero.
_connector_marks = []
_connector_gate = None
_connector_read_from = ['type']
if know is not None:
from vibe import knowledge as K
_connector_marks = K.connector_marks(know)
_connector_gate = K.get(know, 'connectors.component_gate')
_connector_read_from = [str(w).strip().lower() for w in
(K.get(know, 'connectors.read_from',
['type']) or []) if str(w).strip()]
# THE SOLID ZONES ARE GATHERED ONLY WHEN THE PLANT HAS NAMED AND
# VERIFIED THE FAMILY. `solid_zone_rule` returns (None, why) otherwise
# and the checks print that sentence; gathering under an unverified name
# would count zero zones on every piece, and zero zones reads as "no
# lifter is missing one" -- an invisible pass over the whole defect
# class the owner commissioned these rows for.
_solid_zone_family = None
_asm_id = eid(asm)
if know is not None:
from vibe import knowledge as K
_solid_zone_family = K.solid_zone_rule(know)[0]
if _solid_zone_family:
# `[]` NOT None: an empty list means "looked, found none", and the
# checks tell that apart from `None`, which means "never looked".
# They arrive at the check as different values so that no later edit
# can collapse the two -- 6.4's lesson, landed the same day.
t['solid_zones'] = []
t['lifters_without_zone'] = 0
t['lifters_unread'] = 0
wwf_prefix = WWF_PREFIX
if know is not None:
from vibe import knowledge as K
wwf_prefix = K.get(know, 'families.wwf_prefix', WWF_PREFIX)
panel = None
wwf_type = None
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
try:
fam = member.Symbol.Family.Name
except Exception:
continue
t['bom'][fam] = t['bom'].get(fam, 0) + 1
cat = member.Category.Name if member.Category else ''
if cat == 'Structural Framing' and panel is None:
panel = member
if _is_strand(fam):
stype = doc.GetElement(member.GetTypeId())
dia = strand_diameter(fam, rstr(stype, know, 'strand_type.diameter'))
t['strand_by_dia'][dia] = t['strand_by_dia'].get(dia, 0) + 1
t['strand_qty'] += 1
t.setdefault('strand_points', []).append(_point_of(member))
if _is_lifter(fam):
t['lifters'].append({'family': fam, 'point': _point_of(member),
'id': eid(member)})
# THE SOLID ZONES NESTED IN THIS LIFTER. Rows 5.11-5.13.
# Reached off the lifter recognition that already happened one
# line above -- one recogniser, several consumers -- and never
# by a family sweep of its own.
if _solid_zone_family:
_read_nested_solid_zones(doc, member, t, _solid_zone_family,
_asm_id, know)
if _connector_marks:
_cm = _connector_mark_of(doc, member, know, _connector_read_from)
if _cm and _cm.upper() in _connector_marks:
# THE GATE IS OPTIONAL AND MAY ONLY NARROW A MATCH THAT THE
# MARK ALREADY MADE. A plant that leaves it unset loses
# nothing; a plant that sets it wrong loses connectors, so
# what it excluded is counted and reported.
_ok = True
if _connector_gate:
_got = _connector_component_of(doc, member, know,
_connector_read_from)
_ok = (_got is not None and
str(_got).strip().upper() ==
str(_connector_gate).strip().upper())
if _ok:
t.setdefault('connectors', []).append({
'mark': _cm, 'family': fam, 'id': eid(member),
'point': _point_of(member)})
else:
t['connector_gated_out'] = (
t.get('connector_gated_out', 0) + 1)
if fam.startswith(wwf_prefix):
t['wwf_count'] += 1
if wwf_type is None:
wwf_type = doc.GetElement(member.GetTypeId())
if wwf_type is not None:
t['wwf_mesh'] = parse_wwf(rstr(wwf_type, know, 'wwf_type.description'))['mesh']
if panel is None:
_classify_panel(t, know)
return t
t['panel_id'] = eid(panel)
ptype = doc.GetElement(panel.GetTypeId())
t['family'] = panel.Symbol.Family.Name
t['design_number'] = rstr(panel, know, 'panel.design_number')
t['length'] = rdouble(panel, know, 'panel.length')
t['width'] = rdouble(panel, know, 'panel.width')
t['thickness'] = rdouble(ptype, know, 'panel_type.thickness')
t['pcf'] = as_number(rstr(panel, know, 'panel.unit_weight'))
t['weight_lb'] = as_number(rstr(panel, know, 'panel.weight'))
# WHAT KIND OF PIECE THIS IS. Everything the classifier reads is already
# measured by the lines above -- the body family name, the type's
# thickness, and the member families counted into the BOM -- so this
# costs one dict and no extra Revit call. It is done HERE and not in the
# button because `run_all` needs it: a check a plant declares does not
# apply to this kind of piece is not run at all, and `run_all` is handed
# the ticket and nothing else.
_classify_panel(t, know)
# Put the lifters and strands into the panel's own frame so 'distance from
# the end' and 'distance from the rail' are measured along the piece
# rather than along a world axis.
#
# THE FRAME IS SOLVED ONLY WHEN SOMETHING ACTUALLY READS IT. It used to be
# gated on `interference.check` too, which made every ticket pay for the
# clash solver's geometry whether or not this piece would ever be checked
# for clashes -- a ticket check on a non-clash design still did the clash
# geometry. That is a product problem, not a wording one: 51 CO1 pieces at
# ~60 s of first-time geometry each is 45+ minutes the user is not working.
# The frame solves here for lifters/strands (Phase 4.1 needs those local
# stations); the interference reads solve inside check_member_interference,
# which is the only thing that consumes them.
want_frame = bool(t['lifters'] or t.get('strand_points') or
t.get('connectors'))
frame = _panel_frame(panel) if want_frame else None
if t['lifters']:
if frame is not None:
t['panel_extents'] = ((frame[1].X, frame[1].Y, frame[1].Z),
(frame[2].X, frame[2].Y, frame[2].Z))
for lift in t['lifters']:
lift['local'] = to_local(frame, lift['point'])
for lift in t['lifters']:
lift['point'] = None # XYZ objects do not belong in results
# THE CONNECTORS IN THE PANEL'S OWN FRAME, and their in-plane edge
# distances. Same measurement path the lifters take -- no second geometry
# reader -- and the THICKNESS AXIS IS EXCLUDED, which is not a detail:
# every pin sits at exact mid-depth, so including it would report half
# the panel thickness as an "edge distance" on every connector on every
# piece. The thickness axis is DERIVED the way `strand_layout_of` derives
# it, by which local span is closest to the panel thickness, rather than
# assumed to be an index.
if t.get('connectors'):
t['connector_edges'] = connector_edges(
panel, frame, [c['point'] for c in t['connectors']],
t.get('thickness'))
for c in t['connectors']:
c['local'] = to_local(frame, c['point'])
c['point'] = None # XYZ objects do not belong in results
if t.get('strand_points'):
points = [to_local(frame, p) for p in t['strand_points']]
t['strand_points'] = None # XYZ objects do not belong in results
points = [p for p in points if p]
if points:
t['strand_layout'] = strand_layout_of(panel, frame, points,
t.get('thickness'))
# A ticket is a union of sheets. This plant's tickets are drawn as two
# sheets -- `1 OF 2` and `2 OF 2` -- both named after the piece mark, and
# the halves carry different information: f'c / f'ci / unit weight / the
# strand note are on one, the sections on the other. Resolving a mark to a
# single sheet reads half a ticket and reports everything on the other
# half as missing -- sixty findings from that one cause on twelve pieces
# (see docs/ticket-audit-wp1-12.md). So every sheet whose name equals the
# mark is taken; parameters come from whichever carries them, placed views
# from all of them.
found = sheets_by_name.get(t['mark']) if sheets_by_name else None
if not found:
return t
if not isinstance(found, list):
found = [found] # tolerate a single-sheet mapping from callers
sheets = found
t['sheets'] = sheets
t['sheet'] = sheets[0]
t['sheet_id'] = eid(sheets[0])
# Sheet parameters, from whichever sheet carries them. The title block
# lives on sheet 1 (`1 OF 2`); a bare fallback to a winner would read the
# one that happens to win the dict.
for key, role in (('tkt_design_number', 'sheet.design_number'),
('plant', 'sheet.plant'),
('tkt_width', 'sheet.width'),
('tkt_length', 'sheet.length'),
('tkt_depth', 'sheet.depth')):
value = None
for s in sheets:
value = rstr(s, know, role)
if value not in (None, ''):
break
t[key] = value
# Placed views, from every sheet. The same view cannot be placed twice,
# but two sheets can place two different views with the same name (a
# legend copied onto each page), so dedupe by element id.
#
# Pattern legends are collected only for prefixes the knowledge file
# names: a plant that draws none (pattern_prefixes empty -- this one)
# has no legends to collect, and no legend finding may fire for a view
# the plant never draws (see knowledge.DEFAULTS.legend.pattern_prefixes).
pattern_prefixes = ['STRAND', 'REINFORCING']
if know is not None:
from vibe import knowledge as K
prefixes = K.get(know, 'legend.pattern_prefixes', []) or []
pattern_prefixes = [str(p).strip().upper().replace(' PATTERN', '')
for p in prefixes if str(p).strip()]
seen_views = set()
for s in sheets:
for vid in s.GetAllPlacedViews():
view = doc.GetElement(vid)
if view is None:
continue
if eid(view) in seen_views:
continue
seen_views.add(eid(view))
if not pattern_prefixes:
continue
up = view.Name.upper()
if any(up.startswith(p + ' PATTERN') or
up.startswith(p + ' PATTERN -')
for p in pattern_prefixes):
t['legends'].append(view)
if len(t['legends']) == 1:
legend = t['legends'][0]
t['legend_name'] = legend.Name
t['legend_text'] = _flat_text(doc, legend.Id)
# Ticket-page data, phase 2: the bill of materials and the strengths
# printed on the title block. This plant's tickets have no pattern-legend
# view at all, so the checks that used to read the legend now read what
# the ticket actually carries -- see docs/plan-ticketqc-refactor.md.
t['bom_schedules'] = _read_bom_schedules(doc, sheets)
# ROWS 5.13 / 5.14 / 5.15's OTHER HALF, landed 2026-08-31. Every read in
# it is individually guarded and every failure leaves a SENTENCE in
# `insulation_hatch_notes` rather than an exception: a gather that can
# take Ticket QC down is worse than three rows that stand down.
try:
_read_insulation_hatch(doc, t, sheets, know)
except Exception as ex:
t['insulation_hatch_notes'] = [
'reading the insulation hatches raised {0}: {1} -- nothing about '
'the solid zones or the recesses on this ticket was read, and '
'that is a defect in this tool rather than a verdict on the '
'piece'.format(type(ex).__name__, str(ex)[:200])]
t['tkt_fc'] = _sheet_param(sheets, know, 'sheet.fc')
t['tkt_fci'] = _sheet_param(sheets, know, 'sheet.fci')
t['tkt_pcf'] = _sheet_param(sheets, know, 'sheet.unit_weight')
# The strand count the ticket itself states, recovered from the BOM's
# LINEAR-FEET row by division (ticketpage.strand_count_from_linear_ft).
# The BOM row is a length, never a count -- 249 on a 24'-11" piece is
# 9.99 strands, and section A-A prints (10). Section-strand count data:
# the section print is read separately when a check asks for it.
if t.get('length'):
from vibe import ticketpage
linear = (t.get('bom_schedules') or {}).get('strand_linear_ft')
count = ticketpage.strand_count_from_linear_ft(linear, t['length'])
t['ticket_strand_count'] = count
return t
def _connector_type_or_instance(doc, member, read_from):
"""The elements a connector's identity may be read off, in order."""
out = []
for where in (read_from or ['type']):
if where == 'type':
try:
got = doc.GetElement(member.GetTypeId())
except Exception:
got = None
if got is not None:
out.append(got)
elif where == 'instance':
out.append(member)
return out
def _connector_mark_of(doc, member, know, read_from):
"""A connector's part number, off the type (or the instance).
Uses the SAME mark role every other part uses -- the plant maps it once
-- so a plant that renames its mark parameter does not have to remember
a second key here.
"""
for element in _connector_type_or_instance(doc, member, read_from):
got = _member_mark(element, know)
if got:
return str(got).strip()
return None
def _connector_component_of(doc, member, know, read_from):
for element in _connector_type_or_instance(doc, member, read_from):
got = rstr(element, know, 'member.manufacture_component')
if got:
return str(got).strip()
return None
def connector_edges(panel, frame, points, thickness_ft):
"""In-plane edge distances, in inches, one per connector. Or None.
THE THICKNESS AXIS IS EXCLUDED. Measured 2026-08-31: every pin sits at
exact mid-depth, so a three-axis minimum reports half the panel
thickness on every connector of every piece -- a number that is real,
constant, and about nothing. An "edge distance" is a distance to an EDGE
of the panel, and the two faces are not edges.
The thickness axis is DERIVED, not assumed: the local span closest to
the panel's own thickness. That is `strand_layout_of`'s idiom, and it is
used here for the same reason it exists there -- a nearly square piece
makes any shortcut pick the wrong axis.
"""
extents = concrete_extents(panel, frame)
if not extents or not points:
return None
lo, hi = extents
span = [hi[i] - lo[i] for i in (0, 1, 2)]
thick_axis = 0
for axis in (1, 2):
if thickness_ft:
if abs(span[axis] - thickness_ft) < abs(span[thick_axis] -
thickness_ft):
thick_axis = axis
elif span[axis] < span[thick_axis]:
thick_axis = axis
in_plane = [i for i in (0, 1, 2) if i != thick_axis]
out = []
for point in points:
local = to_local(frame, point)
if local is None:
out.append(None)
continue
gaps = []
for axis in in_plane:
gaps.append(local[axis] - lo[axis])
gaps.append(hi[axis] - local[axis])
out.append(round(min(gaps) * 12.0, 3))
return out
def _classify_panel(t, know):
"""Put `panel_facts` and `panel_type` on the ticket. Never raises.
THE FACTS ARE GATHERED HERE AND JUDGED IN `knowledge`. This function
knows how a ticket spells things (`thickness` in Revit's internal FEET,
`bom` keyed by family name); it knows nothing about what makes a piece
insulated, which is plant knowledge and belongs in the plant file. The
seam is `knowledge.panel_facts()`, so a second caller measuring the same
facts from somewhere else -- `liftexport`, which will need "sum both
wythes on an insulated body" -- hands over the same dict rather than
inventing its own key names.
A FAILURE HERE MAY NOT TAKE THE TICKET DOWN, and it may not look like a
classification either: the ticket gets an explicit unclassified answer
carrying the exception, so every check runs and the report says why.
"""
from vibe import knowledge as K
# WHAT KIND OF MEMBER, FROM THE MARK. Read here beside the panel type
# because `run_all` needs both and is handed the ticket and nothing
# else. The prefix itself comes from `scope.prefix_of`, so the declared
# scope and the checklist can never disagree about what a mark's prefix
# is -- see `knowledge.member_kind_of`.
t['member_kind'] = K.member_kind_of(know or {}, t.get('mark'))
thickness_ft = t.get('thickness')
facts = K.panel_facts(
body_family=t.get('family'),
thickness_in=(None if thickness_ft is None
else float(thickness_ft) * 12.0),
member_families=sorted(t.get('bom') or {}))
t['panel_facts'] = facts
try:
t['panel_type'] = K.classify_panel(know or {}, facts)
except Exception as ex:
t['panel_type'] = {
'panel_type': None, 'basis': 'error', 'verified': False,
'signals': {}, 'candidates': [], 'facts': facts,
'why': ('classifying this piece raised {0}, so it is '
'UNCLASSIFIED and every check ran on it. That is a '
'defect in the tool, not a verdict on the piece.'
.format(type(ex).__name__))}
return t['panel_type']
def _sheet_param(sheets, know, role):
"""First non-empty sheet parameter for a role, across the sheets.
The title block of a two-sheet ticket lives on sheet 1; a bare
per-sheet read would see whoever won the dict. Same shape as the
in-function loop in read_ticket, factored out for the added roles.
"""
for s in sheets:
value = rstr(s, know, role)
if value not in (None, ''):
return value
return None
def _read_bom_schedules(doc, sheets):
"""The bill-of-materials schedules placed on the ticket's sheets.
This plant's tickets have no pattern-legend view; the schedule is the
BOM table (see docs/ticket-audit-wp1-12.md). Returns a dict:
{'parts': [[qty, mark, description], ...], # PARTS_LIST rows
'strand_linear_ft': x or None, # STRAND_SCHEDULE cell
'schedules': [name, ...], # read successfully
'unreadable': [(name, why), ...]} # tried and failed
`schedules` and `unreadable` exist so a consumer can tell "this ticket
has no BOM" from "the BOM could not be read". Until 2026-08-30 every
per-schedule failure was swallowed by a bare `except Exception:
continue`, both arrived downstream as the same empty dict, and
LGD-MISSING asserted the pair of them as one FAIL -- an unreadable input
reported as a defect. See ticketpage.bom_state.
A schedule's data is stored, not generated per view, so reading it is
cheap -- unlike a Detail view, which Revit must generate on first read.
Merged cells carry their text at the span's top-left anchor, which is
where the text is read from.
The strand row is a LENGTH in feet, never a count -- 321 on a 22'-11"
piece is 14.007 by division, and the section prints (14). Dividing the
raw cell and reading it as a quantity produced a '23x error' report on
a correct ticket once (see the trap list in ticketpage.py).
"""
from Autodesk.Revit.DB import (FilteredElementCollector,
ScheduleSheetInstance)
import Autodesk.Revit.DB as DB
out = {'parts': [], 'strand_linear_ft': None,
'schedules': [], 'unreadable': []}
seen = set()
for s in sheets:
for el in FilteredElementCollector(doc, s.Id).ToElements():
if not isinstance(el, ScheduleSheetInstance):
continue
sched = doc.GetElement(el.ScheduleId)
name = ''
try:
name = str(sched.Name)
except Exception as ex:
# We could not even get a name out of it. That is OUR
# failure, and it has to be reported as one -- the schedule
# behind it may well be the ticket's BOM.
out['unreadable'].append(
('schedule id {0}'.format(
getattr(getattr(el, 'ScheduleId', None),
'IntegerValue', '?')),
'{0}: {1}'.format(type(ex).__name__, str(ex)[:80])))
continue
if not name or name in seen:
continue
seen.add(name)
up = name.upper()
if 'REVISION' in up:
continue # sheet revision record, not a BOM
try:
data = sched.GetTableData()
body = data.GetSectionData(DB.SectionType.Body)
n_rows = body.NumberOfRows
n_cols = body.NumberOfColumns
except Exception as ex:
out['unreadable'].append(
(name, '{0}: {1}'.format(type(ex).__name__,
str(ex)[:80])))
continue
# An empty table is READ, not unreadable: the schedule is on the
# sheet and it says nothing. Recorded as read so a consumer can
# say "this ticket has no BOM" and mean it.
out['schedules'].append(name)
if n_rows <= 0:
continue
# text at the top-left anchor of every merged span
text = {}
for r in range(n_rows):
for c in range(min(n_cols, 8)):
try:
merged = body.GetMergedCell(r, c)
key = (merged.Top, merged.Left)
except Exception:
key = (r, c)
if key in text:
continue
try:
text[key] = body.GetCellText(r, c)
except Exception:
text[key] = ''
if 'STRAND' in up:
# the strand schedule is one cell: linear feet. Take the
# first numeric value in the grid.
for r in range(n_rows):
for c in range(min(n_cols, 8)):
cell = text.get((r, c), '').strip().replace(',', '')
if cell and _is_numberish(cell):
try:
out['strand_linear_ft'] = float(cell)
except ValueError:
pass
break
if out['strand_linear_ft'] is not None:
break
elif 'PART' in up or 'BOM' in up:
rows = []
for r in range(n_rows):
row = [text.get((r, c), '').strip()
for c in range(min(n_cols, 8))]
if row and any(row):
rows.append(row)
# first row is the column header when it looks like one
if rows and rows[0][0].upper() in ('QTY', 'QUANTITY'):
rows = rows[1:]
out['parts'] = rows
return out
def _is_numberish(text):
return bool(re.match(r'^-?[\d.]+$', text or ''))
_SECTION_COUNT_RE = re.compile(
r'\((\d+)\)\s*\d+\s*\(E\.L\.\)|SECTION\s+A-?A[^)]*\((\d+)\)', re.I)
def section_strand_count(doc, sheets, know=None):
"""The per-piece strand count printed in the ticket's section A-A.
This plant prints it as `(n) 0010001 (E.L.)` inside its section A-A
(which the model calls a Detail view). Reading the (n) requires asking
the view for its contents, which forces Revit to generate a view nobody
has opened -- the expensive move docs/revit-bridge-context.md warns
about -- so this is only called when a check really needs the section
count as a third source (BOM row / section / model). One view per
ticket, named in knowledge.yaml (legend.section_strand_view); empty
name means do not read and the two-way BOM-vs-model check stands alone.
Returns an int, or None when the view is not configured, not placed,
or carries no readable count.
"""
if know is not None:
from vibe import knowledge as K
view_name = K.get(know, 'legend.section_strand_view', '') or ''
else:
view_name = ''
if not view_name:
return None
for s in sheets:
for vid in s.GetAllPlacedViews():
view = doc.GetElement(vid)
if view is None or view.Name != view_name:
continue
text = _flat_text(doc, vid)
m = _SECTION_COUNT_RE.search(text)
if m:
n = m.group(1) or m.group(2)
if n and n.isdigit():
return int(n)
return None # the view exists but has no readable count
return None
# --------------------------------------------------------------------- checks
class Finding(object):
"""A finding, and WHERE IT CAME FROM.
`source` is provenance, and it is on the object because the two kinds of
finding fail differently: a wrong COMPUTED answer is a bug -- reproducible,
findable, fixable -- and a wrong MODEL-READ one is a hallucination, which
may not reproduce and has nothing to fix. A reader who cannot tell them
apart trusts the second as much as the first.
'computed' -- two values existed and code compared them. The default,
because everything that was here before this field was
computed, and a finding that does not say otherwise is.
'model-read' -- a small model looked at a rendered view and said this.
Never a pass, never a fail; see vibe.ticketvision and
docs/ticket-checklist.md section 8bis.
Provenance is carried in four independent places (this field, the `AI-`
code prefix, a separate list, a separate report section) because one of
them will eventually be dropped by an edit and the other three still have
to hold.
"""
def __init__(self, severity, code, message, source='computed'):
self.severity = severity # 'FAIL' | 'WARN' | 'OK'
self.code = code
self.message = message
self.source = source # 'computed' | 'model-read'
def __repr__(self):
if self.source != 'computed':
return '{0} [{1}] ({2}) {3}'.format(self.severity, self.code,
self.source, self.message)
return '{0} [{1}] {2}'.format(self.severity, self.code, self.message)
def spec_to_intent(spec):
"""Map a reviewed design spec (the JSON on disk) onto the shape the checks
already speak. Keeping one internal shape means the regex reader and the
extracted spec are interchangeable, and either can be swapped in.
"""
from vibe.designspec import get_path
intent = {
'name': spec.get('design_number'),
'source': 'spec',
'fc': get_path(spec, 'concrete.fc_psi'),
'fci': get_path(spec, 'concrete.fci_psi'),
'pcf': get_path(spec, 'concrete.unit_weight_pcf'),
'mild_ksi': get_path(spec, 'reinforcing.mild_grade_ksi'),
'wwf_mesh': None,
'wwf_layers': get_path(spec, 'reinforcing.wwf.layers'),
'wwf_per_face': bool(get_path(spec, 'reinforcing.wwf.per_face')),
'thickness_in': spec.get('thickness_in'),
'strands': [],
'envelope': {},
'notes': spec.get('notes') or [],
'unresolved': spec.get('unresolved') or [],
# The same vocabulary the regex reader runs, over the same field, so
# the two intents stay interchangeable -- which is the whole reason
# this function exists.
'requirements': ticketpage.classify_summary_lines(
spec.get('unresolved') or [])[0],
}
mesh = get_path(spec, 'reinforcing.wwf.mesh')
if mesh:
intent['wwf_mesh'] = re.sub(r'\s+', '', str(mesh).upper()).replace('.0', '')
for group in get_path(spec, 'reinforcing.strand_groups', []) or []:
intent['strands'].append({
'qty': group.get('qty'),
'dia': group.get('dia_in'),
'ksi': group.get('grade_ksi'),
'it': group.get('it_lb'),
'role': group.get('role'),
})
for dim in ('length', 'width'):
for bound, key in (('min', 'MIN'), ('max', 'MAX')):
feet = get_path(spec, 'envelope.{0}.{1}_ft'.format(dim, bound))
if feet is None:
continue
text = (get_path(spec, 'envelope.{0}.{1}_text'.format(dim, bound))
or fmt_feet(feet))
intent['envelope']['{0}_{1}'.format(key, dim.upper())] = (feet, text)
return intent
def check_ticket(t, ds, strict_printed_qty=True, check_dimension_params=False,
know=None, spec=None, want_log=False):
"""Compare one ticket against its design intent.
strict_printed_qty -- fail when the strand count printed on the legend
differs from the number actually modelled. Set
False if the printed '(n)' is a typical maximum.
check_dimension_params -- also require the ticket's printed length,
width and depth to equal the modelled ones.
Off by default: on stemmed pieces the printed
length is nominal and runs a fraction over the
modelled length by design.
know -- loaded knowledge dict. When present its
settings override the two flags above, and the
computed checks registered in ticketchecks run.
"""
found = []
def add(sev, code, msg):
found.append(Finding(sev, code, msg))
if know is not None:
from vibe import knowledge as K
strict_printed_qty = K.get(
know, 'legend.printed_strand_count_is_per_piece', strict_printed_qty)
check_dimension_params = K.get(
know, 'sheet.check_tkt_dimensions', check_dimension_params)
if t.get('panel_id') is None:
add('FAIL', 'NO-PANEL', 'assembly has no Structural Framing member')
return found
if t.get('sheet') is None:
add('FAIL', 'NO-TICKET', 'no sheet named "{0}"'.format(t['mark']))
return found
_check_design_number(t, add, know)
_check_legend_identity(t, add, know)
legend = parse_concrete(t.get('legend_text'))
legend_wwf = parse_wwf(t.get('legend_text'))
# -- concrete: summary text vs what the ticket really prints ----------
# This plant's tickets have no pattern-legend view; f'c / f'ci live on
# the title block as sheet parameters, mapped through the sheet.fc /
# sheet.fci roles (see docs/ticket-audit-wp1-12.md). A role that is not
# mapped means the plant does not print that value on its tickets -- say
# so rather than treating it as a missing value.
fc_ticket = _as_number_or_none(t.get('tkt_fc'))
fci_ticket = _as_number_or_none(t.get('tkt_fci'))
pcf_ticket = _as_number_or_none(t.get('tkt_pcf'))
_compare(add, 'FC', "f'c", ds.get('fc'), fc_ticket, ' psi')
_compare(add, 'FCI', "f'ci", ds.get('fci'), fci_ticket, ' psi')
# -- unit weight: summary vs ticket, and vs the panel parameter -----
# PCF-NOTE: only meaningful when the ticket carries a unit weight.
# This plant's title block does not (no sheet.unit_weight mapping), so
# fail nothing for it -- the model-side comparison below still runs.
if ds.get('pcf') is not None and pcf_ticket is None:
add('OK', 'PCF-NOTE',
'unit weight is not printed on this plant\'s tickets; '
'checked against the model (PCF-MODEL)')
else:
_compare(add, 'PCF-NOTE', 'unit weight', ds.get('pcf'),
pcf_ticket, ' pcf')
_compare(add, 'PCF-MODEL', 'unit weight (model)', ds.get('pcf'), t.get('pcf'), ' pcf')
_check_strands(t, ds, add, strict_printed_qty)
_check_wwf(t, ds, legend_wwf, add)
_check_envelope(t, ds, add)
if check_dimension_params:
_check_sheet_dimensions(t, add, know)
run_log = None
if know is not None:
from vibe import ticketchecks
run_findings, run_log = ticketchecks.run_all(t, ds, know, spec)
found.extend(run_findings)
if want_log:
# Both paths return the same shape: (found, log). A path that
# never reached the checks returns (found, None); callers treat it
# as or []. (cb: 573ad56 left want_log=True returning a tuple only
# in the know-is-not-None branch -- the other returned a bare list
# and the button crashed unpacking it.)
return found, run_log
return found
def _check_design_number(t, add, know=None):
from vibe import designspec
sheet_dsn = normalise_design(t.get('tkt_design_number'))
panel_dsn = normalise_design(t.get('design_number'))
# Compared as reduced keys: a piece stamped PNL1 and a ticket stamped
# PNL1.0 name the same design, and only the page suffix differs.
sheet_key = designspec.design_key(sheet_dsn, know)
panel_key = designspec.design_key(panel_dsn, know)
if sheet_key != panel_key:
add('FAIL', 'DSN-MATCH',
'sheet {0}={1} but panel {2}={3}'
.format(role_label(know, 'sheet.design_number'),
t.get('tkt_design_number'),
role_label(know, 'panel.design_number'),
t.get('design_number')))
elif sheet_dsn != panel_dsn:
add('WARN', 'DSN-SUFFIX',
'same design {0}, but written {1} on the ticket and {2} on the panel'
.format(panel_key, t.get('tkt_design_number'), t.get('design_number')))
else:
add('OK', 'DSN-MATCH', 'design number {0}'.format(t.get('design_number')))
# A piece mark whose prefix disagrees with its design number is worth a
# look -- a PNL piece pointed at an SWA design, say.
mark_prefix = re.match(r'^([A-Z]+)', t['mark'] or '')
dsn_prefix = re.match(r'^([A-Z]+)', panel_dsn or '')
if mark_prefix and dsn_prefix and mark_prefix.group(1) != dsn_prefix.group(1):
add('FAIL', 'DSN-PREFIX',
'piece {0} carries design number {1} -- different piece type'
.format(t['mark'], t.get('design_number')))
def _check_legend_identity(t, add, know=None):
"""The pattern legend: present, unique, for this design, at this width.
Phase 2: a plant that draws no pattern legend (pattern_prefixes empty in
knowledge.yaml -- this one, 0 of 204 sheets) is not failing a ticket for
a view it never draws. Plants that do use a legend set pattern_prefixes
and get the full identity checks below.
RETIRED 2026-08-30: this function used to pass judgement on the BOM when
no legend prefixes were configured -- an OK when the BOM had rows and a
`LGD-MISSING` FAIL when it did not. All three legs of that were wrong.
* No checklist item owns `LGD-*`, so neither verdict ever reached the
checklist table. It was an orphan finding nobody could act on.
* It could not tell an absent BOM from an unreadable one --
`_read_bom_schedules` swallowed every per-schedule failure -- and
called both a FAIL. An unreadable input reported as a defect is
exactly backwards.
* Section 8000 of the manufacturer's manual (p713) says "Label all
rebar - no description under material list", so an empty BOM
description is CORRECT by the plant standard, not a defect.
What replaces it is not silence. Every check that actually wants the BOM
-- `ticketchecks.check_bom_strand_count`, `check_bom_members` -- now asks
`ticketpage.bom_state` and reports its OWN NOT RUN with its own reason,
so a BOM-less ticket is named by everything that wanted one and
"absent" never reads the same as "could not be read".
"""
from vibe import designspec
from vibe import knowledge as K
if know is not None:
prefixes = K.get(know, 'legend.pattern_prefixes', []) or []
else:
prefixes = ['STRAND PATTERN', 'REINFORCING PATTERN']
if not prefixes:
# Nothing to say. The plant draws no pattern legend, and the BOM is
# not this function's business -- see the docstring. The BOM checks
# speak for themselves.
return
legends = t.get('legends') or []
if not legends:
# This one survives the retirement above, and it is not the same
# finding: the plant has DECLARED that it draws pattern legends
# (pattern_prefixes is non-empty) and this ticket carries none. That
# is a real absence of a thing the plant says it draws, it reads no
# input we could fail to read, and no BOM is involved. Deleting it
# would leave a legend-drawing plant with nothing said at all.
add('FAIL', 'LGD-MISSING', 'no strand/reinforcing pattern legend on the ticket')
return
if len(legends) > 1:
add('FAIL', 'LGD-DUP', '{0} pattern legends on one ticket: {1}'
.format(len(legends), ' | '.join([v.Name for v in legends])))
return
name = t['legend_name']
m = re.match(r"^\s*(?:STRAND|REINFORCING)\s+PATTERN\s*-\s*([^\s-]+)\s*-\s*"
r"([A-Z0-9]+)\s*-?\s*(?:\(([^)]+)\))?", name, re.I)
if not m:
add('WARN', 'LGD-NAME', 'legend name not in the usual format: ' + name)
return
lgd_design, _pattern, lgd_width = m.group(1), m.group(2), m.group(3)
# Compared as reduced keys, like everywhere else: a legend named for
# WP1.0 belongs to the piece stamped WP1: those are the packet's cover
# sheet and the design it documents, not two designs.
if designspec.design_key(lgd_design, know) != designspec.design_key(
t.get('design_number'), know):
add('FAIL', 'LGD-DSN', 'legend is for {0} but the piece is {1} ({2})'
.format(lgd_design, t.get('design_number'), name))
elif lgd_design.strip().upper() != normalise_design(lgd_design):
# right design, but the legend's own text is malformed -- a doubled
# dot, say. Worth saying; not worth failing.
add('WARN', 'LGD-TYPO', 'legend name misspells the design number as "{0}"'
.format(lgd_design))
if lgd_width:
width = parse_feet(lgd_width)
if width is not None and t.get('width') is not None:
if abs(width - t['width']) > DIM_TOL:
add('FAIL', 'LGD-WIDTH',
'legend is drawn for {0} but the panel is {1} ({2})'
.format(lgd_width, fmt_feet(t['width']), name))
else:
add('OK', 'LGD-WIDTH', 'legend width matches panel ({0})'.format(lgd_width))
def _check_strands(t, ds, add, strict):
summary_strands = ds.get('strands') or []
modelled = t.get('strand_qty') or 0
by_dia = t.get('strand_by_dia') or {}
# Phase 2: this plant's tickets print the piece's strand count inside
# section A-A -- `(n) 0010001 (E.L.)` -- and the IMPLIED count from the
# BOM's linear-feet row is the same number. There is no pattern legend,
# so STR-NOTE's old job -- summary call-out vs legend call-out -- has no
# ticket side left to compare against: the summary's count is the design
# FAMILY's figure, never this piece's (audit §2b), and comparing a
# family maximum to a piece is a guaranteed false finding. That is what
# printed_strand_count_is_per_piece (False here) already says.
#
# The one comparison that means anything is STR-QTY, the printed count
# vs what is actually in the assembly -- the ticket printed THIS piece's
# row, so the ticket is held against the model. str-QTY alone is also a
# clean single comparison: no duplicate codes for one fact.
ticket_count = t.get('ticket_strand_count')
if ticket_count is not None:
if ticket_count != modelled:
add('FAIL' if strict else 'WARN', 'STR-QTY',
'ticket prints {0} strands but {1} are modelled'
.format(ticket_count, modelled))
else:
add('OK', 'STR-QTY', '{0} strands (ticket = model)'.format(modelled))
elif summary_strands:
printed = sum([g['qty'] for g in summary_strands])
if printed != modelled:
add('FAIL' if strict else 'WARN', 'STR-QTY',
'design summary prints {0} strands but {1} are modelled'
.format(printed, modelled))
else:
add('OK', 'STR-QTY', '{0} strands (summary = model)'.format(modelled))
# diameters: every diameter called for must exist in the assembly.
# This comparison is per-size, not per-count: the summary's 14 strands
# at 1/2" are the design family's figure, but the SIZE the design calls
# for still has to be a size the piece actually carries. Count-vs-count
# comparisons are held against the ticket (above), not the summary --
# see audit §2b for why.
if summary_strands:
wanted_dias = set([g['dia'] for g in summary_strands if g['dia']])
have_dias = set([d for d in by_dia if d])
missing = wanted_dias - have_dias
extra = have_dias - wanted_dias
if missing:
add('FAIL', 'STR-DIA',
'calls for {0} strand, none of that size in the assembly (has {1})'
.format(', '.join(sorted([fmt_inches(d) for d in missing])),
', '.join(sorted([fmt_inches(d) for d in have_dias])) or 'none'))
elif extra:
add('WARN', 'STR-DIA-EXTRA',
'assembly also contains {0} strand, not called for'
.format(', '.join(sorted([fmt_inches(d) for d in extra]))))
elif wanted_dias:
add('OK', 'STR-DIA', 'strand diameter {0}'
.format(', '.join(sorted([fmt_inches(d) for d in wanted_dias]))))
def _check_wwf(t, ds, legend_wwf, add):
want = ds.get('wwf_mesh')
printed = legend_wwf.get('mesh')
modelled = t.get('wwf_mesh')
if want and printed and want != printed:
add('FAIL', 'WWF-NOTE', 'W.W.F. summary {0} but ticket note {1}'
.format(want, printed))
if want and modelled and want != modelled:
add('FAIL', 'WWF-MESH', 'W.W.F. summary {0} but modelled {1}'
.format(want, modelled))
elif want and not modelled:
add('FAIL', 'WWF-MISSING',
'summary calls for {0} W.W.F. but the assembly has none'.format(want))
elif want and modelled == want:
add('OK', 'WWF-MESH', 'W.W.F. {0}'.format(modelled))
layers = ds.get('wwf_layers')
if layers is not None and want:
expected = layers * 2 if ds.get('wwf_per_face') else layers
if t.get('wwf_count') != expected:
add('FAIL', 'WWF-QTY', '{0} layer{1} means {2} W.W.F. member(s), found {3}'
.format(layers, ' per face' if ds.get('wwf_per_face') else '',
expected, t.get('wwf_count')))
else:
add('OK', 'WWF-QTY', '{0} W.W.F. member(s)'.format(t['wwf_count']))
def _check_envelope(t, ds, add):
envelope = ds.get('envelope') or {}
for key, label in (('LENGTH', 'length'), ('WIDTH', 'width')):
actual = t.get(label)
if actual is None:
continue
upper = envelope.get('MAX_' + key)
lower = envelope.get('MIN_' + key)
if upper and actual > upper[0] + DIM_TOL:
add('FAIL', 'SIZE-MAX-' + key, '{0} {1} exceeds the design maximum {2}'
.format(label, fmt_feet(actual), upper[1]))
elif lower and actual < lower[0] - DIM_TOL:
add('FAIL', 'SIZE-MIN-' + key, '{0} {1} is below the design minimum {2}'
.format(label, fmt_feet(actual), lower[1]))
elif upper and lower:
add('OK', 'SIZE-' + key, '{0} {1} within {2} .. {3}'
.format(label, fmt_feet(actual), lower[1], upper[1]))
def _check_sheet_dimensions(t, add, know=None):
for pname, key, role in (('tkt_width', 'width', 'sheet.width'),
('tkt_length', 'length', 'sheet.length'),
('tkt_depth', 'thickness', 'sheet.depth')):
label = role_label(know, role)
stated, actual = t.get(pname), t.get(key)
if not stated or actual is None:
continue
parsed = parse_feet(stated)
if parsed is None:
continue
if abs(parsed - actual) > DIM_TOL:
add('WARN', 'TKT-' + label, 'sheet {0}={1} but model {2}'
.format(label, stated, fmt_feet(actual)))
def _as_number_or_none(value):
"""A sheet parameter read as a number, or None when it cannot be read.
Title-block parameters come back as strings ('6000'); the knowledge
file maps the role and the string is this plant's print. Compare the
number, not the characters.
"""
if value in (None, ''):
return None
return as_number(value)
def _compare(add, code, label, want, got, unit):
if want is None:
return
if got is None:
add('WARN', code, '{0} {1}{2} in the summary, not found on the ticket'
.format(label, _fmt_num(want), unit))
return
if abs(want - got) > 0.5:
add('FAIL', code, '{0} summary {1}{3} but ticket {2}{3}'
.format(label, _fmt_num(want), _fmt_num(got), unit))
else:
add('OK', code, '{0} {1}{2}'.format(label, _fmt_num(want), unit))
# ------------------------------------------------------------------- driver
def collect_context(doc):
"""Index the drafting views and sheets once, so runs stay cheap.
Sheets are mapped name -> LIST of sheets: a ticket is drawn across two
sheets numbered `1 OF 2` and `2 OF 2`, both named after the piece mark,
and either half alone reads as every value on the other half missing.
SUMMARIES KEY CONVENTION (write this down -- it has bitten three
times): `summaries` is keyed by the DraftingView's NAME (view.Name),
NOT by the design number. A summary that belongs to a design is
found through design_key(view.Name) -- measured names include
'WP1.0 8" SOLID WALL' and 'WP1.A ABOVE AND BELOW OPENINGS', where
design_key reduces the prefix to the design. Never look up
summaries.get(design): design is 'WP1' and the key is 'WP1.0 ...',
so the lookup silently misses. Use _summary_view_for().
"""
summaries, sheets = {}, {}
for view in FilteredElementCollector(doc).OfClass(View).ToElements():
if view.ViewType == ViewType.DraftingView:
summaries[view.Name] = view
for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements():
sheets.setdefault(sheet.Name, []).append(sheet)
return summaries, sheets
def _summary_view_for(design, summaries, know=None):
"""The drafting view whose name names this design, or None.
summaries is keyed by view NAME (see collect_context). A view belongs
to a design when design_key of its name reduces to that design --
'WP1.0 8" SOLID WALL' -> WP1. This replaces the silently-missing
summaries.get(design): the third time the key convention bit us.
"""
if not summaries:
return None
from vibe import designspec
for name, view in summaries.items():
if designspec.design_key(name, know) == design:
return view
return None
def assemblies_by_design(doc, know=None):
"""Map design key -> [AssemblyInstance], read off the framing member.
know supplies the parameter name behind the 'panel.design_number' role.
Without it nothing groups, which Self Check reports as an unmapped role
rather than as an empty model.
Keyed by the REDUCED design number (designspec.design_key), not the raw
parameter: a piece stamped PNL1 and design sheet PNL1.0 are the same
design, and the packet page suffix a piece happens to carry must not split
it into two designs that each fail to find the other.
"""
from vibe import designspec
out = {}
collector = (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements())
for asm in collector:
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
value = designspec.design_key(
rstr(member, know, 'panel.design_number'), know)
if value:
out.setdefault(value, []).append(asm)
break
return out
def run(doc, design_numbers=None, strict_printed_qty=True,
check_dimension_params=False, specs=None, know=None,
allow_regex_fallback=True):
"""Check every ticket for the given design numbers.
specs -- {design number: reviewed spec dict}. A design with a spec is
checked against it; one without falls back to reading the drafting view
with regexes, which is weaker and says so in the result.
Returns (results, missing). Each result carries design / intent / ticket /
findings / spec_source, and missing lists designs with pieces but nothing
to check them against.
"""
summaries, sheets = collect_context(doc)
grouped = assemblies_by_design(doc, know=know)
specs = specs or {}
wanted = ([d for d in design_numbers if d in grouped] if design_numbers
else sorted(grouped.keys()))
results, missing = [], []
for design in wanted:
spec = specs.get(design)
if spec is not None:
intent = spec_to_intent(spec)
source = 'spec'
else:
view = _summary_view_for(design, summaries, know)
if view is None or not allow_regex_fallback:
missing.append(design)
continue
intent = parse_design_summary(doc, view)
intent['source'] = 'regex'
source = 'regex'
for asm in sorted(grouped[design], key=lambda a: a.AssemblyTypeName):
ticket = read_ticket(doc, asm, sheets, know=know)
_findings, _log = check_ticket(
ticket, intent,
strict_printed_qty=strict_printed_qty,
check_dimension_params=check_dimension_params,
know=know, spec=spec, want_log=True)
results.append({
'design': design,
'intent': intent,
'summary': intent, # kept for older callers
'spec_source': source,
'ticket': ticket,
'findings': _findings,
'run_log': _log,
})
return results, missing
def _missing(asm, know, why):
"""An assembly that could not be checked, with the reason visible.
'could not be checked' is a result, not an absence (Claude 08-28:
the third shape of 'silence read as nothing happened' -- entry layer
was dropping assemblies with no explanation). mark + id let the user
find it; why says what made it uncheckable.
"""
return {'mark': name_of(asm), 'id': eid(asm), 'why': why}
def run_for_assemblies(doc, assemblies, strict_printed_qty=True,
check_dimension_params=False, specs=None, know=None,
allow_regex_fallback=True, context=None):
"""Check the given assemblies, resolving each piece's design intent
per piece.
The design-number entry point (`run`) walks whole designs, assuming every
piece shares one summary. The selection-driven button (Phase 3) checks
exactly the pieces the user picked, which may span designs -- so each
assembly's design number is read off its own panel and looked up against
the specs (or the regex summary) on its own.
`context` -- the (summaries, sheets) pair from one `collect_context`
call, so a caller ticking many pieces (the selection-driven button) can
collect once and run each piece with a live progress bar instead of
re-scanning all 1105 sheets per piece.
Returns (results, missing) in the same shape as `run`: each result
carries design / intent / ticket / findings / spec_source. `missing`
lists assemblies whose design has nothing to check them against, when
regex fallback is refused. Assemblies with no readable design number are
reported as missing rather than grouped under a guessed design.
"""
from vibe import designspec
if context is None:
summaries, sheets = collect_context(doc)
else:
summaries, sheets = context
specs = specs or {}
results, missing = [], []
for asm in assemblies:
# The panel's own design number, reduced the same way everywhere.
design = None
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
design = designspec.design_key(
rstr(member, know, 'panel.design_number'), know)
break
if not design:
missing.append(_missing(asm, know,
'no design number on a framing member'))
continue
spec = specs.get(design)
if spec is not None:
intent = spec_to_intent(spec)
source = 'spec'
else:
view = _summary_view_for(design, summaries, know)
if view is None or not allow_regex_fallback:
missing.append(_missing(
asm, know,
('no spec for {0} and no matching design-summary view '
'(regex fallback {1})'
.format(design, 'on' if allow_regex_fallback else 'off'))))
continue
intent = parse_design_summary(doc, view)
intent['source'] = 'regex'
source = 'regex'
ticket = read_ticket(doc, asm, sheets, know=know)
_findings, _log = check_ticket(
ticket, intent,
strict_printed_qty=strict_printed_qty,
check_dimension_params=check_dimension_params,
know=know, spec=spec, want_log=True)
results.append({
'design': design,
'intent': intent,
'summary': intent, # same shape as run(), for callers
'spec_source': source,
'ticket': ticket,
'findings': _findings,
'run_log': _log,
})
return results, missing
# ============================================================================
# THE EVIDENCE FILE
# ============================================================================
#
# Migration step 1 (docs/architecture-migration.md section 4). One ticket,
# rendered as a JSON document that carries no live Revit object anywhere in it.
#
# NOTHING IN THIS EXTENSION READS THIS FILE. That is the whole point of the
# step: a file nobody reads cannot change what a press reports, so the write
# ships on its own and the judgement stays exactly where it is. `read_ticket`
# is not touched, `t` is not touched, and `ticket_to_json` returns a NEW dict
# -- it never mutates the ticket it was handed. The reader arrives in step 2.
#
# `load_evidence` below IS shipped now even though the button never calls it,
# because rule 1 of the contract is a property of the pair, not of the writer:
# a file format whose reader has never been written has not yet proved it
# discards a mismatch whole.
#
# THE FOUR RULES (section 2), and where each one lives in the code below:
#
# 1. DISCARD WHOLE ON MISMATCH -- `load_evidence`, copied from
# `reinforcecore.load()`. A file whose `schema` is not `SCHEMA` is thrown
# away entirely, never migrated and never partially adopted.
# 2. NO LIVE OBJECTS -- `_plain` is the gate. It admits str / int / float /
# bool / None / list / dict and NOTHING else, so a live `Document`,
# `View`, `XYZ` or `Transform` cannot reach the file by accident. The two
# holdouts the plan names are handled explicitly: `t['doc']` becomes
# `doc_key` plus the model's path stem, `t['legends']` becomes
# `[{id, name, text}]` off `_flat_text`. A THIRD holdout the plan did not
# name is handled the same way: `t['sheets']` / `t['sheet']` are live
# `ViewSheet` objects and thirteen checks read `.Name` and `.Id` off them.
# 3. ABSENCE IS NOT A VALUE -- every field goes through `put()`, and `put()`
# will not write a null without also writing a sentence into `missing`
# saying why. A key that is simply absent reads downstream as "nothing to
# check", which is the silence-looks-like-a-pass failure the whole
# checklist exists to prevent. `unserialised_keys` is the same rule
# turned on this writer: a ticket key it does not know about is named
# rather than dropped in silence.
# 4. PROVENANCE TRAVELS -- the envelope carries `written_at`, `written_by`,
# the build, `doc_key`, `knowledge.sources(doc)` and, per ticket, which
# design and whether its intent came from a reviewed `spec` or from the
# weaker `regex` reading. A finding whose provenance was left behind on
# the Revit side cannot be re-judged from the file.
#
# THE KEY NAMES ARE THE TICKET'S OWN. `mark`, `length`, `tkt_fc`,
# `bom_schedules` and the rest keep the spelling `check_ticket` already reads,
# so the step-2 runner can feed the file straight back to the judgement half
# instead of translating. Only the keys that HAD to change did.
#
# SIZE: measure it on a real press and put the number in section 6 of the
# migration doc. The budget there is 30-80 KB per piece.
# BUMPED 'ticketqc/1' -> 'ticketqc/2' on 2026-08-31: the document gained
# `panel_facts` and `panel_type`, the measured facts a piece is classified
# from and what they classified it as. Rule 1 of the contract is discard
# WHOLE on mismatch, so a shape change is a bump and never a migration --
# and this one is free, because nothing reads the file yet (migration step 2).
SCHEMA = 'ticketqc/2'
EVIDENCE_FOLDER = 'evidence'
_INF = float('inf')
try: # IronPython 2.7 / CPython 2
_INT_TYPES = (int, long) # noqa: F821
except NameError: # Python 3
_INT_TYPES = (int,)
class _NotPlain(Exception):
"""A value that cannot go into a JSON document, and the reason why."""
def _plain(value, where='value'):
"""`value` as JSON-safe data, or raise `_NotPlain` saying what it is.
THE ONLY GATE. Nothing reaches the evidence file without passing through
here, and it admits exactly six things: text, whole numbers, finite
floats, booleans, None, and lists/dicts of those. A live Revit object has
no route in -- it raises, the caller turns that into an explicit null with
the exception's sentence as the reason, and the file says so out loud
rather than carrying an `<Autodesk.Revit.DB.View object>` repr.
Non-finite floats are refused too. `json.dumps` writes `NaN` and
`Infinity`, which are not JSON and which a strict reader on the far side
of the boundary will reject -- a nan that arrived from a degenerate
geometry read is a could-not-measure, and it must land as one.
"""
if value is None or isinstance(value, bool):
return value
if isinstance(value, _INT_TYPES):
return value
if isinstance(value, float):
if not (value == value and -_INF < value < _INF):
raise _NotPlain('{0} is not a finite number ({1})'
.format(where, repr(value)))
return value
if isinstance(value, compat.string_types):
return compat.as_text(value)
if isinstance(value, (list, tuple)):
return [_plain(v, '{0}[{1}]'.format(where, i))
for i, v in enumerate(value)]
if isinstance(value, dict):
out = {}
for key in value:
if not isinstance(key, compat.string_types):
raise _NotPlain('{0} is keyed by {1}, not by text'
.format(where, type(key).__name__))
out[compat.as_text(key)] = _plain(value[key],
'{0}.{1}'.format(where, key))
return out
raise _NotPlain('{0} is a live {1}, which cannot cross a file boundary'
.format(where, type(value).__name__))
def _soft(value, where):
"""(plain value, reason) -- a refusal becomes a reason, never an error."""
try:
return _plain(value, where), None
except _NotPlain as exc:
return None, str(exc)
def _doc_identity(doc):
"""(doc_key, model_stem, reason) for the document a ticket was read from.
This is what `t['doc']` becomes. `doc_key` is `reinforceindex.doc_key`
called rather than reimplemented, so "which model is this" has one answer
in this codebase. The stem is the model file's basename without its
extension -- enough to tell two saved copies apart, and not an absolute
path, which is what `tools/scrub.py` would call a leak if one of these
files were ever committed.
"""
if doc is None:
return (None, None,
'the ticket carries no document, so the model it was read '
'from cannot be named')
try:
from vibe import reinforceindex
key = reinforceindex.doc_key(doc) or None
except Exception as exc: # pylint: disable=W0703
return (None, None,
'the document would not give a title ({0})'
.format(type(exc).__name__))
stem = None
try:
path = doc.PathName or ''
except Exception: # pylint: disable=W0703
path = ''
if path:
stem = os.path.splitext(os.path.basename(path))[0] or None
reason = None
if key is None and stem is None:
reason = ('the document has neither a title nor a path -- it has '
'never been saved')
return key, stem, reason
def _legend_rows(doc, legends):
"""`t['legends']` -- live `View` objects -- as `[{id, name, text}]`.
The text is flattened here rather than on the far side because
`_flat_text` is a Revit read: it collects the view's text notes. An empty
string is a legend that was read and says nothing; `text: null` with a
`reason` beside it is a legend that could not be read at all, and those
are not the same answer.
"""
rows = []
for view in (legends or []):
row = {'id': None, 'name': None, 'text': None, 'reason': None}
try:
row['id'] = eid(view)
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the legend view has no readable id ({0})'
.format(type(exc).__name__))
try:
row['name'] = compat.as_text(view.Name)
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the legend view has no readable name ({0})'
.format(type(exc).__name__))
if doc is None:
row['reason'] = ('no document on the ticket, so the legend text '
'could not be flattened')
else:
try:
row['text'] = compat.as_text(_flat_text(doc, view.Id))
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the legend text could not be read ({0})'
.format(type(exc).__name__))
rows.append(row)
return rows
def _sheet_rows(sheets):
"""`t['sheets']` -- live `ViewSheet` objects -- as `[{id, number, name}]`.
NOT NAMED IN THE PLAN, AND IT HAD TO BE. Step 1 lists `t['doc']` and
`t['legends']` as the two holdouts and says everything else in
`read_ticket` is already strings, numbers and dicts. It is not:
`t['sheets']` is a list of live `ViewSheet` and `t['sheet']` is one of
them, and `ticketchecks` reads `.Name`, `.Id` and the sheet number off
them in thirteen places. Both are recorded here; `sheet_id` keeps the
ticket's own spelling for the first of them.
"""
rows = []
for sheet in (sheets or []):
row = {'id': None, 'number': None, 'name': None, 'reason': None}
try:
row['id'] = eid(sheet)
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the sheet has no readable id ({0})'
.format(type(exc).__name__))
try:
row['name'] = compat.as_text(sheet.Name)
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the sheet has no readable name ({0})'
.format(type(exc).__name__))
try:
row['number'] = compat.as_text(sheet.SheetNumber)
except Exception as exc: # pylint: disable=W0703
row['reason'] = ('the sheet has no readable number ({0})'
.format(type(exc).__name__))
rows.append(row)
return rows
def _strand_by_dia_rows(by_dia):
"""`{diameter: count}` as `[{diameter_in, count}]`.
A dict keyed by a float is the one shape JSON quietly mangles: the keys
become strings on the way out and never come back as numbers, and a
`None` key -- which `strand_diameter` really can produce, when neither
the family name nor the parameter says a diameter -- becomes the literal
string "null". A list of records keeps the diameter a number, keeps
could-not-tell as an explicit null with a reason beside it, and survives
the round trip.
"""
rows = []
for dia in sorted(by_dia or {}, key=lambda d: (d is None, d)):
value, why = _soft(dia, 'strand diameter')
if value is None:
why = why or ('neither the strand family name nor its diameter '
'parameter said what size this strand is')
rows.append({'diameter_in': value,
'count': (by_dia or {}).get(dia),
'reason': why})
return rows
# Every ticket key this writer knows about. A key on the ticket that is not
# here is NOT dropped in silence -- it is named in `unserialised_keys`, which
# is rule 3 applied to the writer itself. The check caches `ticketchecks`
# leaves behind (`frame`, `member_boxes`, `_debond_requirement`, ...) land
# there, and that list is the worklist for step 4.
_TICKET_KEYS_CONSUMED = frozenset([
'mark', 'asm_id', 'doc', 'bom', 'legends', 'legend_name', 'legend_text',
'strand_by_dia', 'strand_qty', 'strand_points', 'strand_layout',
'wwf_count', 'wwf_mesh', 'sheet', 'sheets', 'sheet_id', 'panel_id',
'lifters', 'weight_lb', 'ticket_strand_count', 'family', 'design_number',
'length', 'width', 'thickness', 'pcf', 'panel_extents',
'tkt_design_number', 'plant', 'tkt_width', 'tkt_length', 'tkt_depth',
'bom_schedules', 'tkt_fc', 'tkt_fci', 'tkt_pcf',
'panel_facts', 'panel_type', 'member_kind',
'connectors', 'connector_edges', 'connector_gated_out',
'solid_zones', 'lifters_without_zone', 'lifters_unread',
])
def ticket_to_json(t, design=None, spec_source=None, intent=None):
"""One ticket as a JSON-safe document. Never mutates `t`.
`design` / `spec_source` / `intent` are the run's own provenance for this
piece -- which design number it was judged as, whether the intent came
from a reviewed spec or from the weaker regex reading of the summary
view, and the intent itself. They live on the run result rather than on
the ticket, and rule 4 is explicit that a file which left them behind
cannot be re-judged.
Every null in the returned document has a sentence about it in
`missing`. That is not decoration: on the far side of a file boundary a
key that is null and a key that is absent look identical, and both look
exactly like "there was nothing to check here".
"""
t = t or {}
out = {}
missing = {}
def put(key, value, reason):
"""One field, with rule 3 enforced rather than remembered."""
plain, why = _soft(value, key)
out[key] = plain
if plain is None:
missing[key] = why or reason
has_panel = t.get('panel_id') is not None
sheets = t.get('sheets') or []
no_panel = ('no Structural Framing member on this assembly, so the panel '
'and everything measured from it was never read')
no_sheet = ('no sheet is named after this mark, so nothing printed on '
'the ticket was read')
def of_panel(reason):
return reason if has_panel else no_panel
def of_sheet(reason):
if not has_panel:
return no_panel
return reason if sheets else no_sheet
# -- what this ticket is
put('mark', t.get('mark'), 'the assembly has no type name')
put('asm_id', t.get('asm_id'), 'the assembly has no readable element id')
# -- HOLDOUT 1: `t['doc']`, a live Document.
key, stem, doc_reason = _doc_identity(t.get('doc'))
put('doc_key', key, doc_reason or 'the document would not give a title')
put('model_stem', stem,
doc_reason or 'the model has no path -- it has never been saved')
# -- the run's provenance for this piece (rule 4)
put('design', design, 'this ticket was not judged under a design number')
put('spec_source', spec_source,
'nothing recorded whether the intent came from a spec or a regex')
put('intent', intent, 'no design intent was resolved for this piece')
# -- the bill of materials as counted off the assembly's members
put('bom', t.get('bom'), 'no member of this assembly had a readable family')
# -- HOLDOUT 2: `t['legends']`, live View objects.
out['legends'] = _legend_rows(t.get('doc'), t.get('legends'))
put('legend_name', t.get('legend_name'),
'this ticket has no single pattern-legend view -- a plant that draws '
'none has nothing here, and that is not a defect')
put('legend_text', t.get('legend_text'),
'this ticket has no single pattern-legend view, so there is no '
'legend text to compare against')
# -- HOLDOUT 3: `t['sheets']` / `t['sheet']`, live ViewSheet objects.
out['sheets'] = _sheet_rows(sheets)
put('sheet_id', t.get('sheet_id'), no_sheet)
# -- strand
out['strand_by_dia'] = _strand_by_dia_rows(t.get('strand_by_dia'))
put('strand_qty', t.get('strand_qty'), 'no strand family was counted')
put('strand_layout', t.get('strand_layout'),
of_panel('the panel frame or its concrete extents could not be '
'solved, so the strands were never located in the piece'))
put('strand_points', t.get('strand_points'),
'the strand points were consumed into strand_layout and are not '
'carried separately')
put('ticket_strand_count', t.get('ticket_strand_count'),
of_sheet('the BOM strand row is missing or does not divide into a '
'whole number of strands over the panel length'))
# -- W.W.F.
put('wwf_count', t.get('wwf_count'), 'no W.W.F. family was counted')
put('wwf_mesh', t.get('wwf_mesh'),
'no W.W.F. type on this assembly carried a readable description')
# -- the panel itself
put('panel_id', t.get('panel_id'), no_panel)
put('family', t.get('family'), no_panel)
put('design_number', t.get('design_number'),
of_panel('the panel carries no value for the panel.design_number '
'role'))
put('length', t.get('length'),
of_panel('the panel carries no value for the panel.length role'))
put('width', t.get('width'),
of_panel('the panel carries no value for the panel.width role'))
put('thickness', t.get('thickness'),
of_panel('the panel type carries no value for the '
'panel_type.thickness role'))
put('pcf', t.get('pcf'),
of_panel('the panel carries no value for the panel.unit_weight role'))
put('weight_lb', t.get('weight_lb'),
of_panel('the panel carries no value for the panel.weight role'))
put('panel_extents', t.get('panel_extents'),
of_panel('the panel frame was not solved -- nothing on this piece '
'asked for it'))
# -- what kind of piece this is, and the facts it was decided from.
# An INPUT, so it belongs in this file: `qc-run.json` owns the verdicts
# and records the same classification as the premise of every row it
# switched off, and step 2 has to be able to re-derive one from the
# other. `panel_type` is never null -- an unclassified piece gets a
# dict whose `panel_type` is None and whose `why` says what every signal
# said, because "no answer" and "no attempt" are different facts.
put('panel_facts', t.get('panel_facts'),
of_panel('nothing was gathered to classify this piece with'))
out['connectors'] = [
{'mark': c.get('mark'), 'family': c.get('family'), 'id': c.get('id'),
'local': list(c.get('local')) if c.get('local') else None}
for c in (t.get('connectors') or [])]
put('connector_edges', t.get('connector_edges'),
of_panel('no connector was recognised on this piece, or the panel '
'frame was not solved, so no edge distance was measured'))
put('connector_gated_out', t.get('connector_gated_out'),
'no connector was excluded by the plant component gate')
put('member_kind', t.get('member_kind'),
'this piece was never put to the member-kind reader at all, so which '
'checklist a plant asks of it was not merely unknown -- it was never '
'asked')
put('panel_type', t.get('panel_type'),
'this piece was never put to the panel-type classifier at all, so '
'whether a plant rule about a kind of piece applies to it is not '
'merely unknown -- it was never asked')
# -- lifters, already in the panel's own frame
out['lifters'] = []
for lift in (t.get('lifters') or []):
lift = lift or {}
local, why = _soft(lift.get('local'), 'lifter local')
if local is None:
why = why or ('the panel frame was not solved, so this lifter '
'could not be placed along the piece')
out['lifters'].append({'id': lift.get('id'),
'family': lift.get('family'),
'local': local,
'reason': why})
# -- what the ticket itself prints
put('tkt_design_number', t.get('tkt_design_number'),
of_sheet('no sheet of this ticket carries the sheet.design_number '
'role'))
put('plant', t.get('plant'),
of_sheet('no sheet of this ticket carries the sheet.plant role'))
put('tkt_length', t.get('tkt_length'),
of_sheet('no sheet of this ticket carries the sheet.length role'))
put('tkt_width', t.get('tkt_width'),
of_sheet('no sheet of this ticket carries the sheet.width role'))
put('tkt_depth', t.get('tkt_depth'),
of_sheet('no sheet of this ticket carries the sheet.depth role'))
put('tkt_fc', t.get('tkt_fc'),
of_sheet('no sheet of this ticket carries the sheet.fc role'))
put('tkt_fci', t.get('tkt_fci'),
of_sheet('no sheet of this ticket carries the sheet.fci role'))
put('tkt_pcf', t.get('tkt_pcf'),
of_sheet('no sheet of this ticket carries the sheet.unit_weight role'))
put('bom_schedules', t.get('bom_schedules'), no_sheet)
# -- the solid zones nested in this piece's lifters (rows 5.11 - 5.13)
#
# `None` AND `[]` REACH THE FILE AS DIFFERENT THINGS, which is the whole
# reason this rides `put` rather than a bare assignment: never gathered
# (the plant has not verified the family, or no lifter was recognised)
# is a null with a sentence in `missing`, and gathered-and-empty is an
# empty list that claims a piece really has no nested zones.
put('solid_zones',
None if t.get('solid_zones') is None else [
{'id': z.get('id'), 'lifter_id': z.get('lifter_id'),
'member': z.get('member'), 'description': z.get('description')}
for z in t['solid_zones']],
'the solid zones nested in this piece\'s lifters were never gathered '
'-- either insulation.solid_zone.family is unset or unverified, or '
'no lifter was recognised to look inside')
put('lifters_without_zone', t.get('lifters_without_zone'),
'not counted, because the solid zones were never gathered')
put('lifters_unread', t.get('lifters_unread'),
'not counted, because the solid zones were never gathered')
out['missing'] = missing
out['unserialised_keys'] = sorted(
[compat.as_text(k) for k in t if k not in _TICKET_KEYS_CONSUMED])
return out
def _extension_version():
"""(version, reason) off `WillTools.extension/extension.json`.
Which build wrote the file, rule 4. Read from the manifest rather than
hardcoded, so it cannot drift from what `tools/package.py` ships.
"""
path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), 'extension.json')
try:
handle = io.open(path, 'r', encoding='utf-8')
try:
data = json.loads(handle.read())
finally:
handle.close()
except Exception as exc: # pylint: disable=W0703
return None, ('the extension manifest could not be read ({0}), so '
'this file cannot say which build wrote it'
.format(type(exc).__name__))
version = (data or {}).get('version')
if not version:
return None, 'the extension manifest declares no version'
return compat.as_text(version), None
def _knowledge_rows(doc):
"""(rows, reason) -- `knowledge.sources(doc)`, called not reimplemented.
Which layers were in force, rule 4. `sources` returns
`[(label, path, exists)]` newest last; that order is the merge order and
is preserved. `exists` false is not a fault -- a layer that is not there
is a layer that overrode nothing, and a reader has to be able to tell
that from a layer that was there and was empty.
"""
if doc is None:
return None, ('no document, so the knowledge layers in force cannot '
'be listed')
try:
from vibe import knowledge as K
rows = K.sources(doc)
except Exception as exc: # pylint: disable=W0703
return None, ('the knowledge layers could not be listed ({0})'
.format(type(exc).__name__))
out = []
for row in (rows or []):
label, path, exists = (list(row) + [None, None, None])[:3]
out.append({'label': compat.as_text(label or ''),
'path': compat.as_text(path or ''),
'exists': bool(exists)})
return out, None
def run_envelope(schema, doc=None, written_at=None, written_by=None):
"""The provenance wrapper every file this tool writes beside a model uses.
Rule 4, factored out so there is ONE envelope shape and not a second one
invented next door. `run_evidence` below builds on it, and so does
`qc-run.json` (`ticketvision.qc_json`, which is handed the result of this
because it must stay importable without Revit).
Returns `schema` / `written_at` / `written_by` / `build` / `doc_key` /
`model_stem` / `knowledge_sources` / `missing`, with rule 3 held: every
null in it has a sentence in `missing` saying why, because on the far
side of a file boundary a null and an absent key look identical and both
look like "there was nothing to record here".
`written_at` is injectable because a parity gate has to run with the
clock frozen -- a diff that is nothing but timestamps teaches nothing.
"""
version, version_why = _extension_version()
layers, layers_why = _knowledge_rows(doc)
key, stem, doc_why = _doc_identity(doc)
missing = {}
if key is None:
missing['doc_key'] = doc_why or 'the document would not give a title'
if stem is None:
missing['model_stem'] = (doc_why or
'the model has no path -- it has never '
'been saved')
if layers is None:
missing['knowledge_sources'] = layers_why
if version is None:
missing['build'] = version_why
return {
'schema': schema,
'written_at': compat.as_text(written_at or
time.strftime('%Y-%m-%dT%H:%M:%S')),
'written_by': compat.as_text(
written_by or 'TicketQC {0}'.format(version or 'unknown build')),
'build': {'extension_version': version, 'reason': version_why},
'doc_key': key,
'model_stem': stem,
'knowledge_sources': layers,
'missing': missing,
}
def run_evidence(results, doc=None, written_at=None, written_by=None):
"""Every ticket of one press, in one document, with its provenance.
`results` is what `run` / `run_for_assemblies` return -- entries carrying
`ticket`, `design`, `spec_source` and `intent`. Findings are deliberately
NOT here: step 1 moves no judgement, and `qc-run.json` already owns the
findings of a run.
`written_at` is injectable because the parity gate requires the
comparison to be run with the clock frozen -- a diff that is nothing but
timestamps teaches nothing and hides everything.
"""
tickets = []
for entry in (results or []):
entry = entry or {}
tickets.append(ticket_to_json(entry.get('ticket'),
design=entry.get('design'),
spec_source=entry.get('spec_source'),
intent=entry.get('intent')))
document = run_envelope(SCHEMA, doc=doc, written_at=written_at,
written_by=written_by)
document['ticket_count'] = len(tickets)
document['tickets'] = tickets
return document
def evidence_run_name(stamp=None):
"""`run-<stamp>.json`. One file per press, never one per piece."""
return 'run-{0}.json'.format(stamp or time.strftime('%Y%m%d-%H%M%S'))
def write_evidence(folder, document, name=None):
"""Write the run's evidence into `folder`, returning the path.
Takes a folder rather than a document so the write is testable without
Revit; the button passes `designspec.sub_dir(doc, EVIDENCE_FOLDER)`,
which is `<model>/_ticketqc/evidence`.
`allow_nan=False` is the belt to `_plain`'s braces: if anything ever gets
past the gate, this raises here -- where the button's wrapper prints a
line and loses nothing -- rather than writing `NaN` into a file a strict
reader chokes on days later.
"""
if not os.path.isdir(folder):
os.makedirs(folder)
path = os.path.join(folder, name or evidence_run_name())
text = json.dumps(document, indent=2, sort_keys=True, allow_nan=False)
if not isinstance(text, compat.text_type):
text = text.decode('utf-8')
handle = io.open(path, 'w', encoding='utf-8')
try:
handle.write(text)
finally:
handle.close()
return path
def load_evidence(path):
"""The evidence document at `path`, or an empty one saying why.
NOTHING IN THIS EXTENSION CALLS THIS YET -- step 2's runner is what reads
the file. It ships now because rule 1 is a property of the format, not of
the writer, and a discard rule that has never been executed is a comment.
Copied from `reinforcecore.load()`, deliberately, down to the shape of
the failure: a file whose `schema` is not `SCHEMA` is discarded WHOLE.
Not migrated, not partially adopted, and above all not read for the keys
that happen to still parse -- a half-read evidence file is a run against
a configuration nobody chose, and it would look completely normal.
`tickets` comes back EMPTY on any failure, so a caller that ignores
`_load_error` judges nothing rather than judging half a model.
"""
def _empty(why):
return {'schema': SCHEMA, 'written_at': None, 'written_by': None,
'build': None, 'doc_key': None, 'model_stem': None,
'knowledge_sources': None, 'ticket_count': 0, 'tickets': [],
'missing': {}, '_load_error': why}
if not path or not os.path.isfile(path):
return _empty('no evidence file at {0}'.format(path or '(no path)'))
try:
handle = io.open(path, 'r', encoding='utf-8')
try:
document = json.loads(handle.read())
finally:
handle.close()
except Exception as exc: # pylint: disable=W0703
return _empty('{0}: {1}'.format(type(exc).__name__, str(exc)[:120]))
if not isinstance(document, dict) or document.get('schema') != SCHEMA:
return _empty('schema {0}, expected {1}'.format(
(document or {}).get('schema')
if isinstance(document, dict) else '?', SCHEMA))
document.setdefault('tickets', [])
return document