52d94303bc
ci / test (push) Has been cancelled
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.
3532 lines
147 KiB
Python
3532 lines
147 KiB
Python
# -*- 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
|