WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user