8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
333 lines
14 KiB
Python
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
|