25592fa6c2
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.
808 lines
34 KiB
Python
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))
|