Files
willtools-demo/WillTools.extension/lib/vibe/jobreconscan.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

333 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""The Revit half of the job-level reconciliation: gather, then hand over.
`vibe.jobrecon` holds the reconciliation itself and knows nothing about
Revit, so it can be tested on host CPython. This module is the thin gatherer
that turns a live document into the plain dicts that module eats -- the same
split `ticketpage.py` / `ticketchecks.py` uses, for the same reason: the
arithmetic is where a wrong answer hides, and arithmetic must be testable
without a licence.
NO OFFICE PARAMETER NAME APPEARS IN THIS FILE. Everything is read by ROLE
through `vibe.knowledge`, and a role nobody has mapped reads as a REFUSAL,
not as an empty job. That distinction is the whole point of this tool: a
reconciliation that cannot see the control numbers must say so, because
"found nothing" and "looked at nothing" are the same output otherwise, and
this project has already shipped that bug once.
Roles this module needs, none of which the older checks used:
member.control_number the piece's control number -- ONE PHYSICAL
PIECE. The unit of the whole reconciliation.
sheet.control_numbers the title-block slots holding the control
numbers a ticket covers. A list of parameter
names, or leave it unset and give
`nomenclature.control_slot_re` instead, a
regex matched against sheet parameter names --
which is what a plant with numbered slots
(..._01 .. _30) wants.
sheet.total_required the quantity the title block states.
sheet.sheet_count how many pages the ticket says it has.
member.ticket_created the model's own claim that a ticket exists.
member.ticket_detailed ... and that it was detailed. Both optional.
Already-mapped roles it reuses: `member.mark`, `panel.design_number`,
`sheet.design_number`.
Cost, measured on a 2480-piece job: the piece sweep is about 6 s across six
categories and the sheet sweep about 1 s across 1105 sheets -- no geometry is
built anywhere, which is what keeps it in seconds rather than the 10 s a
690-piece geometry pass costs.
IronPython 2.7: no f-strings, no pathlib, no typing.
"""
# pylint: disable=import-error,invalid-name,broad-except
import re
import time
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, AssemblyInstance, ViewSheet,
)
from vibe import jobrecon
# Where precast pieces can live. Not a filter on what counts as a piece --
# a piece is anything carrying a control number -- but the set of collectors
# the sweep opens. Every category here is reported with its tally, INCLUDING
# the zeros, so a reader can see what was looked at; categories outside this
# list are named in the result's blind spots rather than silently omitted.
#
# Never widen this to an unfiltered collector. A model-wide
# WhereElementIsNotElementType() walk killed Revit here on 2026-08-18.
PIECE_CATEGORIES = (
'OST_StructuralFraming',
'OST_StructuralColumns',
'OST_Walls',
'OST_Floors',
'OST_StructuralFoundation',
'OST_GenericModel',
)
# WHAT WAS LOOKED AT IS NOT A BLIND SPOT. `gather` returns ONE list of notes
# -- everything it wants said about the run -- and two different kinds live in
# it: 'I could not see X', which is a blind spot, and 'I opened these six
# category collectors and here is the tally', which is an inventory. The
# report folds the second and opens the first, so `run` has to tell them
# apart, and these two constants are how: the inventory notes are written
# with them three lines below and recognised by them in `is_inventory_note`.
#
# `gather`'s signature is deliberately NOT changed to return them separately.
# `vibe.takeoffgather` calls `gather` too and collects the same notes; a
# different tuple there would be a change to another button's path for the
# sake of this one's formatting.
INVENTORY_LEAD = 'categories searched:'
INVENTORY_INDENT = ' '
def is_inventory_note(note):
"""True for a note that is an INVENTORY of what was swept, not a blind
spot. See the constants above."""
text = str(note or '')
return text.startswith(INVENTORY_LEAD) or text.startswith(INVENTORY_INDENT)
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _param_text(param):
"""A parameter as text, whatever it is stored as."""
if param is None:
return None
try:
value = param.AsString()
except Exception:
value = None
if value in (None, ''):
try:
value = param.AsValueString()
except Exception:
value = None
return value
def _read(el, names):
"""First non-empty value across candidate parameter names."""
for name in names or ():
value = _param_text(el.LookupParameter(name))
if value not in (None, ''):
return value
return None
def _roles(know, overrides=None, _doc_for_knowledge=None):
"""Resolve every role this module reads, once.
`overrides` -- {role: name or [names]}, for a caller that knows the
template but whose knowledge file has not been taught these roles yet.
Explicit and visible beats a hardcoded office name in the repo.
"""
from vibe import knowledge as K
from vibe.compat import string_types
overrides = overrides or {}
# `know=None` used to mean "nothing is mapped", which made a caller who
# simply forgot the argument indistinguishable from an unconfigured
# plant: every role resolved empty and the whole reconciliation refused.
# It refused LOUDLY, which is the only reason this was survivable. Every
# other module in vibe loads knowledge itself when it is handed none, and
# so does this one now. A genuine refusal still happens -- it just has to
# be because the file really does not map the role.
if know is None and _doc_for_knowledge is not None:
try:
know = K.load(_doc_for_knowledge)
except Exception:
know = None
out = {}
for role in ('member.control_number', 'member.mark', 'member.ticket_created',
'member.ticket_detailed', 'panel.design_number',
'sheet.design_number', 'sheet.control_numbers',
'sheet.total_required', 'sheet.sheet_count'):
if role in overrides:
value = overrides[role]
out[role] = ([value] if isinstance(value, string_types)
else list(value))
elif know is not None:
out[role] = K.param_names(know, role)
else:
out[role] = []
slot_re = overrides.get('control_slot_re')
if slot_re is None and know is not None:
slot_re = K.get(know, 'nomenclature.control_slot_re', None)
out['control_slot_re'] = slot_re
return out
def gather(doc, know=None, overrides=None, categories=None):
"""Read the document. Returns (pieces, sheets, notes, timing).
`notes` collects everything the gather itself could not do -- an unmapped
role, a category that could not be collected. It becomes part of the
reconciliation's blind spots; it is never swallowed.
"""
roles = _roles(know, overrides, _doc_for_knowledge=doc)
notes = []
t0 = time.time()
ctrl_names = roles['member.control_number']
if not ctrl_names:
notes.append('the role member.control_number is not mapped -- no '
'piece could be identified, so NOTHING was reconciled. '
'Map it in the plant knowledge file.')
slot_names = roles['sheet.control_numbers']
slot_re = roles['control_slot_re']
if not slot_names and not slot_re:
notes.append('neither the role sheet.control_numbers nor '
'nomenclature.control_slot_re is set -- no ticket could '
'be read, so every piece will read as unticketed. This '
'is a refusal, not a finding.')
# ---- which elements are already inside an assembly ------------------
in_assembly = {}
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
name = asm.AssemblyTypeName
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if member is not None:
in_assembly[_eid(member)] = name
# ---- the pieces -----------------------------------------------------
pieces = []
per_category = {}
for cat_name in (categories or PIECE_CATEGORIES):
bic = getattr(BuiltInCategory, cat_name, None)
if bic is None:
notes.append('this Revit build has no category {0}; it was not '
'searched'.format(cat_name))
continue
try:
elements = (FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception as exc:
notes.append('could not collect {0}: {1}'.format(cat_name, exc))
continue
found = 0
for el in elements:
control = _read(el, ctrl_names)
if control in (None, ''):
continue
found += 1
family = None
try:
family = el.Symbol.Family.Name
except Exception:
try:
family = el.Name
except Exception:
family = None
pieces.append({
'id': _eid(el),
'category': cat_name,
'control': control,
'mark': _read(el, roles['member.mark']),
'design': _read(el, roles['panel.design_number']),
'family': family,
'in_assembly': in_assembly.get(_eid(el)),
'ticket_created': _read(el, roles['member.ticket_created']),
'ticket_detailed': _read(el, roles['member.ticket_detailed']),
})
per_category[cat_name] = {'elements': len(elements), 'pieces': found}
t1 = time.time()
# ---- the sheets -----------------------------------------------------
#
# One pass over each sheet's own Parameters, not thirty LookupParameter
# calls per sheet: with numbered slots that is the difference between
# about a second and about ten across a 1105-sheet job, and the slots are
# discovered rather than assumed to be contiguous.
slot_pattern = re.compile(slot_re, re.IGNORECASE) if slot_re else None
sheets = []
for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements():
slots = {}
if slot_pattern is not None:
for param in sheet.Parameters:
try:
name = param.Definition.Name
except Exception:
continue
if slot_pattern.match(name):
slots[name] = _param_text(param)
controls = [slots[k] for k in sorted(slots)]
for name in slot_names:
controls.append(_param_text(sheet.LookupParameter(name)))
sheets.append({
'id': _eid(sheet),
'number': sheet.SheetNumber,
'name': sheet.Name,
'design': _read(sheet, roles['sheet.design_number']),
'controls': controls,
'total_required': _read(sheet, roles['sheet.total_required']),
'sheet_count': _read(sheet, roles['sheet.sheet_count']),
})
t2 = time.time()
searched = set(categories or PIECE_CATEGORIES)
notes.append(INVENTORY_LEAD + ' {0}. A precast piece modelled in any '
'other category was not seen by this run.'
.format(', '.join(sorted(searched))))
for cat_name in sorted(per_category):
row = per_category[cat_name]
notes.append(INVENTORY_INDENT +
'{0}: {1} element(s), {2} carrying a control number'
.format(cat_name, row['elements'], row['pieces']))
timing = {'pieces_s': round(t1 - t0, 2), 'sheets_s': round(t2 - t1, 2),
'total_s': round(t2 - t0, 2)}
return pieces, sheets, notes, timing
def _numeric_sort(value):
"""Sort control numbers the way a person reads them: 9 before 10."""
text = str(value)
parts = re.split(r'(\d+)', text)
key = []
for part in parts:
if part.isdigit():
key.append((1, int(part), ''))
elif part:
key.append((0, 0, part))
return key
def run(doc, know=None, overrides=None, categories=None, scope=None):
"""Gather and reconcile. Returns the jobrecon result, plus timings.
The DECLARED SCOPE is read from the job knowledge here and handed to
`reconcile`, which sets aside what is outside it and reports that. The
gather itself is deliberately unscoped: it reads the whole job, so the
count of what was left alone is a measured number rather than an
estimate of what was never looked at.
"""
pieces, sheets, notes, timing = gather(doc, know=know,
overrides=overrides,
categories=categories)
if scope is None:
from vibe import scope as scopelib
if know is None:
from vibe import knowledge as K
know = K.load(doc)
scope = scopelib.of(know)
recon = jobrecon.reconcile(pieces, sheets, sort_key=_numeric_sort,
scope=scope)
# Split, not merged: what the sweep opened folds away as routine, what it
# could not see opens itself. Both are printed either way.
recon['blind_spots'] = ([n for n in notes if not is_inventory_note(n)]
+ recon['blind_spots'])
recon['searched'] = [n for n in notes if is_inventory_note(n)]
recon['timing'] = timing
return recon