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

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

808 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""Reading the model for the reinforcing index. The Revit half only.
The split is the one `ticketpage.py` / `ticketchecks.py` already models:
everything that can be decided without a document lives in
`vibe.reinforcecore` and is unit-tested on host CPython; this file does the
gathering and nothing else. It is READ ONLY -- no Transaction, no parameter
write, no delete, anywhere in it, and there is no reason for that to change:
the feature writes nothing to the model.
WHERE THE CACHE LIVES
---------------------
Beside the model, under the same `_ticketqc` root the ticket evidence and
specs already use (`designspec.root_dir`), in `reinforce/index.json`. It
holds job geometry, so it may not enter the code repository; that root is
gitignored and, when `WILLTOOLS_JOBS` is set, is not inside the repository at
all. Nothing about this index is portable between models, so the model's own
folder is also where it is actually useful.
WHAT THE COST ACTUALLY IS -- measured, this model, 2026-08-30
-------------------------------------------------------------
755 assemblies collected + both flags read 0.29 s
every assembly's cheap signature 0.71 s
full geometry build, all 690 eligible donors 6.9 s (10 ms each)
slowest single piece 0.061 s
serialise + write the finished index (983 kB) 2.0 s
---- what a button press actually costs ----
full rebuild, cold cache 10.1 s
full rebuild, first press after a Revit restart 16.2 s
refresh with nothing changed 2.3 s
read the index without refreshing 1.4 s
---- schema 5, same model, measured 2026-08-30 ----
assemblies in the model 755
eligible donors 690
in-scope pieces added as TARGETS 28 (WP, not donors)
pieces in the file 718
index size 1.01 MB
Quote a rebuild as "~10-16 s", not as ten: the lower number was measured in a
session that had been open for hours, and the first press after a restart is
the one the owner actually experiences.
That is not the 30-60 s per piece this feature was warned about, and the
difference is worth stating because it moves the design: the expensive read
in this project is GENERATING A VIEW -- what `read_ticket` pays when it walks
a ticket sheet -- and nothing here touches a view. `concrete_solid` asks the
panel element for its own geometry and `_openings` reads faces off that
solid. So the index is not expensive to build; it is still cached, because
the owner's constraint is that a button press must not re-analyse the model,
and 6 s of frozen UI per click is 6 s too many.
Honesty about that number: this session was already open when it was
measured. Only a Revit restart restores a truly cold cache and a restart is
the owner's to make. The structural argument -- no view is generated -- is
what the number rests on, not a claim that the session was cold.
"""
# pylint: disable=import-error,broad-except,invalid-name
import os
import time
from vibe import reinforcecore as core
FOLDER = 'reinforce'
FILENAME = 'index.json'
# The two instance parameters on the assembly that decide donor eligibility.
# Named here rather than in the pure module because they are this job's
# ticketing-system parameters, read by name off the element.
P_REINFORCED = 'TICKET_IS_REINFORCED'
P_ON_HOLD = 'ON_HOLD'
# ------------------------------------------------------- the review signal
#
# WHERE 'MASTER' ACTUALLY LIVES -- measured on this model, 2026-08-30, before
# anything was written to match on it, because a guess about a hand-typed
# field is the kind of thing that quietly matches nothing.
#
# The owner said "identity description". Four candidate free-text fields were
# scanned across all 755 assemblies -- the assembly's identity comment, the
# panel instance's identity comment, and two identity fields on the panel
# type and instance. Only the first two ever contain the word:
#
# assembly identity comment 18 pieces <- what this reads
# panel-instance identity comment 16 pieces (a strict subset of it)
# panel-type identity field 0 -- 5 distinct values, all of the
# form 'PRECAST CONCRETE WALL
# PANEL'; a product class, not a
# review state
# instance short identity field 0 -- a category stub per piece
#
# The assembly field is a SUPERSET of the instance field on this model: every
# one of the 18 carries it on the assembly, two carry it ONLY there, and none
# carries it only on the instance. So this reads the assembly and nothing
# else -- one parameter read, no member walk, and the same source in the cheap
# signature pass as in the full extract, which is what keeps the staleness
# stamp honest about this field.
#
# THE PARAMETER IS NAMED BY ROLE, NEVER BY LITERAL. This extension is the
# shareable half of the project and a shared-parameter name is plant
# knowledge: another office's template calls the same field something else,
# and a name written into code is a plant that cannot be served without
# editing the code. `parameters.member.identity_description` is the role;
# `knowledge.param_names` resolves it to the office's real name(s), tried in
# order.
#
# UNMAPPED MEANS NOT RUN, NEVER A FALLBACK. With no name behind the role the
# signal STANDS DOWN: every piece gets `master = None`, which is the project's
# could-not-judge value and is not False. It must not quietly guess a name --
# that would work in this office and silently do nothing in every other one,
# while the report went on printing "no donor here is reviewed" as though
# somebody had looked. A plant that genuinely has no such field says so the
# way every other role does, under `parameters.not_applicable`.
R_IDENTITY = 'member.identity_description'
def master_token(know=None):
"""The plant's own word for a reviewed template.
The default lives in `reinforcecore.DEFAULT_MASTER_TOKEN`, so a plant
that maps the field but uses another word only has to set the key.
The path is written out as a LITERAL rather than through a module
constant on purpose: `tools/knowledge_drift.py` matches literal paths and
a key reached through a variable lands in its blind-spot list instead of
being credited with a reader -- which is how a key ends up reported as
declared-and-never-read while a check is quietly using it.
"""
from vibe import knowledge
return knowledge.get(know, 'reinforce.master_token',
core.DEFAULT_MASTER_TOKEN)
def identity_params(know=None):
"""The office's name(s) for the identity field, or [] when unmapped."""
from vibe import knowledge
return knowledge.param_names(know, R_IDENTITY)
def master_of(el, names, token):
"""(master, note) off one element. `master` is None when NOT RUN.
None is could-not-judge -- the role is unmapped and nothing was looked at.
False is judged: the field was read and does not carry the token.
"""
if not names:
return None, None
for name in names:
note = _text(el, name)
if note:
return core.is_master_text(note, token), note
return False, None
# ------------------------------------------------- the elevation accelerator
#
# THE OPTIONAL FAST PATH, NEVER THE SOURCE OF TRUTH. The source of truth for
# same-elevation is geometric (`_elevation_band_of`, below, in the expensive
# pass) and is computed for every piece regardless of this section. This is
# only a shortcut: a plant-declared GROUPING KEY a wall may already carry
# for an unrelated reason -- MC's is `Tagged_Sheet`, the drafting sheet a
# wall's elevation is drawn on. Owner, 2026-09-02: "对MC而言,所有的墙应该都
# 有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个
# elevation,不用跑哪个几何引擎。" Two panels sharing a non-empty value here
# are the same elevation with no geometry involved.
#
# ABSENCE IS THE NORMAL STATE, not a stand-down worth reporting. Owner, the
# same day, correcting the scope: other plants carry no such parameter at
# all, and even on MC it exists only on complex jobs -- a simple job with a
# handful of elevations carries none, by a deliberate human-vs-computer
# workload balance. So an unmapped role, or a blank value on a piece, is
# expected far more often than not, and `reinforcecore.elevation_match`
# simply falls through to the geometric floor without comment.
#
# Read off the PANEL, exactly as `design_number` is (by role, never a
# literal name) -- `parameters.panel.elevation_group`.
R_ELEVATION_GROUP = 'panel.elevation_group'
def _panel_member(doc, asm):
"""The Structural Framing member of one assembly, cheaply -- no geometry.
The same walk `extract()` does to find the panel it measures, pulled out
so the CHEAP pass can find it too: reading `elevation_group` needs the
panel, and the signature has to see the same source `extract()` records
or the two could disagree about what changed.
"""
try:
member_ids = list(asm.GetMemberIds())
except Exception:
return None
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing':
return member
return None
def elevation_group_of(doc, asm, know=None):
"""The plant's elevation-grouping text off one assembly's panel, or None.
None means could-not-judge, for any of: no Structural-Framing member to
read it off, the role is unmapped (the common case -- see the section
note above), or the field is blank on this piece. `Q.rstr` already
returns None for an unmapped role, the same stand-down `design_number`
uses. Never raises: a piece this cannot be read for still has the
geometric floor to fall back on.
"""
panel = _panel_member(doc, asm)
if panel is None:
return None
from vibe import ticketqc as Q
try:
return Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
return None
def index_path(doc):
from vibe import designspec
return os.path.join(designspec.sub_dir(doc, FOLDER), FILENAME)
def index_path_in(job_folder):
"""The index inside one model's job folder. THE OFFLINE RESOLVER.
It takes a folder rather than a document, so nothing on this path imports
Revit -- `index_path` reaches `designspec`, which imports the Revit API at
module scope, and that single line is what would otherwise drag the whole
API into an offline run. It also does not CREATE anything: reading an
index must never leave a folder behind on a machine that has no model.
The layout is `designspec`'s and is repeated here rather than shared,
because the doc-side function is the one that knows about the model-folder
and APPDATA fallbacks. When WILLTOOLS_JOBS is set -- the case that matters
offline -- the whole path is `<jobs root>/<model title>/reinforce/index.json`
and `jobs_folder()` builds the first half of it.
"""
return os.path.join(job_folder, FOLDER, FILENAME)
def jobs_folder(jobs_root, model):
"""`<jobs root>/<model title>` -- what WILLTOOLS_JOBS means, offline."""
return os.path.join(jobs_root, model or 'untitled')
def doc_key(doc):
"""Which model a cache belongs to, without writing a path into the repo."""
try:
return doc.Title or ''
except Exception:
return ''
# ------------------------------------------------------------- the cheap pass
def _name_of(el):
from Autodesk.Revit.DB import Element
try:
return Element.Name.GetValue(el)
except Exception:
return None
def _text(el, pname):
"""A string parameter's value, or None. Never raises. READ ONLY."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
value = p.AsString()
except Exception:
value = None
if not value:
return None
return value
def _flag(el, pname):
"""1 / 0 / None. None means the parameter is not on this element."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
return int(p.AsInteger())
except Exception:
return None
def assemblies(doc):
"""Every assembly instance, as (name, element). Scoped collector only."""
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
out = []
for a in (FilteredElementCollector(doc)
.OfClass(AssemblyInstance)
.WhereElementIsNotElementType()
.ToElements()):
name = _name_of(a)
if name:
out.append((name, a))
return out
def live_signatures(doc, elements=None, know=None):
"""{name: (signature, reinforced, on_hold)} for every assembly.
No geometry is read here. This is the pass that decides what has to be
rebuilt, so it has to stay cheap enough to run on every button press --
measured at 0.23 s over 755 assemblies. The MASTER flag joined it in
2026-08-30 at the cost of one string parameter read per assembly:
measured over the same 755 assemblies, reading the identity field and
five other string fields together took 1.07 s including the member walk,
so one field on its own is well inside the same budget. It has to be here --
see `reinforcecore.signature` for why a hand-typed field that is not in
the stamp is a field that never takes effect.
THE GEOMETRIC ELEVATION FLOOR IS NOT HERE, and it briefly stood in this
spot under a different name for one day (2026-09-02): the owner's
ruling made it a purely GEOMETRIC fact, and geometry needs the panel's
measured solid -- unaffordable in a pass that has to stay cheap enough
to run on every button press. See `reinforcecore.elevation_band`'s
module note for the full reasoning; the band is captured only in
`extract()`, alongside `thickness_in` and `features`, and never feeds
this cheap stamp.
THE ELEVATION ACCELERATOR IS HERE, and costs about what MASTER does:
reading `elevation_group` needs the PANEL, not the assembly, so
`elevation_group_of` pays its own member walk per assembly
(`_panel_member`) rather than one parameter read off `a` directly. That
walk is the same one `extract()` already pays to find the panel it
measures -- it is not new work, only moved earlier. On most jobs the
role is unmapped (see the section note above `R_ELEVATION_GROUP`), so
this is one more `LookupParameter` call that returns nothing -- cheap
even when it never answers.
"""
token = master_token(know)
names = identity_params(know)
out = {}
for name, a in (elements if elements is not None else assemblies(doc)):
reinforced = _flag(a, P_REINFORCED)
on_hold = _flag(a, P_ON_HOLD)
master = master_of(a, names, token)[0]
elevation_group = elevation_group_of(doc, a, know)
try:
member_ids = [_int_id(i) for i in a.GetMemberIds()]
except Exception:
member_ids = []
out[name] = (core.signature(reinforced, on_hold, member_ids, master,
elevation_group),
reinforced, on_hold)
return out
def _int_id(element_id):
try:
return element_id.IntegerValue
except Exception:
return element_id.Value
# ------------------------------------------------------- the expensive pass
def _global_box_of_solid(solid):
"""The solid's own bounding box, in RAW GLOBAL (model) coordinates.
`Solid.GetBoundingBox()` over the solid's 8 box corners, mirroring
`ticketqc._one_solid_box`'s reasoning exactly: a curved edge's two
"endpoints" coincide at the seam, so walking `solid.Edges` directly
collapses a round feature to a sliver -- `Solid.GetBoundingBox()` does
not have that failure. Unlike `_one_solid_box` this stops at the
solid's own `box.Transform` and never applies the panel's inverse
afterward: a building elevation is measured in the MODEL's frame, not
the panel's own local one.
Returns `(lo, hi)`, each an (x, y, z) list, or `(None, None)`.
"""
if solid is None:
return None, None
try:
from Autodesk.Revit.DB import XYZ
box = solid.GetBoundingBox()
if box is None:
return None, None
local = box.Transform
lo = [None, None, None]
hi = [None, None, None]
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 = local.OfPoint(corner)
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, None
return lo, hi
except Exception:
return None, None
def _elevation_band_of(panel, extents, solid):
"""`core.elevation_band(...)` off the panel already being measured.
`extents` is `Q.concrete_extents(panel, frame)`'s (lo, hi) LOCAL-frame
feet triples -- the same measurement `thickness_in` already comes from,
read again here rather than reconverted from `lo_in` / `hi_in` to avoid
a needless feet -> inches -> feet round trip. `solid` is the same one
`extract()` already paid for to build `features` -- no second geometry
pass; only `panel.GetTransform()` is read again, and that is a
placement property, not a solve.
Never raises: any failure reading the transform or the box is
could-not-judge, exactly what `core.elevation_band` itself returns for
an ambiguous or off-axis panel -- a ranking signal going quiet, never a
reason to fail the whole piece.
"""
from vibe import knowledge
try:
spans_ft = [extents[1][i] - extents[0][i] for i in (0, 1, 2)]
transform = panel.GetTransform()
basis = (transform.BasisX, transform.BasisY, transform.BasisZ)
basis_xyz = [(b.X, b.Y, b.Z) for b in basis]
except Exception:
return None
box_lo, box_hi = _global_box_of_solid(solid)
try:
return core.elevation_band(
spans_ft, basis_xyz[0], basis_xyz[1], basis_xyz[2],
box_lo, box_hi,
near_square_ratio=knowledge.UP_END_NEAR_SQUARE_RATIO)
except Exception:
return None
def extract(doc, name, asm, sig, know=None):
"""One piece's index record. Never raises: a failure becomes `error`.
Every early return is a could-not-judge with a reason attached, never an
empty feature list. A piece whose solid would not read and a piece with
no openings are different answers.
"""
from vibe import ticketqc as Q, liftexport as L
reinforced = _flag(asm, P_REINFORCED)
on_hold = _flag(asm, P_ON_HOLD)
# The review signal. Read here as well as in the cheap pass, and from the
# same parameter, so a record and its own signature can never disagree
# about it. A piece that could not be measured still carries it -- being
# unreadable does not un-review a piece.
master, note = master_of(asm, identity_params(know), master_token(know))
def fail(why, **extra):
return core.piece_record(name, _int_id(asm.Id), sig,
reinforced, on_hold, error=why,
master=master, master_note=note, **extra)
panel = None
families = {}
try:
member_ids = list(asm.GetMemberIds())
except Exception as exc:
return fail('members unreadable: {0}'.format(exc))
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
fam = member.Symbol.Family.Name
except Exception:
continue
families[fam] = families.get(fam, 0) + 1
# NOT filtered on SuperComponent -- but the reason written here
# first was wrong in both halves, and it was corrected 2026-08-30
# against the live model:
#
# * SuperComponent set means the element IS A CHILD; it holds the
# PARENT. Measured: SP03 members return
# CONNX_HPKM39_COLUMN_SHOE(10 ANCHOR BOLTS) as their super
# component. The old comment had it backwards, claiming the
# property marks an element that HAS children.
# * It is not "nearly every member". Over 25 WP assemblies,
# 553 of 2755 members carry one -- 20.1%.
#
# So filtering would not empty the count. It would remove a fifth of
# it, and that fifth is nested children that a naive count counts
# twice: once as themselves and once inside their parent.
#
# The inventory is still recorded RAW and deliberately so -- this is
# an index, and a consumer that needs a de-duplicated count should
# decide its own rule and say which it used. But a consumer that
# counts `families` without de-duplicating is over-counting by
# roughly that 20%, and it should know that rather than inherit a
# comment saying the property is useless.
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing' and panel is None:
panel = member
if panel is None:
return fail('no structural-framing member to measure',
families=families)
try:
design_number = Q.rstr(panel, know, 'panel.design_number')
except Exception:
design_number = None
# THE ELEVATION ACCELERATOR -- read the same way and at the same point
# as design_number, since both are hand-set fields off the same panel.
# None the vast majority of the time (unmapped role, or blank on this
# piece) is expected, not a fault -- the geometric floor below is what
# actually answers on most jobs. See `reinforcecore.elevation_match`'s
# module note.
try:
elevation_group = Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
elevation_group = None
try:
frame = Q._panel_frame(panel)
except Exception as exc:
return fail('frame failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if frame is None:
return fail('no panel frame', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
try:
extents = Q.concrete_extents(panel, frame)
except Exception as exc:
return fail('extents failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if extents is None:
return fail('no concrete extents', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
# concrete_extents is in FEET; _openings and opening_penetration both want
# INCHES. Getting this backwards is a 12x error, which is the single most
# expensive mistake this project has made.
lo_in = [v * 12.0 for v in extents[0]]
hi_in = [v * 12.0 for v in extents[1]]
axes = L._axes(lo_in, hi_in)
length_axis, width_axis, thick_axis = axes
length_ft = (hi_in[length_axis] - lo_in[length_axis]) / 12.0
width_ft = (hi_in[width_axis] - lo_in[width_axis]) / 12.0
thickness_in = hi_in[thick_axis] - lo_in[thick_axis]
try:
solid = L.concrete_solid(panel)
except Exception as exc:
return fail('solid failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
if solid is None:
return fail('no concrete solid', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# THE ELEVATION FLOOR -- purely geometric, owner's ruling 2026-09-02;
# see `reinforcecore.elevation_band`'s module note for the definition.
# Computed for EVERY piece regardless of `elevation_group` above -- it
# is the answer a job with no grouping parameter has, and the one that
# keeps working if the parameter is added or dropped later. Off the
# SAME solid `features` is about to be measured from -- no second
# geometry pass -- plus the panel's own placement transform, which is a
# cheap property read, not a solve. A failure here is a ranking signal
# that could not be determined, never a reason to fail the whole piece:
# `thickness_in` and `features` are still worth having even when the
# panel turns out to be near-square or skewed off every global axis.
elevation_band = _elevation_band_of(panel, extents, solid)
try:
rects = L._openings(solid, frame, lo_in, hi_in, axes)
except Exception as exc:
return fail('openings failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
elevation_band=elevation_band,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# Penetration is a separate solve and can fail on its own. When it does,
# the rectangles are still real -- so the features are kept and `through`
# goes to None on each, which is the could-not-judge value, not False.
try:
pen = L.opening_penetration(panel, frame, solid, rects, axes,
lo_in, hi_in)
except Exception:
pen = []
features = []
for i, rect in enumerate(rects):
p = pen[i] if i < len(pen) else {}
features.append(core.feature(
name, i, rect[0], rect[1], rect[2], rect[3],
p.get('through'), p.get('concrete_in'),
length_ft, width_ft, thickness_in))
return core.piece_record(
name, _int_id(asm.Id), sig, reinforced, on_hold,
design_number=design_number, panel_id=_int_id(panel.Id),
length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in,
families=families, features=features,
master=master, master_note=note,
elevation_band=elevation_band, elevation_group=elevation_group)
# ------------------------------------------------------------------ refresh
def scope_of(know):
"""The declared scope, or an undeclared one. Never raises on a bad file."""
from vibe import scope as scopelib
if not know:
return scopelib.Scope()
try:
return scopelib.of(know)
except Exception:
return scopelib.Scope()
def scope_in(cache):
"""(Scope, stamped) out of a cache. NO DOCUMENT, NO KNOWLEDGE FILE.
`stamped` is False when the index carries no scope at all -- built before
the stamp existed, or by a caller that had no knowledge layer. That is not
the same as "no scope was declared", and an offline reader has to say
which: the first means nobody knows what the pool was bounded by, the
second means it was deliberately unbounded. Both come back as an
undeclared `Scope()`; only this flag tells them apart.
"""
from vibe import scope as scopelib
stamp = (cache or {}).get('scope')
if not isinstance(stamp, dict):
return scopelib.Scope(), False
return scopelib.Scope(prefixes=stamp.get('prefixes') or (),
note=stamp.get('note'),
declared_by=stamp.get('declared_by'),
source='the index, built ' +
str((cache or {}).get('built_at'))), True
def refresh(doc, budget_s=None, limit=None, rebuild_all=False, know=None,
on_piece=None, targets=True):
"""Bring the index up to date and return (cache, report). READ ONLY.
`budget_s` stops the build partway and leaves the rest for the next call.
That is what makes a cold index usable immediately: the first press
indexes what it can afford, answers with what it has, and says what it
could not see. `report['coverage']['missing']` is that list and a caller
that drops it is claiming a completeness the index does not have.
`rebuild_all` ignores the cached signatures. It is the escape hatch for
the thing signatures cannot see -- an opening moved or resized without
the member set changing -- and it is cheap: a full build of this model's
690 eligible donors measured 6.07 s (16.2 s on the first build after a
Revit restart, measured 2026-08-30).
`targets` (schema 5, default ON) also measures every piece INSIDE THE
DECLARED SCOPE that is not an eligible donor. Those are the pieces
somebody actually wants suggestions for -- not yet reinforced, so donor
eligibility left them out of the file altogether and the target had to be
measured through a live document on every press. With them in the file the
whole chain runs off the snapshot and `tools/reinforce_offline.py` can
answer with no Revit at all.
IT IS THE SCOPE THAT BOUNDS THIS, NOT A PREFIX IN THE CODE. The scope is
read from the job knowledge (`scope.mark_prefixes`), which is where this
project already keeps the answer to "whose pieces are these". An
UNDECLARED scope includes everything, by `vibe.scope`'s standing rule that
no declaration means judge everything -- which here costs measuring the
whole model rather than the donors, roughly one extra second per hundred
assemblies, and produces a `features=None` record for anything with no
structural-framing member to measure. Pass `targets=False` for the
donors-only index schema 4 built.
"""
started = time.time()
path = index_path(doc)
cache = core.load(path)
if cache.get('doc_key') not in (None, doc_key(doc)):
cache = core.new_cache(doc_key(doc))
cache['doc_key'] = doc_key(doc)
the_scope = scope_of(know)
# Stamped into the file so the offline reader, which cannot load a
# knowledge layer without a model, gets the same scope the button had.
cache['scope'] = {'prefixes': list(the_scope.prefixes),
'note': the_scope.note,
'declared_by': the_scope.declared_by}
# And the nomenclature, for the same reason and to the same reader. A
# donor must be the SAME DESIGN as its target (owner ruling, 2026-09-01),
# and reducing two design numbers to one family is done with the plant's
# `nomenclature.design_number_re`. The offline reader has no knowledge
# layer to get that from, so it is stamped here alongside the scope.
# Additive: a file written before this key falls back exactly as before.
from vibe import knowledge as K
cache['nomenclature'] = {
'design_number_re': K.get(know, 'nomenclature.design_number_re', None)
if know else None}
elements = assemblies(doc)
by_name = dict(elements)
t_sig = time.time()
live = live_signatures(doc, elements, know=know)
seconds_signatures = time.time() - t_sig
if rebuild_all:
cache['pieces'] = {}
the_plan = core.plan(cache, live,
include=the_scope.includes if targets else None)
todo = the_plan['build']
if limit is not None:
todo = todo[:limit]
records = []
t_build = time.time()
ran_out = False
for name in todo:
if budget_s is not None and (time.time() - t_build) > budget_s:
ran_out = True
break
asm = by_name.get(name)
if asm is None:
continue
sig = live[name][0]
records.append(extract(doc, name, asm, sig, know=know))
if on_piece is not None:
on_piece(name)
seconds_build = time.time() - t_build
# Rewriting an unchanged cache costs a full serialise for nothing, and the
# commonest press of all is the one where nothing moved. Measured: writing
# the 690-piece file back is the single largest slice of a no-change run.
changed = bool(records) or bool(the_plan['drop'])
cache = core.apply_plan(cache, the_plan, records,
built_at=_stamp() if changed else None)
# MAINTAIN THE FEATURE VOCABULARY on every refresh (schema 6). The registry
# lives on the cache and is NEVER reset by `rebuild_all` -- only
# `cache['pieces']` is cleared above -- so `get_or_add` keeps every existing
# id stable and only assigns ids for shapes genuinely seen for the first
# time. Registering the freshly-built records is sufficient: an unchanged
# (fresh) piece's descriptors were registered when it was first built and
# its ids never move. `_pack`/`_unpack` carry the registry to disk, so the
# offline reader gets the same vocabulary this build produced.
if records:
core.register_features(cache, records)
if changed:
core.save(path, cache)
report = {
'path': path,
'assemblies': len(elements),
'scope': the_scope.label(),
'targets_wanted': len(the_plan.get('targets') or ()),
'seconds_signatures': round(seconds_signatures, 3),
'seconds_build': round(seconds_build, 3),
'seconds_total': round(time.time() - started, 3),
'built': len(records),
'reused': len(the_plan['fresh']),
'dropped': len(the_plan['drop']),
'stopped_early': ran_out or (limit is not None
and len(the_plan['build']) > len(todo)),
'coverage': core.coverage(cache, the_plan),
}
return cache, report
def _stamp():
return time.strftime('%Y-%m-%d %H:%M:%S')
def read(doc):
"""The index as it stands, without rebuilding anything. READ ONLY.
For a caller that only wants to search: `refresh` first if you mean to be
current, `read` if you mean to be fast. Neither touches the model.
"""
return core.load(index_path(doc))