# -*- coding: utf-8 -*- """Joining the reinforcing index to the matcher: features, the real `covers`, the donor filter, and the end-to-end path from a piece name to proposals. WHAT A FEATURE IS HERE, AND WHY IT IS NOT A VOID ------------------------------------------------ Owner's reframe, 2026-08-30: what gets reinforced is the CONCRETE THAT IS LEFT, not the hole. Two openings 3 ft apart leave a 3 ft strip that has to carry load and take bars; the same two openings 8 in apart leave a little column section that may not develop a strand at all. The two holes are identical in both cases. The strip between them is the thing an engineer details, and it belongs to neither hole. So the features this module hands the matcher are LIGAMENTS -- strips of remaining concrete -- built from the index's voids rather than being the voids. A ligament is the strip between two voids, or between a void and a panel edge. LOCALITY NEEDS NO RADIUS. There is no neighbourhood size to tune here and none is invented: a ligament's extent is fully determined by the two things that bound it. The strip between two voids spans the overlap of their ranges on one axis and the gap between them on the other; both numbers fall out of the geometry. That is the whole definition. NO THRESHOLD. Every ligament is emitted, however narrow. The width below which a strip is a "little column section" that cannot develop a strand is a plant number, it lives in the local knowledge YAML, and it is not this module's to guess -- consistent with the matcher's standing rule that exactness RANKS and does not CLASSIFY. A LIGAMENT MUST BE CONCRETE YOU CAN POINT AT. Every candidate strip is checked clear of every other void before it is emitted. Without that check a "strip" between two voids at opposite ends of a panel would be reported as concrete while a third void sits in the middle of it. Zero-width strips are not emitted either, because a void flush with the panel edge leaves no concrete there; that condition shows up as the ABSENCE of an edge ligament, which the set cover reads correctly -- a target that has the strip and a donor that does not means the strip goes uncovered. THE RECTANGLE ASSUMPTION, AND THE FLAG THAT KEEPS IT HONEST ------------------------------------------------------------ Ligaments are derived from `rect(length_ft, width_ft) minus the voids`. That was measured against the model rather than assumed: comparing each panel's actual solid volume with its extents box minus its recorded voids, 242 of the 255 WP donors agree to a median 0.00% and a p90 of 0.01%. The derivation is sound for those. THIRTEEN OF 255 DO NOT. Their solid is 3% to 33% smaller than their extents box with no voids to explain it -- WP108 is 32.7% short against 0.2 cubic feet of recorded voids. Those panels have a non-rectangular outline (a slope or a corner cut) that the index, which stores only a bounding length and width, cannot see. For them the derived shape claims concrete that is not there, and it claims it exactly at the panel edge, which is where the edge ligaments live. Those pieces are marked `outline_approximate` and their EDGE ligaments can never score exact (see `covers`). They still take part -- excluding them would silently shrink the pool -- but a caller can never mistake an approximated edge condition for a measured one. `judged` and `judged on an approximation` stay different answers, which is the same rule the index already applies to `features == []` against `features is None`. KNOWN FOLLOW-UP, for whoever picks this up ------------------------------------------- The fix is one new key on the index's piece record: the boundary of `concrete_solid` projected into the piece frame, as a list of absolute-feet `(x, y)` vertices, alongside the existing `length_ft` / `width_ft`. It needs a `reinforcecore.SCHEMA` bump and a change in `reinforceindex.extract`. Deferred deliberately on 2026-08-30 for a deadline, with the owner's agreement that cleanup happens at integration; the evidence for doing it is the 13-of-255 measurement above, reproducible with `outline_audit()` below. Until it lands, `outline_approximate` is what stands in for it. UNITS: FEET IN, INCHES OUT -------------------------- Every record this module reads is in decimal FEET -- Revit's internal unit, which is what `reinforcecore` caches and what the geometry extraction produces. Nothing here converts a coordinate. `covers` is the one exception and it is the seam: the distance it RETURNS is INCHES, on a 1/8 in grain, by the owner's ruling of 2026-08-30 ("all actual measurements use inches, to two decimal places"). It converts once, through `reinforcecore.inches()`, and everything above it -- the matcher, the proposals, the report's distance column -- is inches from there on. The report converts stored feet coordinates for display through the same helper, at the moment of printing, and never writes an inch value back onto a record. So a double conversion has nowhere to live. See `GRAIN_IN` for why the grain exists and `reinforcecore`'s seam note for the whole rule. WHAT IS REVIT-FREE ------------------ The MODULE is: nothing at module scope imports Revit, so `from vibe import reinforcebind` works under host CPython (that is how the tests reach it, and how `tools/reinforce_offline.py` does). TWO FUNCTIONS IN IT ARE NOT, and the line is drawn at the function, not at the file, so an offline caller can be told exactly what it may call: * `outline_audit(doc, ...)` -- reads each panel's real solid through `liftexport.concrete_solid`. There is no way to compute a volume from a snapshot that stores only a bounding rectangle; that is the whole reason the audit exists. Offline, `load_audit_at(folder)` reads the RESULT it left on disk, which is all a suggestion needs. * `suggest_for_piece(doc, ...)` -- measures the target through the document, and resolves the index path through `reinforceindex.index_path` -> `designspec`, which imports `Autodesk.Revit.DB` at module scope. `suggest_from_index` is the whole of `suggest_for_piece` except those two steps, and it takes a cache instead of a document. Both of them only READ -- no Transaction, no parameter write, nothing in this feature writes to the model. """ # pylint: disable=invalid-name,broad-except from vibe import reinforcecore as core from vibe import reinforcematch as match # Revit-free, like the two above -- it reaches only `yamlkit` and `compat`. # It is here for `design_key`, which is this codebase's ONE answer to "are # these two design numbers the same design?" and which lived in `designspec` # (Revit at module scope) until 2026-09-01. See `design_family` below. from vibe import specformat # Two lengths this close are the same length as far as reinforcing goes: # 1/32 in in feet, under the 3-decimal rounding the index already applies. EXACT_TOL_FT = 0.003 # The same tolerance on the other side of the unit seam. `covers` returns # inches, so the "close but not exact" floor has to be quoted in inches too. EXACT_TOL_IN = core.inches(EXACT_TOL_FT) # 0.036 in # THE GRAIN, and the whole answer to "two decimals does not mean 0.01 in". # # The owner ruled the DISPLAY: "实际测量全部用英寸,带两位小数". Two decimal # inches is 0.01", which is an order of magnitude finer than any tolerance # this tool uses anywhere -- dimensions are checked at 3/8" (0.375") and # interference at 1/8" (0.125"). Printing a ranking driven by 0.01" would be # claiming a precision the measurement does not have, and this project has # been burned before by a criterion that measured confidently and wrongly. # # So the DISPLAY does what he asked and the MEASUREMENT does not pretend: # every non-exact distance `covers` returns is rounded to the nearest 1/8 in, # the finest tolerance the tool actually works to. Two consequences, both # wanted: # # * the ranking cannot be steered by a difference smaller than 1/16 in. # Two donors that differ by less than that rank EQUAL and fall through to # the next tie-break (MASTER, then input order) instead of being separated # by noise. # * a printed distance can only ever be a multiple of 1/8 in -- 0.13, 0.25, # 0.38, 0.50 ... -- so the two decimals the owner asked for are visibly # granular on the page. The report says the grain out loud as well # (`reinforcereport.GRAIN_NOTE`), but a reader who never reads a footnote # still cannot mistake this for 0.01 in resolution. # # EXACTNESS IS NOT QUANTIZED. Rounding to 1/8 in would make anything under # 1/16 in read as 0.0, i.e. as EXACT, and "exact" is the one word in this # report that has to keep meaning measured-identical. So the exact test stays # at EXACT_TOL (1/32 in) and quantization applies only above it, with a floor # so a non-exact match can never quantize down to zero. GRAIN_IN = 0.125 # A strip narrower than this is not concrete, it is two voids touching. MIN_STRIP_FT = 0.005 # Feature kinds. `covers` never compares across kinds -- a strip and an # opening are not the same thing however well their rectangles line up. OPENING = 'opening' STRIP_X = 'strip_x' # width measured along the piece's length STRIP_Y = 'strip_y' # width measured across the piece's width VOID_VOID = 'void-void' VOID_EDGE = 'void-edge' # ------------------------------------------------------------ the features def features_for(piece, outline_approximate=False, include_voids=True): """The feature list for one index piece record: ligaments, and the voids. Returns None when the piece was never measured (`features is None`) -- could-not-judge propagates rather than becoming an empty list. Returns a list, possibly empty, when it was measured. JUDGEMENT CALL, flagged for review: the voids themselves are kept as features alongside the ligaments (`include_voids`, default True). The reframe says the strip is what gets detailed, and it does; but an opening still needs trim bars sized to the opening, so dropping the hole entirely would lose a real requirement. Keeping both means a donor has to account for the target's holes AND the concrete between them. Pass `include_voids=False` to get ligaments only. """ voids = (piece or {}).get('features') if voids is None: return None L = _opt(piece.get('length_ft')) W = _opt(piece.get('width_ft')) out = [] if include_voids: for v in voids: out.append(_record(piece, OPENING, v['x0'], v['x1'], v['y0'], v['y1'], None, None, through=v.get('through'), concrete_in=v.get('concrete_in'), outline_approximate=outline_approximate)) if L is not None and W is not None: out.extend(_ligaments(piece, voids, L, W, outline_approximate)) for i, f in enumerate(out): f['index'] = i return out def _ligaments(piece, voids, L, W, outline_approximate): """Every strip of concrete bounded by two voids, or by a void and an edge. Both axes, because a strip between two voids stacked vertically is as real as one between two side by side, and the index records both axes precisely so this cannot be got wrong in one of them. """ out = [] n = len(voids) # void to void: an x-gap needs the two to overlap in y, and the reverse. for i in range(n): for j in range(i + 1, n): a, b = voids[i], voids[j] for lo, hi in ((a, b), (b, a)): if lo['x1'] <= hi['x0']: span0 = max(lo['y0'], hi['y0']) span1 = min(lo['y1'], hi['y1']) if span1 - span0 > MIN_STRIP_FT: _add(out, piece, STRIP_X, lo['x1'], hi['x0'], span0, span1, VOID_VOID, None, voids, (i, j), outline_approximate) if lo['y1'] <= hi['y0']: span0 = max(lo['x0'], hi['x0']) span1 = min(lo['x1'], hi['x1']) if span1 - span0 > MIN_STRIP_FT: _add(out, piece, STRIP_Y, span0, span1, lo['y1'], hi['y0'], VOID_VOID, None, voids, (i, j), outline_approximate) # void to edge, all four edges of the piece. for i in range(n): v = voids[i] _add(out, piece, STRIP_X, 0.0, v['x0'], v['y0'], v['y1'], VOID_EDGE, 'x0', voids, (i,), outline_approximate) _add(out, piece, STRIP_X, v['x1'], L, v['y0'], v['y1'], VOID_EDGE, 'x1', voids, (i,), outline_approximate) _add(out, piece, STRIP_Y, v['x0'], v['x1'], 0.0, v['y0'], VOID_EDGE, 'y0', voids, (i,), outline_approximate) _add(out, piece, STRIP_Y, v['x0'], v['x1'], v['y1'], W, VOID_EDGE, 'y1', voids, (i,), outline_approximate) out.sort(key=lambda f: (f['kind'], f['x0'], f['y0'], f['x1'], f['y1'])) return out def _add(out, piece, kind, x0, x1, y0, y1, between, edge, voids, owners, outline_approximate): """Emit one strip, if it is really concrete: wide enough, and clear.""" if x1 - x0 <= MIN_STRIP_FT or y1 - y0 <= MIN_STRIP_FT: return # no concrete here to detail if not _clear(x0, x1, y0, y1, voids, owners): return # a third void sits in the strip out.append(_record(piece, kind, x0, x1, y0, y1, between, edge, outline_approximate=outline_approximate)) def _clear(x0, x1, y0, y1, voids, owners): """No void may overlap the strip's interior. This is what keeps a ligament a thing you can point at on the piece.""" for k in range(len(voids)): if k in owners: continue v = voids[k] if (v['x0'] < x1 - MIN_STRIP_FT and v['x1'] > x0 + MIN_STRIP_FT and v['y0'] < y1 - MIN_STRIP_FT and v['y1'] > y0 + MIN_STRIP_FT): return False return True def _record(piece, kind, x0, x1, y0, y1, between, edge, through=None, concrete_in=None, outline_approximate=False): x0, x1 = _r(x0), _r(x1) y0, y1 = _r(y0), _r(y1) return { 'piece': piece.get('name'), 'kind': kind, 'index': None, 'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1, 'w': _r(x1 - x0), 'h': _r(y1 - y0), 'cx': _r((x0 + x1) / 2.0), 'cy': _r((y0 + y1) / 2.0), # For a strip, the width that matters is the one across it. For an # opening it is not meaningful and stays None. 'strip_ft': (None if kind == OPENING else _r(x1 - x0 if kind == STRIP_X else y1 - y0)), 'span_ft': (None if kind == OPENING else _r(y1 - y0 if kind == STRIP_X else x1 - x0)), 'between': between, 'edge': edge, 'through': through, 'concrete_in': concrete_in, 'piece_length_ft': piece.get('length_ft'), 'piece_width_ft': piece.get('width_ft'), 'piece_thickness_in': piece.get('thickness_in'), 'outline_approximate': bool(outline_approximate), } def _r(v, places=3): return None if v is None else round(float(v), places) def _opt(value): if value is None: return None try: return float(value) except (TypeError, ValueError): return None # --------------------------------------------------------------- the compare def _quantize(distance_in): """A non-exact distance, on the 1/8 in grain, never rounded down to exact. Below `EXACT_TOL_IN` this is never called -- that case returned 0.0 already. Above it, anything that would land on zero is floored at `EXACT_TOL_IN` instead, so "off by a hair" and "identical" stay two different answers however coarse the grain gets. """ steps = int(distance_in / GRAIN_IN + 0.5) got = steps * GRAIN_IN if got < EXACT_TOL_IN: return EXACT_TOL_IN return got def covers(donor_feature, target_feature): """The production `covers`. None / 0.0 / a positive distance in INCHES. Meets the matcher's seven requirements as follows. TOTAL (1): any pair that cannot be read returns None. It never raises. PURE (2): arithmetic on the two records and nothing else. DIRECTIONAL (3): the strip-width rule below is one-way on purpose. COMMENSURABLE (4): one unit, INCHES, for every kind. The distance is a sum of absolute lengths, so 0.5 on a strip means what 0.5 means on an opening. No per-kind weighting exists and none is invented: the owner ruled the unit, not a weighting. ABSOLUTE (5): every coordinate on the RECORD is feet from the start of the piece -- the index stores Revit's internal unit and nothing converts it. Nothing here divides by a panel dimension. TWO-DIMENSIONAL (6): both the centre test and the distance use x and y. CHEAP (7): a few floats. COVERS AT ALL, in four conditions: a. Same kind. An opening is not a strip, and a strip whose width runs along the panel is not one whose width runs across it, however well the rectangles line up. b. Each rectangle contains the other's centre. Two-dimensional on purpose: measured on this model, two voids share an x-range while sitting in different height bands, and an x-only test calls those the same feature. It also does the size sanity for free. c. For strips, the donor's strip may be NARROWER than the target's or equal, never wider (beyond tolerance). This is the directional part and it is the one piece of reinforcing judgement in the test: bars detailed for a narrow strip fit in a wider one with room to spare, while a cage laid out for a wide strip does not fit into concrete that is not there. NOTE, because I said the opposite in an earlier reassessment and it was wrong: the permissive direction is the narrow donor, not the wide one. The owner's own concern -- a little column section that cannot develop a strand -- is about narrow strips, and a donor that solved a narrow one is a good template for a wider one. d. Penetration must not CONTRADICT, for openings. A through opening and a blind pocket are different reinforcing: the pocket leaves concrete behind it that carries load and takes its own trim. `through=None` is COULD NOT JUDGE, never False, and is never compared as if it were -- a pair with an unjudged side passes here, on the grounds that nothing was measured to contradict it, and `explain()` reports it as unjudged. DISTANCE is the sum of the four absolute corner offsets. Zero for identical rectangles. The edge-condition information that an earlier version tried to bolt on as extra distance terms is not needed: the edge ligaments ARE that information, as their own features. THE UNIT SEAM IS THIS FUNCTION AND ONLY THIS FUNCTION. The two records come in as feet -- that is what `reinforcecore` stores, because feet is Revit's internal unit and converting on the way in is one more chance at a 12x error. The sum is computed in feet, converted ONCE by `reinforcecore.inches()`, and everything above this line -- the matcher's distance vectors, `Proposal.total_distance`, the report -- is inches and is never converted again. See the seam note in `reinforcecore`. THE GRAIN. The returned distance is quantized to 1/8 in, the finest tolerance this tool uses. See `GRAIN_IN` above for why, and for why exactness is deliberately left off the grain. THE APPROXIMATION FLOOR. A feature on a piece whose outline could only be approximated (see the module docstring) and which touches a panel edge can never return 0.0. It returns EXACT_TOL_IN instead, so it ranks immediately behind every genuinely exact match and can never be reported as one. That is not a fudge factor on the geometry; it is the refusal to claim an exact edge match on an edge nobody measured. """ d = _read(donor_feature) t = _read(target_feature) if d is None or t is None: return None if d['kind'] != t['kind']: return None if not _holds_centre(d, t['cx'], t['cy']): return None if not _holds_centre(t, d['cx'], d['cy']): return None if d['kind'] != OPENING: if d['strip'] is None or t['strip'] is None: return None if d['strip'] > t['strip'] + EXACT_TOL_FT: return None # donor cage will not fit elif _through_contradicts(donor_feature, target_feature): return None # Computed in feet, because that is what the records are in. dist_ft = (abs(d['x0'] - t['x0']) + abs(d['x1'] - t['x1']) + abs(d['y0'] - t['y0']) + abs(d['y1'] - t['y1'])) if dist_ft < EXACT_TOL_FT: if _edge_approximate(donor_feature, target_feature): return EXACT_TOL_IN # close, but not claimed as exact return 0.0 # The one conversion. Feet below, inches above, and nothing converts twice. return _quantize(core.inches(dist_ft)) def _read(feature): try: x0 = float(feature['x0']) x1 = float(feature['x1']) y0 = float(feature['y0']) y1 = float(feature['y1']) kind = feature['kind'] except (TypeError, ValueError, KeyError, IndexError): return None strip = _opt(_get(feature, 'strip_ft')) return {'kind': kind, 'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1, 'cx': (x0 + x1) / 2.0, 'cy': (y0 + y1) / 2.0, 'strip': strip} def _holds_centre(rect, cx, cy): return (min(rect['x0'], rect['x1']) <= cx <= max(rect['x0'], rect['x1']) and min(rect['y0'], rect['y1']) <= cy <= max(rect['y0'], rect['y1'])) def _through_contradicts(donor_feature, target_feature): """True only when both sides were judged AND they disagree. `None` is could-not-judge. It is not False and is never read as one. """ dt = _get(donor_feature, 'through') tt = _get(target_feature, 'through') if dt is None or tt is None: return False return bool(dt) != bool(tt) def _edge_approximate(donor_feature, target_feature): """Either side an approximated outline, and the comparison touches an edge.""" for f in (donor_feature, target_feature): if not _get(f, 'outline_approximate'): continue if _get(f, 'between') == VOID_EDGE or _get(f, 'kind') == OPENING: return True return False def _get(record, key): try: return record.get(key) except AttributeError: return None # -------------------------------------------------------- the design number # # THE HARD RULE. Owner, 2026-09-01: "选择母版的目标,必须得是跟自己的design # number一样的panel。我刚在一个WP2.0上面跑了reinforce,给我俩WP1.0的母版,压根 # 用不了。" 必须 -- so this is a FILTER, not a term in the ranking. A WP1.0 # donor offered for a WP2.0 target is not a weaker suggestion that a penalty # could push down the list; it is an answer the engineer cannot use at all, # and a ranking that can be overruled by geometry would put one back on top # the moment its openings happened to line up. # # HOW TWO DESIGN NUMBERS ARE COMPARED: through `specformat.design_key`, which # is this codebase's one answer to that question and was already the rule # Ticket QC checks a piece against its ticket with (`_check_design_number`). # It reduces a packet's page suffixes away -- PNL1, PNL1.0, PNL1.A and PNL1.B1 # are all the key PNL1 -- which is the owner's established nomenclature # ruling: a packet suffix and a `.A` note are not separate designs. It does # NOT merge PNL1 with PNL2, which is exactly the distinction he was handed # wrongly. # # WHY THE PATTERN IS A PARAMETER. The reduction is plant knowledge # (`nomenclature.design_number_re`), and this module may not import # `knowledge` -- it has to run in `tools/reinforce_offline.py` with no model # and therefore no knowledge layer. So `suggest_for_piece` resolves the # pattern from the job's knowledge and hands it in; offline it is None and # `design_key` falls back to its built-in pattern, which is what an unmapped # nomenclature has always produced everywhere else in this codebase. # # NONE IS NOT A DESIGN. `design_family` returns None both for a piece with no # design number and for one whose number the pattern cannot read. Two Nones # are NOT a match here -- see `_design_reject` -- because "neither of these was # read" is not "these are the same". def design_family(value, pattern=None): """The design family key for one design number, or None if unreadable. Thin, and deliberately so: it exists to name the rule at the call site and to keep `specformat` the only implementation of it. """ return specformat.design_key(value, None, pattern) def design_pattern(know): """The plant's design-number pattern out of a knowledge tree, or None. The literal key is written out here rather than reached through a constant so `tools/knowledge_drift.py` credits it with a reader; see the same note in `reinforceindex.master_token`. None -- no knowledge layer, or a plant that has not set it -- means `design_key`'s built-in fallback, which is what every other reader of this key already does. """ if not know: return None from vibe import knowledge return knowledge.get(know, 'nomenclature.design_number_re', None) # ------------------------------------------------------ the elevation affinity # # THE LAST TIE-BREAK, RULED 2026-09-02. WP425 ranked five donors dead level # on every other criterion -- 1 donor, 5/5 covered, 4 exact, 0 reviewed, # 0.04 in total distance, all five identical -- and the tie fell through to # mark order, so WP133 printed first though WP234 shares WP425's own # building elevation: owner, "WP234明明在同一个elevation,为什么排名没有 # 提前?" Approved as briefed the same day ("同意,就这么做"): elevation is # an AFFINITY, never a filter -- a cross-elevation donor with better # coverage still wins, exactly the standing correction on donor order this # module already carries for the design-number rule. See # `reinforcematch`'s ranking notes for where it sits in the comparator. # # WHAT "SAME ELEVATION" MEANS, settled in three passes the same day. First: # a hand-typed parameter (`panel.elevation`) -- wrong, reversed. Second: the # owner ruled it PURELY GEOMETRIC ("elevation是纯几何问题"). Third, the # scope correction once MC's real model turned up a usable grouping # parameter (`Tagged_Sheet`) that not every job has: geometry is the FLOOR, # always computed and always correct; a plant-declared grouping key is an # OPTIONAL ACCELERATOR checked first where it exists. See # `reinforcecore.elevation_match`'s module note for the whole story -- # this function is just the caller, via `core.elevation_match`, which # returns which of the two sources answered so the report can say. # # UNKNOWN IS NO AFFINITY, NEVER A MATCH, from EITHER source. # `core.elevation_match` never treats two could-not-judge values as # agreeing -- a target with no signal at all prefers nobody, and a donor # with no signal is never preferred even when the target's is known. Same # discipline `_design_reject` already applies to design numbers, one level # down in consequence: this is a ranking term, not a filter, so getting it # wrong here means a worse ORDER, never a lost donor. def _elevation_affinity(target, kept): """(target's own elevation signal, {donor name, ...} sharing it, {donor name: source, ...}). `target`'s signal is `{'group': ..., 'band': ...}` -- whatever the target itself carries of each, for the report to describe even when nothing in the pool matches. `source` is `core.ELEVATION_BY_GROUP` or `core.ELEVATION_BY_BAND`, naming which one actually decided that donor. `kept` is `select_donors`'s first return -- the pool AFTER the filter, so a donor already rejected for design or thickness is never considered for the affinity either; there is nothing to prefer among candidates that were never offered. """ target = target or {} target_group = core.elevation_group_key(target.get('elevation_group')) target_band = target.get('elevation_band') same = set() sources = {} if target_group is not None or target_band: for rec in kept or (): source = core.elevation_match(target, rec or {}) if source is not None: donor_name = (rec or {}).get('name') same.add(donor_name) sources[donor_name] = source return {'group': target_group, 'band': target_band}, same, sources # ---------------------------------------------------------------- the filter def _target_measured_empty(target): """True only when the TARGET was positively measured to have no voids. `features == []` and not `features is None` -- the project's cardinal distinction, re-asserted here because this is the one place a FALSE positive on it (reading a never-measured target as a featureless one) would silently widen a filter instead of narrowing it. See `DonorFilter.require_features`. """ feats = (target or {}).get('features') return feats is not None and not feats # Owner, verbatim (2026-09-01), hit on a real piece: "如果扫描到目标没有 # opening,那么就找另一块没有opening的板子给target当doner。" -- if the scan # finds the target has no opening, find another panel with no opening to be # the target's donor. # Owner, verbatim (2026-09-01), the very next sentence: "在这个过程当中,很 # 多其他东西依然是有用的,比如说design number。" -- in this process a lot of # other things are still useful, such as design number. class DonorFilter(object): """Which indexed pieces may stand in for this target. A named predicate. Every rule is a judgement call, stated so it can be argued with and changed in one place rather than buried in a comprehension. prefixes = None (the DECLARED SCOPE decides) Which marks may donate. Owner's ruling, 2026-08-30: a splitter for wall panels filters to wall panels -- a beam's or a slab's reinforcing has nothing to do with a panel's, and on the job this was measured on the majority of eligible pieces were exactly that. The ruling has not changed; where it is WRITTEN DOWN has. It used to be this default. That made one job's division of labour a constant in a shareable repository, which is the same unauthorised exclusion the rest of this codebase exists to prevent, one layer further back. `suggest_for_piece` now fills this in from `vibe.scope` -- the scope the JOB file declares -- and `None` here means no prefix rule at all, which is what an undeclared scope correctly produces. Every rejection is still counted and named in the report either way. match_thickness = True, thickness_tol_in = 0.01 The donor panel must be the same thickness as the target. The strongest of the geometric rules: thickness sets the number of bar layers, the cover top and bottom, and where the strand sits, so an 8 in cage cannot be transplanted into a 12 in panel however well the voids line up. In this model the WP pool is 244 at 8 in and 11 at 12 in -- two populations that should never mix. High confidence. max_width_diff_ft = None (off) Width -- a wall panel's height -- is the ARGUABLE one and it is off by default. For: a taller panel carries more longitudinal bars and more strand rows, so its sheet is not a template for a short one. Against, and this is why it is off: the comparison is now per-ligament, and a strip 2 ft off the bottom of a 12 ft panel is detailed like the same strip 2 ft off the bottom of a 9 ft panel. Turning it on can only remove donors the matcher would have ranked on their merits, against the owner's standing correction that a filter must never exclude a better-shaped donor. THIS IS THE ONE TO REVIEW. Set feet to enable. require_features = True A donor measured to have no voids (`features == []`) has no ligaments either and can cover nothing, so it is dropped before the compare rather than after: arithmetic, not judgement. A donor that was NEVER measured (`features is None`) is dropped for a different reason and under a different label -- letting one in would silently remove a candidate rather than flag it. THE RULE INVERTS WHEN THE TARGET ITSELF HAS NO VOIDS. Owner, hit on a real piece 2026-09-01: "if the scan finds the target has no opening, find another panel with no opening to be the target's donor." The reasoning above only holds when the TARGET has openings to cover -- a featureless donor genuinely has nothing to offer a panel that has something to cover. A target measured to have NO openings is the opposite case: a panel with no openings IS the right donor for a panel with no openings, and this rule was discarding exactly that pool. So `reject` now reads the TARGET's own `features` (already on the record it is handed) and skips this rejection when the target was MEASURED and came back empty (`features == []`, never `features is None` -- see point 4 below). Every other rule in this filter stays in force: design, thickness, prefix and self-exclusion are unaffected, because the owner's very next sentence ruled that out explicitly: "in this process a lot of other things are still useful, such as design number." The ranking among the survivors -- and what to do with a donor that DOES have voids when the target has none -- is `reinforcebind.shape_search_for`, not this filter; a filter decides who is IN the pool, not their order. A target that was never measured (`features is None`) must NOT take this path -- that is could-not-judge, not measured-empty. `reject` is called with the raw target record regardless of its `target_status` (`select_donors` always runs, even though `propose` goes on to refuse an unjudged target before using the result), so the check here is conservative on purpose: it inverts only on a positive empty-list read of the target, never on an absent one. exclude_self = True A piece is not its own donor; it would win every comparison exactly and tell the engineer nothing. match_design = True, design_re = None THE HARD ONE, and the only rule here the owner has stated as mandatory (2026-09-01). A donor must carry the SAME DESIGN NUMBER as the target, reduced through `design_family` so a packet suffix or a `.A` note does not split one design into several. It is not a ranking penalty and it may not be turned into one: a WP1.0 donor for a WP2.0 target is unusable however well its openings line up. It is on by default and there is no knob on the button to turn it off. `match_design=False` exists for one purpose -- letting a test state what the ranking does INSIDE one design without having to give every fixture a design number -- and switching it off in production would reintroduce the exact defect. `design_re` is the plant's `nomenclature.design_number_re`, handed in rather than read, because this module may not import `knowledge` (see the note above `design_family`). None means the built-in fallback. THREE OUTCOMES, not two. `design` is a judged mismatch. `design_unknown` is a donor whose own design number could not be read, and it is a REJECTION, never a pass: an unread field that defaulted to matching is how this defect got shipped. `design_target_unknown` is the target's own number missing, which rejects everybody -- and `propose` refuses before it ever gets that far, so the report says it once instead of six hundred times. """ def __init__(self, prefixes=None, match_thickness=True, thickness_tol_in=0.01, max_width_diff_ft=None, require_features=True, exclude_self=True, match_design=True, design_re=None): self.prefixes = tuple(prefixes) if prefixes else None self.match_thickness = match_thickness self.thickness_tol_in = thickness_tol_in self.max_width_diff_ft = max_width_diff_ft self.require_features = require_features self.exclude_self = exclude_self self.match_design = match_design self.design_re = design_re def reject(self, donor, target): """None to accept, else a short reason string, so every rejection can be counted and named.""" donor = donor or {} target = target or {} name = donor.get('name') if self.exclude_self and name == target.get('name'): return 'self' feats = donor.get('features') if feats is None: return 'unjudged' if (self.require_features and not feats and not _target_measured_empty(target)): return 'no_features' if not is_eligible(donor): return 'not_eligible' if self.prefixes is not None and not _has_prefix(name, self.prefixes): return 'prefix' # AFTER the scope, before the geometry. After, so another engineer's # beams are still reported as out of scope rather than as a design # mismatch -- they were never candidates and the pool table should # keep saying so. Before, because design is 必须 and thickness is a # judgement: when a piece fails both, the harder rule is the one worth # printing. if self.match_design: got = self._design_reject(donor, target) if got is not None: return got if self.match_thickness: got = _diff(donor.get('thickness_in'), target.get('thickness_in')) if got is None: return 'thickness_unknown' if got > self.thickness_tol_in: return 'thickness' if self.max_width_diff_ft is not None: got = _diff(donor.get('width_ft'), target.get('width_ft')) if got is None: return 'width_unknown' if got > self.max_width_diff_ft: return 'width' return None def _design_reject(self, donor, target): """None to accept on design grounds, else which of the three it is. Two unreadable design numbers are NOT a match. `None == None` is true in Python and false in precast: it would mean two pieces nobody read were declared the same design, which is the whole defect. """ want = design_family(target.get('design_number'), self.design_re) if want is None: return 'design_target_unknown' got = design_family(donor.get('design_number'), self.design_re) if got is None: return 'design_unknown' if got != want: return 'design' return None def accepts(self, donor, target): return self.reject(donor, target) is None def is_eligible(record): """The index's own donor rule, re-asserted on a record from any source.""" from vibe import reinforcecore as core return core.is_eligible((record or {}).get('reinforced'), (record or {}).get('on_hold')) def _has_prefix(name, prefixes): if not name: return False for p in prefixes: if name.startswith(p): return True return False def _diff(a, b): if a is None or b is None: return None try: return abs(float(a) - float(b)) except (TypeError, ValueError): return None # ------------------------------------------------ the design-number verdict # # Five answers, and only one of them lets a suggestion be printed. The four # refusals are all "could not judge" or "judged, and the answer is no" -- the # project's standing distinction -- and none of them may degrade into offering # a donor of another design, even labelled as one. There is no fallback here # on purpose: a labelled cross-design suggestion is still the thing the owner # could not use, and a label is exactly what a hurried reader skips. # The vocabulary is `reinforcecore`'s, so that `reinforcereport` can name # these statuses without importing this module. Re-exported here under the # same names because this is where the rule that produces them lives, and a # reader of `design_info` should not have to go two files to see them. # # DESIGN_OK at least one candidate is the same design. # DESIGN_TARGET_UNKNOWN the target's own design number is missing or # unreadable. Nothing can be filtered against a # design nobody knows. # DESIGN_INDEX_NOT_RECORDED the index does not carry the field at all. # DESIGN_INDEX_UNREAD it carries the field and not one candidate has # a value in it: the role is unmapped, or the # build ran without a knowledge layer. # DESIGN_NONE_MATCH everything was read; there is no donor of this # design. DESIGN_OK = core.DESIGN_OK DESIGN_TARGET_UNKNOWN = core.DESIGN_TARGET_UNKNOWN DESIGN_INDEX_NOT_RECORDED = core.DESIGN_INDEX_NOT_RECORDED DESIGN_INDEX_UNREAD = core.DESIGN_INDEX_UNREAD DESIGN_NONE_MATCH = core.DESIGN_NONE_MATCH DESIGN_REFUSALS = core.DESIGN_REFUSALS DESIGN_UNREADABLE = core.DESIGN_UNREADABLE # Reasons `DonorFilter.reject` can give BEFORE it ever looks at the design. # A piece dropped for one of these was never a candidate for this target, so # its design number says nothing about what the pool held -- another # engineer's beam is not evidence that this job has no WP2 donor. PRE_DESIGN_REASONS = ('self', 'unjudged', 'no_features', 'not_eligible', 'prefix') def design_info(target, records, rejected, donor_filter=None): """What the design rule did, as data the report turns into a sentence. `rejected` is `select_donors`' second return. The candidate pool is reconstructed from it rather than re-filtered, so this can never disagree with the filter that actually ran. """ donor_filter = donor_filter or DonorFilter() pattern = donor_filter.design_re raw = (target or {}).get('design_number') want = design_family(raw, pattern) dropped_early = set() for reason in PRE_DESIGN_REASONS: for nm in (rejected or {}).get(reason) or (): dropped_early.add(nm) # EVERY NUMBER BELOW IS ABOUT CANDIDATES, not about the whole index, and # that is what keeps this rule from claiming findings that belong to # somebody else. A pool emptied by the scope, by eligibility or by a piece # nobody measured was emptied BEFORE the design test ran; saying "no donor # of design WP2 exists" about a pool that never contained a single # candidate would blame the design rule for another rule's work, and would # bury the real reason under a confident sentence. candidates = [rec or {} for rec in (records or []) if (rec or {}).get('name') not in dropped_early] seen = {} same = [] unreadable = [] for rec in candidates: key = design_family(rec.get('design_number'), pattern) if key is None: unreadable.append(rec.get('name')) seen[DESIGN_UNREADABLE] = seen.get(DESIGN_UNREADABLE, 0) + 1 continue seen[key] = seen.get(key, 0) + 1 if want is not None and key == want: same.append(rec.get('name')) pool = core.design_status(candidates) if not donor_filter.match_design or not candidates: # No candidates: the design rule never ran, and NO_DONORS is the # finding. `ran` says so, so a reader of `info` cannot mistake this # DESIGN_OK for "the design was checked and is fine". status = DESIGN_OK elif pool == core.DESIGN_NOT_RECORDED: status = DESIGN_INDEX_NOT_RECORDED elif pool == core.DESIGN_UNREAD: status = DESIGN_INDEX_UNREAD elif want is None: status = DESIGN_TARGET_UNKNOWN elif not same: status = DESIGN_NONE_MATCH else: status = DESIGN_OK return { 'status': status, 'refused': status in DESIGN_REFUSALS, 'enforced': bool(donor_filter.match_design), 'ran': bool(donor_filter.match_design and candidates), 'target': raw, 'target_key': want, 'index_status': pool, # What survived everything the filter asks BEFORE the design, which is # the honest denominator for "designs seen". 'candidates': len(candidates), 'considered': len(records or []), 'same_design': sorted(n for n in same if n), 'unreadable': sorted(n for n in unreadable if n), 'designs_seen': seen, } def select_donors(records, target, donor_filter=None): """(kept, rejected). `rejected` is {reason: [name, ...]} -- a pool that shrank from 690 to 200 without saying why is not a filter.""" donor_filter = donor_filter or DonorFilter() kept, rejected = [], {} for rec in records or []: why = donor_filter.reject(rec, target) if why is None: kept.append(rec) else: rejected.setdefault(why, []).append(rec.get('name')) kept.sort(key=lambda r: r.get('name') or '') for names in rejected.values(): names.sort() return kept, rejected # --------------------------------------------------- the featureless target # # WHAT HAPPENS WHEN THE TARGET HAS NO OPENINGS AT ALL. # # `DonorFilter.reject` stops excluding a featureless donor once the target is # itself featureless (see the note on `require_features`), so the pool # `select_donors` returns for that target is no longer empty by construction # -- but there is nothing left for `reinforcematch.suggest`'s set-cover search # to do with it: `target_features` is `[]`, there is nothing to cover, and # zero donors are needed to cover nothing. Set cover is the wrong question # here. The right one -- "which of these is the closest TEMPLATE, having # nothing to cover?" -- has no ligaments to compare, so it is answered with # whatever the filter left standing to compare: design (already forced equal # by the hard filter), thickness, length and width. # # TWO TIERS, NOT A FLAT RANKING. A donor that is ALSO featureless is an exact # shape twin as far as this tool can tell -- nothing about its cage was driven # by a void the target does not have. A donor that DOES carry openings is a # real but weaker candidate: the reinforcing it built around its own voids is # irrelevant to this target, but the rest of its cage (the field steel that # has nothing to do with an opening) may still be a fine template. Owner's # brief left this one to be decided rather than ruling on it; the position # taken here is INCLUDE, RANKED BELOW -- dropping them would silently narrow # the pool again, in the same shape as the defect this module exists to fix, # and "irrelevant to this target" is not the same claim as "useless to this # target". They are never allowed to outrank a featureless donor, which is # the one thing that would make this worse than saying nothing. # # THE RANKING ITSELF. Within a tier, ordered by absolute difference from the # target on thickness, then length, then width -- thickness first because # `DonorFilter`'s own docstring already ranks it "the strongest of the # geometric rules"; design is left out because the filter has already forced # it equal, so ranking on it again would be decoration. DEGENERACY IS # EXPECTED, not a bug to route around: this codebase's own nomenclature # convention is that a design number identifies a specific sheet, so two # panels of the same design very often share the same length, width and # thickness exactly, and the "ranking" is then a coin flip on a difference # smaller than anything this tool measures to. `_shape_degenerate` catches # that case and the report is told to say "indistinguishable", not to print # an order it cannot defend -- the same discipline `covers`' GRAIN applies to # distances that are close but not exact, one level up. SHAPE_FEATURELESS = 'featureless' SHAPE_HAS_FEATURES = 'has_features' _SHAPE_TIER_RANK = {SHAPE_FEATURELESS: 0, SHAPE_HAS_FEATURES: 1} # The tie-break precision: differences at or under this (in the same units # the comparison already uses -- inches for thickness, feet for length and # width) are not a finding, they are noise the rest of this tool already # refuses to rank on. Reusing `EXACT_TOL_FT`/`EXACT_TOL_IN` rather than a new # number keeps this consistent with `covers`' own exactness floor instead of # inventing a second opinion about how fine a measurement can be trusted. def _shape_diff(donor_value, target_value): """Absolute difference, or None when either side is unreadable. None is COULD NOT COMPARE, not "no difference" -- it must sort as the worst case, never as a tie for first, or an unmeasured donor would win the ranking by default. """ if donor_value is None or target_value is None: return None try: return abs(float(donor_value) - float(target_value)) except (TypeError, ValueError): return None def _shape_row(target, rec): feats = rec.get('features') return { 'name': rec.get('name'), 'tier': SHAPE_HAS_FEATURES if feats else SHAPE_FEATURELESS, 'design_number': rec.get('design_number'), 'thickness_diff_in': _shape_diff(rec.get('thickness_in'), target.get('thickness_in')), 'length_diff_ft': _shape_diff(rec.get('length_ft'), target.get('length_ft')), 'width_diff_ft': _shape_diff(rec.get('width_ft'), target.get('width_ft')), 'master': rec.get('master'), } def _shape_sort_key(row): def key(v): return v if v is not None else float('inf') return (_SHAPE_TIER_RANK[row['tier']], key(row['thickness_diff_in']), key(row['length_diff_ft']), key(row['width_diff_ft']), 0 if row.get('master') else 1, row['name'] or '') def _shape_degenerate(rows): """True when the FEATURELESS tier cannot be told apart on shape. Fewer than two rows is not degenerate -- there is nothing to rank between, which is a plain single answer, not a tie. Two or more whose thickness, length and width all land within this tool's own exactness floor are a tie the ranking cannot honestly break, and the report has to say so rather than present sort order as if it meant something. """ if len(rows) < 2: return False def rounded(row): return tuple( (round(row[k], 3) if row[k] is not None else None) for k in ('thickness_diff_in', 'length_diff_ft', 'width_diff_ft')) return len(set(rounded(r) for r in rows)) == 1 def shape_search_for(target, kept): """The ranked pool for a target measured to have NO openings. `kept` is `select_donors`'s first return -- the pool AFTER the filter, which by now already includes featureless donors (the inversion above) alongside any donor that has openings of its own. This just tiers and ranks what the filter already decided was eligible; it rejects nobody. """ rows = [_shape_row(target, rec) for rec in (kept or [])] rows.sort(key=_shape_sort_key) eligible = [r for r in rows if r['tier'] == SHAPE_FEATURELESS] secondary = [r for r in rows if r['tier'] == SHAPE_HAS_FEATURES] return { 'ran': True, 'considered': len(kept or ()), 'eligible': eligible, 'secondary': secondary, 'degenerate': _shape_degenerate(eligible), } # ------------------------------------------------------------- the whole run def propose(target, records, donor_filter=None, approximate=None, include_voids=True, max_proposals=match.DEFAULT_MAX_PROPOSALS, max_donors=match.DEFAULT_MAX_DONORS): """Filter, build features, match. Returns (suggestion, info). Revit-free. `max_donors` is how many donors ONE proposal may contain -- four, by the owner's ruling. `max_proposals` is how many proposals come back. They are different axes and are passed through untouched. `target` is an index piece record. `records` is every candidate -- pass `reinforcecore.donors(cache)`. `approximate` is the set of piece names whose outline could only be approximated (see `outline_audit`). `suggestion` is None when there was nothing to judge, and `info['target_status']` says which nothing it was: `unjudged` (never measured) or `no_features` (measured, genuinely nothing to reinforce). For `no_features`, `info['shape_search']` carries a real search anyway -- see `shape_search_for` -- because "nothing to cover" is not "nothing to say"; the target's own emptiness makes a featureless donor the right template, and this is where that answer comes from. IT IS ALSO None WHEN THE DESIGN RULE REFUSES, and `info['design']['status']` says which of the four refusals it was. That is not a fifth way of saying "no suggestions": a target whose design has no donor in the index gets NOTHING rather than the nearest other design, by ruling. See `design_info` and `DonorFilter.match_design`. """ donor_filter = donor_filter or DonorFilter() approximate = set(approximate or ()) kept, rejected = select_donors(records, target, donor_filter) design = design_info(target, records, rejected, donor_filter) target_elevation, same_elevation, elevation_sources = _elevation_affinity( target, kept) target_features = features_for( target, (target or {}).get('name') in approximate, include_voids) info = { 'target': (target or {}).get('name'), 'target_status': _status(target, target_features), 'target_approximate': (target or {}).get('name') in approximate, 'target_features': target_features, 'design': design, 'considered': len(records or []), 'donors': len(kept), 'donor_names': [r.get('name') for r in kept], 'rejected': dict((k, len(v)) for k, v in rejected.items()), 'rejected_names': rejected, 'approximate_in_pool': sorted( r.get('name') for r in kept if r.get('name') in approximate), # The reviewed templates IN THIS POOL, after the filter. Reported # separately from the index-wide count because a MASTER that the # thickness filter dropped is not one this answer could have used. 'master_donors': sorted(r.get('name') for r in kept if r.get('master') is True), # NOT RUN, not "none found": the plant has not mapped # `parameters.member.identity_description`, so no piece in this pool # was ever read for the marker. An empty `master_donors` means two # completely different things depending on this flag and the report # must never collapse them. 'master_unjudged': bool(kept) and all(r.get('master') is None for r in kept), # The target's own elevation signal -- `{'group': ..., 'band': ...}`, # geometry always present, the plant-declared group only where one # is mapped and set (see `reinforcecore.elevation_match`) -- and # which of THIS POOL shares it: the LAST ranking tie-break # (`reinforcematch`'s `same_elevation` term). Both being falsy means # "could not be determined", never "nothing matches"; see # `_elevation_affinity`. 'target_elevation': target_elevation, 'same_elevation_donors': sorted(same_elevation), # Which SOURCE decided each matching donor -- # `core.ELEVATION_BY_GROUP` ("same sheet") or `core.ELEVATION_BY_BAND` # ("same geometry") -- so the report can say why rather than just # that. Keyed by donor name; a name absent from `same_elevation_donors` # is absent here too. 'same_elevation_sources': elevation_sources, 'features_by_donor': {}, } if not target_features: # A MEASURED-EMPTY TARGET STILL GETS A SEARCH. `suggest` (the set-cover # matcher) has nothing to do with zero target features -- there is # nothing to cover -- but that is not the same as nothing to say: the # donor pool above already inverted `require_features` for exactly # this target, and `shape_search_for` is what turns that pool into an # answer. An UNJUDGED target (`features is None`) must not take this # path -- `_status` already told them apart, and only the genuinely # measured-empty one gets searched. if info['target_status'] == 'no_features': info['shape_search'] = shape_search_for(target, kept) return None, info # THE HARD FILTER'S REFUSAL, and it is deliberately here -- after the # target has been measured, so the report can still say what the piece is # and how current the index was, and before any donor's features are built, # so not one cross-design comparison is ever made. if design['refused']: return None, info donors = [] for rec in kept: feats = features_for(rec, rec.get('name') in approximate, include_voids) info['features_by_donor'][rec.get('name')] = feats donors.append((rec.get('name'), feats or [])) info['donor_cap'] = max_donors suggestion = match.suggest(target_features, donors, covers, max_proposals=max_proposals, masters=info['master_donors'], same_elevation=same_elevation, max_donors=max_donors) return suggestion, info def _status(target, feats): if not target: return 'no_target' if target.get('features') is None: return 'unjudged' # never measured -- NOT "has no features" if not feats: return 'no_features' # measured, genuinely nothing to cover return 'ok' def explain(suggestion, info, proposal=None): """One row per target feature: what covered it, how far off, and whether the answer rests on an approximation or on an unjudged penetration.""" proposal = proposal or (suggestion.best if suggestion else None) if proposal is None: return [] feats = info.get('target_features') or [] by_donor = info.get('features_by_donor') or {} # THE FEATURE VOCABULARY, read-only. Deserialised once here off whatever # `suggest_from_index` stamped on `info` (the cache's plain registry dict). # A target NEVER grows the vocabulary -- `Registry.lookup` is read-only, so # a target shape no donor ever produced projects to None, which is the # informative "no donor carries this shape" signal (surfaced as # uncovered/PARTIAL), not an error. Donor feature-IDs come off # `core.feature_ids_of`, the read-only projection over a record's features. registry = core.Registry.deserialize(info.get('registry')) out = [] for i in range(len(feats)): tf = feats[i] row = { 'target_index': i, 'kind': tf.get('kind'), 'between': tf.get('between'), 'edge': tf.get('edge'), 'rect': (tf.get('x0'), tf.get('x1'), tf.get('y0'), tf.get('y1')), 'strip_ft': tf.get('strip_ft'), 'target_feature_id': registry.lookup(core.canonical_descriptor(tf)), 'donor': None, 'donor_index': None, 'distance': None, 'exact': False, 'donor_rect': None, 'donor_master': False, 'donor_feature_id': None, 'approximate': bool(tf.get('outline_approximate')), 'through_unjudged': (tf.get('kind') == OPENING and tf.get('through') is None), } got = proposal.donor_for(i) if got is not None: key, d_index, dist = got donor_feats = by_donor.get(key) or [] df = donor_feats[d_index] donor_ids = core.feature_ids_of({'features': donor_feats}, registry) row.update({ 'donor': key, 'donor_index': d_index, 'distance': dist, 'exact': dist == 0.0, 'donor_master': key in (proposal.masters or ()), 'donor_rect': (df.get('x0'), df.get('x1'), df.get('y0'), df.get('y1')), 'donor_feature_id': (donor_ids[d_index] if donor_ids is not None and d_index < len(donor_ids) else None), 'approximate': (row['approximate'] or bool(df.get('outline_approximate'))), 'through_unjudged': (row['through_unjudged'] or (tf.get('kind') == OPENING and df.get('through') is None)), }) out.append(row) return out # ------------------------------------------------------------- the Revit end AUDIT_FILE = 'outline.json' # A panel whose solid is this much smaller than its extents box, after its # recorded voids are taken out, has an outline the index cannot see. Measured # on this model: 242 of 255 WP land under 0.1%, the other 13 sit at 3.8% and # up, so anything in the low single digits separates the two populations # cleanly. Three percent, not tuned finer than the evidence supports. APPROX_PCT = 3.0 def outline_audit(doc, cache=None): """Which pieces have an outline the index's rectangle cannot represent. READ ONLY. Compares each piece's real solid volume against its extents box minus its recorded voids. Cached beside the index, because it costs one geometry read per piece and changes only when the model does. This is the stand-in for the deferred outline-polygon key on the index record; see the module docstring for what the real fix is. """ import json import os from vibe import reinforceindex as index from vibe import liftexport as L if cache is None: cache = index.read(doc) by = dict(index.assemblies(doc)) names = set() rows = {} for name, rec in (cache.get('pieces') or {}).items(): feats = rec.get('features') if feats is None: continue Lf, Wf, Tin = (rec.get('length_ft'), rec.get('width_ft'), rec.get('thickness_in')) asm = by.get(name) if asm is None or not Lf or not Wf or not Tin: continue panel = _panel_of(doc, asm) if panel is None: continue try: volume = L.concrete_solid(panel).Volume # cubic FEET except Exception: continue # NOT THE UNIT SEAM. These divisions turn the recorded THICKNESS, # which is stored in inches, back into feet so it multiplies with the # feet length and width into cubic feet -- the unit Revit reports a # solid's volume in. Nothing here is displayed or ranked; the seam # that the owner's inch ruling lands on is `covers` alone. box = Lf * Wf * (Tin / 12.0) voided = 0.0 for f in feats: depth = (Tin / 12.0) if f.get('through') else ( (Tin - (f.get('concrete_in') or 0.0)) / 12.0) voided += (f['w'] or 0.0) * (f['h'] or 0.0) * depth pct = 100.0 * (box - voided - volume) / box if box else 0.0 rows[name] = round(pct, 2) if pct > APPROX_PCT: names.add(name) path = os.path.join(os.path.dirname(index.index_path(doc)), AUDIT_FILE) handle = open(path, 'wb') try: handle.write(json.dumps( {'approximate': sorted(names), 'pct': rows}, separators=(',', ':'), sort_keys=True).encode('utf-8')) finally: handle.close() return names def load_audit(doc): """The cached outline audit, or None if it has never been run. None is not an empty set: never audited and audited-and-found-none are different answers, and a caller that reads the first as the second is claiming every outline is exact when nobody checked. """ import os from vibe import reinforceindex as index return load_audit_at(os.path.dirname(index.index_path(doc))) def load_audit_at(folder): """The same file, addressed by FOLDER instead of by document. This is the offline half of `load_audit`: the audit is a plain JSON file that sits beside `index.json`, so reading it needs a path and nothing else. `load_audit(doc)` exists only to work out that path, and working it out is the one step that needs Revit (`index_path` -> `designspec` -> `Autodesk.Revit.DB` at module scope). """ import json import os path = os.path.join(folder or '', AUDIT_FILE) if not os.path.isfile(path): return None try: handle = open(path, 'rb') try: data = json.loads(handle.read().decode('utf-8')) finally: handle.close() return set(data.get('approximate') or ()) except Exception: return None def _panel_of(doc, asm): 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 suggest_for_piece(doc, name, refresh=False, audit=False, donor_filter=None, include_voids=True, max_proposals=match.DEFAULT_MAX_PROPOSALS, know=None, max_donors=match.DEFAULT_MAX_DONORS): """Everything, for one assembly by name, against the live model. READ ONLY. The target is always measured live rather than taken from the cache: the piece being reinforced is usually not a donor, so it is usually not in it. `refresh` brings the index up to date first; `audit` re-runs the outline audit. Both default off so the common press is a read. """ from vibe import reinforceindex as index from vibe import reinforcecore as core from vibe import scope as scopelib # The donor pool is bounded by the DECLARED SCOPE, read from the job # knowledge rather than written into the filter. An undeclared scope # leaves `prefixes` None -- every indexed piece may donate -- and the # report says which of the two happened, because a pool of one kind and # a pool of everything look identical once the answer is printed. the_scope = scopelib.of(know) if know else scopelib.Scope() if donor_filter is None: donor_filter = DonorFilter(prefixes=the_scope.prefixes or None, design_re=design_pattern(know)) if refresh: cache, report = index.refresh(doc, know=know) else: cache, report = index.read(doc), None approximate = outline_audit(doc, cache) if audit else load_audit(doc) # THE TARGET IS MEASURED LIVE HERE AND THAT IS THE ONLY REASON THIS # FUNCTION NEEDS A DOCUMENT. Since schema 5 the index also holds # in-scope non-donors, so the cache usually has a record for it too -- # but a live measurement is never stale and the model is right there, so # in Revit the live one wins. `suggest_from_index` is the same tail with # the cached record instead, and is what runs with no Revit at all. target = (cache.get('pieces') or {}).get(name) for asm_name, asm in index.assemblies(doc): if asm_name == name: sigs = index.live_signatures(doc, [(name, asm)]) target = index.extract(doc, name, asm, sigs[name][0], know=know) break if target is None: return None, {'target': name, 'target_status': 'not_in_model'} return suggest_from_index( cache, name, approximate=approximate, the_scope=the_scope, donor_filter=donor_filter, include_voids=include_voids, max_proposals=max_proposals, max_donors=max_donors, target=target, refresh_report=report, offline=False) # ------------------------------------------------- the whole run, NO DOCUMENT def suggest_from_index(cache, name, approximate=None, the_scope=None, donor_filter=None, include_voids=True, max_proposals=match.DEFAULT_MAX_PROPOSALS, max_donors=match.DEFAULT_MAX_DONORS, target=None, refresh_report=None, offline=True): """Everything, for one piece, FROM THE SNAPSHOT. No Revit on this path. This is the tail `suggest_for_piece` used to hold inline, lifted out whole. It is the same filter -> features -> covers -> set cover -> info that the button runs; the only difference is where the target record came from. Live, `suggest_for_piece` measures the target through the document and passes it in as `target`. Offline, `target` is left None and the record is taken out of the index like every donor's. `offline=True` (the default here, and False from the live path) is carried on `info` and is what makes a report say, in as many words, that it was answered from a snapshot rather than from the model. It changes no arithmetic anywhere -- see `reinforcereport.offline_banner`. `the_scope` is a `vibe.scope.Scope`. Offline it comes out of the index itself (`reinforceindex.scope_in`), because there is no knowledge layer to read without a model. Returns `(suggestion, info)` -- a TUPLE, always, exactly like `suggest_for_piece`. A caller that treats the return as a Suggestion gets a tuple and no error until much later. """ from vibe import reinforcecore as core from vibe import scope as scopelib the_scope = the_scope if the_scope is not None else scopelib.Scope() if donor_filter is None: # The design pattern comes OUT OF THE INDEX here, not out of a # knowledge layer -- offline there is no model and therefore no # knowledge layer, and `reinforceindex.refresh` stamped the pattern # into the file for exactly this reader. An index written before that # stamp returns None and `design_key` falls back, which is what an # unmapped nomenclature has always produced. donor_filter = DonorFilter(prefixes=the_scope.prefixes or None, design_re=core.design_re_in(cache)) if target is None: target = (cache.get('pieces') or {}).get(name) if target is None: # OFFLINE THIS IS NOT "NOT IN THE MODEL". The snapshot simply does not # hold this piece -- because it is out of the declared scope, or # because the index predates it, or because the index was built by an # older donors-only version. The model may well contain it and nobody # here can tell. Two different words for two different findings. return None, {'target': name, 'offline': offline, 'target_status': 'not_in_index' if offline else 'not_in_model'} suggestion, info = propose(target, core.donors(cache), donor_filter=donor_filter, approximate=approximate, include_voids=include_voids, max_proposals=max_proposals, max_donors=max_donors) info['report'] = refresh_report info['audited'] = approximate is not None info['offline'] = offline info['target_source'] = 'index' if offline else 'model' info['built_at'] = (cache or {}).get('built_at') # THE FEATURE VOCABULARY, carried as its plain JSON dict (never a live # Registry object -- `info` may be handed to layers that serialise it), so # `explain()` can project read-only feature-IDs. See `core.Registry`. info['registry'] = (cache or {}).get('registry') # A COLD DONOR DB IS NOT "JUDGED AND FOUND NOTHING". An index with no pieces # is either brand new (no Snapshot has run yet) or was just discarded by a # schema bump (`core.load` returns an empty cache with a `_load_error`). # Either way NOTHING has been indexed to compare against, which reads as # NO_DONORS -- but the fix is "run a Snapshot", not "no donor matches". The # report reuses the NO_DONORS headline and swaps in that wording so a cold # DB can never render as a silent all-clear. info['cold_db'] = not ((cache or {}).get('pieces')) info['cold_reason'] = (cache or {}).get('_load_error') # What the scope kept out of the donor pool, so the report can say it in # the same breath as the rest of the pool arithmetic. `rejected['prefix']` # already counts them; this names the rule they were held to. rejected_names = (info.get('rejected_names') or {}).get('prefix') or [] in_scope = the_scope.includes(name) info['scope'] = { 'declared': the_scope.is_declared, 'label': the_scope.label(), 'declaration': the_scope.declaration_lines(), 'line': the_scope.line(rejected_names, unit='indexed donor'), 'target_in_scope': in_scope, # ONLY when the target really is outside it. Passing the name # unconditionally made every report carry "JUDGED OUTSIDE THE DECLARED # SCOPE, AT YOUR REQUEST" beside `target_in_scope: True` -- verified # against a live in-scope run, 2026-08-30. A report that contradicts # itself in one dict teaches the reader to skip both lines. 'target_annotation': the_scope.annotation( [] if in_scope else [name], unit='piece'), } # DONORS ONLY, and that is not a narrowing -- it is what this number has # always meant: "indexed pieces that took no part in this search". Since # schema 5 the file also holds pieces measured only so they can be # targets, and one of those that would not measure never was a candidate. # Counting it here would put a warning on every press of a job whose scope # is undeclared, where the index widens to every assembly in the model and # a beam with no structural-framing member to measure is an ordinary, # expected miss rather than a lost donor. info['coverage_unjudged'] = sorted( r['name'] for r in core.donors(cache, include_unjudged=True) if (r or {}).get('features') is None) return suggestion, info