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.
1599 lines
73 KiB
Python
1599 lines
73 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Turning a reinforcing suggestion into the words the button prints.
|
|
|
|
No Revit here, and no matching either. This module knows nothing about
|
|
geometry: it is handed the `(suggestion, info)` pair that
|
|
`vibe.reinforcebind.propose` / `suggest_for_piece` returns, plus the
|
|
`explain()` rows for one proposal, and it decides how that reads. It exists
|
|
so the wording -- which is the part that can quietly lie -- is unit-tested on
|
|
host CPython instead of being trapped inside a pyRevit script that can only
|
|
be exercised by restarting Revit.
|
|
|
|
WHAT THIS MODULE IS PROTECTING
|
|
------------------------------
|
|
Distinctions that the layers below take care to keep apart, and that a
|
|
report is the easiest place in the whole feature to collapse by accident:
|
|
|
|
1. `NO_DONORS` -- nothing eligible was offered, so NOTHING WAS JUDGED --
|
|
against `NO_COVERAGE` -- donors were examined and none of them covers
|
|
anything. The first is ignorance, the second is a finding. They get
|
|
different headlines and different words, never "no suggestions".
|
|
|
|
2. `features == []` (measured, genuinely nothing to reinforce) against
|
|
`features is None` (never measured, could not be judged). Same rule:
|
|
different headlines.
|
|
|
|
3. PARTIAL is an ANSWER, not a failure. "Here is a cover of five of your six
|
|
features and this is the one nobody has" is the useful shape of that
|
|
answer, so a partial cover prints its proposals first and its gap second,
|
|
rather than printing an apology.
|
|
|
|
4. `master` -- a donor the owner has typed MASTER against in the model,
|
|
meaning REVIEWED, not merely reinforced. Every other donor in the pool is
|
|
marked `TICKET_IS_REINFORCED`, which says somebody drew bars and says
|
|
nothing about whether anybody checked them. That difference is the only
|
|
provenance this feature has, so a MASTER is marked wherever it is printed
|
|
and the footnote says what the mark means -- and also says what it does
|
|
NOT mean, because MASTER is a tie-break and never beats a better fit.
|
|
|
|
5. `outline_approximate` -- the 13 of 255 WP donors whose real outline is not
|
|
the rectangle the index stores. A match involving one of those may never
|
|
be printed as a plain "exact". It is printed as `exact*` with the footnote
|
|
attached, every time, so the word "exact" in this report always means
|
|
measured.
|
|
|
|
6. Same-elevation AFFINITY against MATCH. Ruled 2026-09-02, after WP425
|
|
ranked five donors in a dead tie and the one sharing its own building
|
|
elevation printed last, invisibly, by mark order -- and RULED AGAIN the
|
|
same day: elevation is a PURELY GEOMETRIC fact (a thickness-axis band,
|
|
`reinforcecore.elevation_band`), never a hand-typed parameter. It is the
|
|
LAST tie-break -- after fewest donors, most exact, most reviewed and
|
|
total distance -- and this module marks it the same way it marks
|
|
MASTER: on the donor, in the proposals table, with a footnote that says
|
|
plainly it never outranks a better fit. A band that could not be
|
|
determined is a stated could-not-judge (`elevation_note`), never a
|
|
silent absence that reads as "elevation was never a factor here".
|
|
|
|
INCHES, TWO DECIMALS -- AND WHAT THAT DOES NOT MEAN
|
|
---------------------------------------------------
|
|
Owner's ruling, 2026-08-30: "all actual measurements use inches, to two
|
|
decimal places". Every measurement
|
|
this module prints is in INCHES to two decimals, and never a bare number
|
|
without a unit.
|
|
|
|
The stored geometry is still FEET -- Revit's internal unit, which is what the
|
|
index caches. Two kinds of number arrive here and they cross the unit seam at
|
|
different places, so they get two different formatters and the names say
|
|
which is which:
|
|
|
|
* `length_in(feet)` -- a COORDINATE or a strip width, still feet on the
|
|
record. It converts here, at the moment of printing, through
|
|
`reinforcecore.inches()`.
|
|
* `distance_in(inches)` -- a `covers` distance. It is ALREADY inches:
|
|
`reinforcebind.covers` converted it at the seam. This one must never
|
|
convert, and a second conversion is the 12x error this project has
|
|
already paid for once.
|
|
|
|
Passing the wrong one is a factor of twelve, so they are named for what they
|
|
take rather than for what they print.
|
|
|
|
TWO DECIMALS IS DISPLAY, NOT PRECISION. 0.01 in is an order of magnitude
|
|
finer than any tolerance this tool uses -- dimensions 3/8", interference
|
|
1/8". So `reinforcebind` quantizes every non-exact distance to the nearest
|
|
1/8 in before it ever reaches here, which means a distance printed on this
|
|
page can only ever be a multiple of 1/8 in. `GRAIN_NOTE` says so out loud;
|
|
the granularity of the printed values says it again to anyone who skips
|
|
footnotes.
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
|
|
from vibe import reinforcecore as core
|
|
from vibe import reinforcematch as match
|
|
|
|
# Feature kinds, copied rather than imported: `reinforcebind` is under active
|
|
# rewrite and this module is deliberately not coupled to its internals. These
|
|
# are the string values that arrive on a feature record.
|
|
OPENING = 'opening'
|
|
STRIP_X = 'strip_x'
|
|
STRIP_Y = 'strip_y'
|
|
|
|
VOID_VOID = 'void-void'
|
|
VOID_EDGE = 'void-edge'
|
|
|
|
APPROX_FOOTNOTE = (
|
|
'`*` — this match involves a piece whose real outline is not the '
|
|
'rectangle the index stores (a slope or a corner cut the index cannot '
|
|
'see). Its **edge** conditions are approximated, so a starred match is '
|
|
'never a measured exact match. Check the edge on the drawing before '
|
|
'trusting it.')
|
|
|
|
# The mark on a reviewed donor. An HTML entity, not a literal character: the
|
|
# extension runs on IronPython 2.7 where a non-ASCII source literal is a byte
|
|
# string, and the report is already rendered as markdown (`—` throughout).
|
|
# `*` is taken -- it means "outline approximated" -- and the two can and do
|
|
# appear on the same donor, so they must not be the same symbol.
|
|
MASTER_MARK = '†'
|
|
|
|
MASTER_FOOTNOTE = (
|
|
'`' + MASTER_MARK + '` — this donor is typed **MASTER** in the '
|
|
'model, which means it has been **reviewed**. Every other donor here is '
|
|
'marked `TICKET_IS_REINFORCED`, which only means somebody drew '
|
|
'reinforcing on it — not that anyone checked it. MASTER is the one '
|
|
'provenance signal this tool has. It is a **tie-break only**: a MASTER '
|
|
'never outranks a donor that fits better, it only wins between donor '
|
|
'sets the geometry cannot tell apart.')
|
|
|
|
# The mark on a donor sharing the target's building elevation. A double
|
|
# dagger, distinct from `*` (outline approximated) and MASTER_MARK's single
|
|
# dagger -- all three can land on the same donor. Owner ruling, 2026-09-02,
|
|
# on WP425: five donors tied on every other criterion and the one on the
|
|
# target's own elevation (WP234) printed last, invisibly, because nothing
|
|
# marked it. This mark is the fix: it is what makes the tie AND what broke
|
|
# it visible on the same row, rather than a silent sort by mark number.
|
|
ELEVATION_MARK = '‡'
|
|
|
|
ELEVATION_FOOTNOTE = (
|
|
'`' + ELEVATION_MARK + '` — this donor shares the target\'s '
|
|
'**building elevation**. It is the **last** tie-break in the ranking '
|
|
'— after fewest donors, most exact, most reviewed and total '
|
|
'distance — so it never outranks a donor that fits better; it '
|
|
'only decides who wins among donors everything above it left dead '
|
|
'level. A donor whose elevation could not be read never carries this '
|
|
'mark, even when the target\'s is known.')
|
|
|
|
GRAIN_NOTE = (
|
|
'> **How to read the numbers.** Every measurement here is in **inches**. '
|
|
'Distances (`off 0.25 in`) are rounded to the nearest **1/8 in**, which '
|
|
'is the finest tolerance this tool works to anywhere — dimensions '
|
|
'are checked at 3/8 in and interference at 1/8 in. Two decimals are '
|
|
'printed as asked, but nothing here is measured to 0.01 in, and two '
|
|
'donors that differ by less than 1/16 in are ranked as **equal** rather '
|
|
'than separated by noise. `exact` is the one exception: it is not '
|
|
'rounded, and it means measured-identical to within 1/32 in. Positions '
|
|
'and strip widths come from geometry cached to 0.036 in.')
|
|
|
|
CAP_NOTE = (
|
|
'> **A proposal may name at most {0} donors**, by ruling. When '
|
|
'{0} cannot account for every feature, this tool returns the best {0} '
|
|
'and **names what they miss** rather than growing to a fifth. A feature '
|
|
'left out by the cap is not a feature nobody has — somebody in the '
|
|
'pool covers it, and it is listed separately below.')
|
|
|
|
TITLE = 'Reinforcing Suggestion'
|
|
|
|
|
|
# ------------------------------------------------------------- small format
|
|
|
|
def length_in(value_ft, places=2):
|
|
"""A stored length -- FEET IN -- printed as inches. Or an em dash.
|
|
|
|
This is the converting formatter. Give it a coordinate or a strip width
|
|
off a feature record, which are feet. Do NOT give it a `covers` distance:
|
|
those are already inches and would come out twelve times too big.
|
|
"""
|
|
got = core.inches(value_ft)
|
|
if got is None:
|
|
return '—' if value_ft is None else str(value_ft)
|
|
return ('{0:.' + str(places) + 'f} in').format(got)
|
|
|
|
|
|
def distance_in(value_in, places=2):
|
|
"""A `covers` distance -- ALREADY INCHES -- printed. Or an em dash.
|
|
|
|
The non-converting formatter, and the reason the two are named apart:
|
|
`reinforcebind.covers` crossed the unit seam already, and converting a
|
|
second time here is exactly the 12x mistake this project has made before.
|
|
"""
|
|
if value_in is None:
|
|
return '—'
|
|
try:
|
|
return ('{0:.' + str(places) + 'f} in').format(float(value_in))
|
|
except (TypeError, ValueError):
|
|
return str(value_in)
|
|
|
|
|
|
def rect(box):
|
|
"""A feature's rectangle as `x0..x1 x y0..y1`, absolute INCHES.
|
|
|
|
The box arrives in feet, straight off the record; every one of the four
|
|
numbers converts here through `length_in`'s helper.
|
|
"""
|
|
if not box:
|
|
return '—'
|
|
x0, x1, y0, y1 = box
|
|
return '{0}..{1} x {2}..{3}'.format(
|
|
_n(x0), _n(x1), _n(y0), _n(y1))
|
|
|
|
|
|
def _n(value_ft, places=2):
|
|
"""One coordinate of a rectangle: feet in, bare inches out (no unit --
|
|
`rect` carries the unit once, in the column heading)."""
|
|
got = core.inches(value_ft)
|
|
if got is None:
|
|
return '?'
|
|
return ('{0:.' + str(places) + 'f}').format(got)
|
|
|
|
|
|
def feature_label(row):
|
|
"""What a target feature IS, in words an engineer can point at.
|
|
|
|
A strip is named by what bounds it, because that is the whole definition
|
|
of a ligament -- there is no radius or neighbourhood involved.
|
|
"""
|
|
kind = (row or {}).get('kind')
|
|
if kind == OPENING:
|
|
return 'opening'
|
|
if kind not in (STRIP_X, STRIP_Y):
|
|
return str(kind or 'feature')
|
|
across = 'along length' if kind == STRIP_X else 'across height'
|
|
if (row or {}).get('between') == VOID_EDGE:
|
|
edge = {'x0': 'left end', 'x1': 'right end',
|
|
'y0': 'bottom', 'y1': 'top'}.get(row.get('edge'), 'edge')
|
|
return 'strip, opening to {0} ({1})'.format(edge, across)
|
|
return 'strip between two openings ({0})'.format(across)
|
|
|
|
|
|
def short_label(row):
|
|
"""The three-word version of `feature_label`, for a tally column."""
|
|
kind = (row or {}).get('kind')
|
|
if kind == OPENING:
|
|
return 'opening'
|
|
if kind not in (STRIP_X, STRIP_Y):
|
|
return str(kind or 'feature')
|
|
if (row or {}).get('between') == VOID_EDGE:
|
|
return 'strip to an edge'
|
|
return 'strip between openings'
|
|
|
|
|
|
def mark(name, master_names=None, approximate=False,
|
|
same_elevation_names=None):
|
|
"""A donor name with its marks. Never silently drops one."""
|
|
out = str(name)
|
|
if approximate:
|
|
out += '*'
|
|
if name in set(master_names or ()):
|
|
out += MASTER_MARK
|
|
if name in set(same_elevation_names or ()):
|
|
out += ELEVATION_MARK
|
|
return out
|
|
|
|
|
|
def exactness(row):
|
|
"""`exact` / `exact*` / `off 0.25 in` / `--`.
|
|
|
|
The star is not decoration. An approximated outline can never be reported
|
|
as a measured exact match -- see the module docstring.
|
|
"""
|
|
if not row or row.get('donor') is None:
|
|
return '--'
|
|
star = '*' if row.get('approximate') else ''
|
|
if row.get('exact'):
|
|
return 'exact' + star
|
|
dist = row.get('distance')
|
|
if dist is None:
|
|
return 'covered' + star
|
|
# A non-zero distance that rounds to 0.00 in must not print as `off
|
|
# 0.00 in`, which reads as exact to everyone who has ever seen a rounded
|
|
# number. Only `exact` means zero. In practice the production `covers`
|
|
# floors every non-exact distance at 1/32 in, which prints as 0.04 in, so
|
|
# this branch only fires for a caller that supplies its own comparison --
|
|
# and it must still be right for that caller.
|
|
if 0.0 < dist < 0.005:
|
|
return 'off <0.01 in' + star
|
|
return 'off {0}{1}'.format(distance_in(dist), star)
|
|
|
|
|
|
# ------------------------------------------------------- the index and when
|
|
|
|
def index_line(cache, refresh_report, rebuilt_all, refreshed, offline=False):
|
|
"""One line saying how current the answer is. Always printed.
|
|
|
|
The build stamp is not a nicety: the staleness signature cannot see a
|
|
geometry edit that leaves the member set alone, so a reader who does not
|
|
know when the index was built cannot judge whether that matters to him.
|
|
Offline it is the ONLY thing standing between the reader and a snapshot of
|
|
unknown age, so `offline=True` says so in the same line rather than
|
|
leaving it to a footnote.
|
|
|
|
The donor count is the count of ELIGIBLE records, not of records. Since
|
|
schema 5 the file also holds in-scope pieces measured so they can be
|
|
targets, and calling those donors would overstate the pool that was
|
|
actually searched by exactly the number of pieces nobody may copy from.
|
|
"""
|
|
built = (cache or {}).get('built_at') or 'never'
|
|
pieces = (cache or {}).get('pieces') or {}
|
|
donor_count = 0
|
|
for rec in pieces.values():
|
|
rec = rec or {}
|
|
if core.is_eligible(rec.get('reinforced'), rec.get('on_hold')):
|
|
donor_count += 1
|
|
others = len(pieces) - donor_count
|
|
if offline:
|
|
how = ('read from the snapshot on disk; the model was never opened, '
|
|
'so nothing was re-checked against it')
|
|
elif rebuilt_all:
|
|
how = 'FULL REBUILD this press'
|
|
elif refreshed:
|
|
built_now = (refresh_report or {}).get('built')
|
|
how = 'refreshed this press ({0} piece(s) rebuilt)'.format(
|
|
0 if built_now is None else built_now)
|
|
else:
|
|
how = 'read from cache, nothing needed rebuilding'
|
|
tail = ('' if not others else
|
|
' (+ **{0}** piece(s) measured as possible targets, which may not '
|
|
'donate)'.format(others))
|
|
return ('Index: **{0}** indexed donor(s){1}, built **{2}** — {3}.'
|
|
.format(donor_count, tail, built, how))
|
|
|
|
|
|
# The exact sentence an offline answer opens with. Short on purpose: it is the
|
|
# one line a reader must not be able to skim past, and it says the two things
|
|
# that matter -- that this is a snapshot, and that the model was not consulted.
|
|
OFFLINE_BANNER = 'OFFLINE — answered from the snapshot, not the model'
|
|
|
|
|
|
def offline_banner(cache):
|
|
"""The offline headline, with the snapshot's age attached to it.
|
|
|
|
The age belongs HERE rather than only in `index_line`, because offline the
|
|
build stamp is not a detail about the index -- it is the date of the
|
|
answer. Everything below is as true as the model was at that moment and no
|
|
truer.
|
|
"""
|
|
built = (cache or {}).get('built_at') or 'never'
|
|
return '**{0}.** Index built **{1}**.'.format(OFFLINE_BANNER, built)
|
|
|
|
|
|
def staleness_note(rebuilt_all, offline=False):
|
|
"""The known limitation, said out loud unless it has just been ruled out.
|
|
|
|
KNOWN LIMITATION. The change signature is
|
|
`reinforced | on_hold | master | member-count | checksum(member ids)`. It
|
|
sees a member added, removed or swapped, and a piece typed up as MASTER;
|
|
it does NOT see an opening moved or resized in place, or a panel
|
|
stretched, because those leave the member set alone. Only a full rebuild
|
|
does.
|
|
|
|
OFFLINE IS A STRICTLY WEAKER POSITION and gets its own words. In Revit the
|
|
cheap signature pass runs on every press, so the first half of that list
|
|
IS caught. With no model open nothing can be recomputed at all, so both
|
|
halves are invisible: not one of the model's assemblies was looked at, and
|
|
a piece added, deleted, reinforced or typed MASTER since the build is as
|
|
invisible as an opening dragged two feet left. The only remedy is to open
|
|
the model and refresh, and the note says so instead of offering a
|
|
shift-click that means nothing at a command line.
|
|
"""
|
|
if offline:
|
|
return ('> **Nothing here was checked against the model.** This answer '
|
|
'is built entirely from the index file, which was written the '
|
|
'last time somebody refreshed it inside Revit. Anything that '
|
|
'has changed since then is invisible: an opening moved or '
|
|
'resized, a panel stretched, a piece added or deleted, a piece '
|
|
'newly reinforced, newly held or newly typed MASTER. In Revit '
|
|
'the first of those is the only blind spot, because the cheap '
|
|
'signature pass catches the rest on every press; offline there '
|
|
'is no pass and no model, so all of them are blind. To rule '
|
|
'any of it out, open the model and refresh the index (a full '
|
|
'rebuild of this model measured ~10-16 s).')
|
|
if rebuilt_all:
|
|
return ('Every donor was re-measured from the model on this press, so '
|
|
'nothing here is stale.')
|
|
return ('> **The index may be stale in one specific way.** It notices a '
|
|
'member added, removed or swapped, a piece newly reinforced, a '
|
|
'piece newly typed MASTER, and a piece put on or taken off hold. '
|
|
'It does **not** notice an '
|
|
'opening moved or resized in place, or a panel stretched — '
|
|
'those leave the member set unchanged and the cached measurement '
|
|
'standing. If anyone has been editing panel geometry, '
|
|
'**shift-click this button** to force a full rebuild before '
|
|
'trusting the answer.')
|
|
|
|
|
|
def coverage_note(info):
|
|
"""What the index could not see, in the caller's own terms.
|
|
|
|
A search over a partial index that does not say what it missed is
|
|
claiming a completeness it does not have.
|
|
"""
|
|
out = []
|
|
unjudged = (info or {}).get('coverage_unjudged') or []
|
|
if unjudged:
|
|
out.append(
|
|
'> **{0} indexed piece(s) could not be measured** and took no part '
|
|
'in this search. They are not "pieces with nothing to offer" '
|
|
'— nobody knows what they have: {1}'
|
|
.format(len(unjudged), ', '.join(sorted(unjudged)[:20]) +
|
|
(' ...' if len(unjudged) > 20 else '')))
|
|
if (info or {}).get('audited') is False:
|
|
out.append(
|
|
'> **The outline audit has never been run on this model**, so no '
|
|
'piece is flagged as having an outline the index cannot see. That '
|
|
'is "nobody checked", not "every outline is a clean rectangle". '
|
|
'Shift-click to run it.')
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- the target
|
|
|
|
# ------------------------------------------------------ the design refusal
|
|
#
|
|
# THE ONE RULE ON THIS PAGE THAT REFUSES RATHER THAN RANKS.
|
|
#
|
|
# Owner, 2026-09-01: "选择母版的目标,必须得是跟自己的design number一样的panel。
|
|
# 我刚在一个WP2.0上面跑了reinforce,给我俩WP1.0的母版,压根用不了。" A donor of
|
|
# another design is unusable, not merely worse -- so when there is none of the
|
|
# right design this report says so and offers NOTHING. No nearest-design
|
|
# fallback, not even a labelled one: a label is precisely what a reader in a
|
|
# hurry skips, and the page would then look exactly like an answer.
|
|
#
|
|
# Every one of these four says the same three things, because that is what
|
|
# makes a refusal actionable rather than annoying: WHICH DESIGN was wanted,
|
|
# WHAT WAS ACTUALLY THERE (counted, so the reader can tell "the index is
|
|
# empty" from "this job has no second WP2 yet"), and THE WAY OUT.
|
|
|
|
DESIGN_RULE_NOTE = (
|
|
'A template is only usable if it carries the **same design number** as '
|
|
'the piece being reinforced — owner ruling, 2026-09-01. That is a '
|
|
'**filter**, not a preference: this tool will not offer a donor of '
|
|
'another design, and will not offer one labelled as such either. Packet '
|
|
'suffixes do not split a design: `{0}.0` and `{0}.A` are both design '
|
|
'`{0}`.')
|
|
|
|
# The design the suffix rule is illustrated with when the target's own could
|
|
# not be read. A placeholder mark, per this repository's convention -- never a
|
|
# design number from a job, and never one invented to look like this job's.
|
|
DESIGN_RULE_EXAMPLE = 'PNL1'
|
|
|
|
|
|
def designs_seen_text(seen):
|
|
"""`WP1 (300), WP3 (110), unreadable (2)` -- biggest first, then by name.
|
|
|
|
An empty tally reads as "none", never as an empty string: a sentence that
|
|
trails off after 'the designs seen were' is a sentence a reader fills in
|
|
for himself.
|
|
"""
|
|
seen = seen or {}
|
|
if not seen:
|
|
return 'none'
|
|
order = sorted(seen, key=lambda k: (-seen[k], k))
|
|
return ', '.join(
|
|
'{0} ({1})'.format('unreadable' if k == core.DESIGN_UNREADABLE else k,
|
|
seen[k])
|
|
for k in order)
|
|
|
|
|
|
def design_refusal(info):
|
|
"""(sentence, [paragraph, ...]) when the design rule refuses, else None.
|
|
|
|
Kept out of `verdict()` on purpose: `verdict` reports what a SEARCH found,
|
|
and none of these ran a search. They belong with the other could-not-judge
|
|
answers, which is `target_block`'s half of the page -- so the first return
|
|
is a **bold sentence** in the shape the other refusals there use, not a
|
|
heading. `render` prints `## NOT JUDGED` above it.
|
|
"""
|
|
design = (info or {}).get('design') or {}
|
|
if not design.get('refused'):
|
|
return None
|
|
|
|
name = (info or {}).get('target') or '?'
|
|
want = design.get('target_key')
|
|
raw = design.get('target')
|
|
considered = design.get('considered')
|
|
candidates = design.get('candidates')
|
|
status = design.get('status')
|
|
|
|
if status == core.DESIGN_INDEX_NOT_RECORDED:
|
|
return ('**This index predates design-number recording, so no donor '
|
|
'could be checked against {0}.**'.format(name),
|
|
['The index does not carry the field at all. Nothing was '
|
|
'judged and nothing is offered — an index that never '
|
|
'recorded the field is "nobody looked", **not** "every piece '
|
|
'matches".',
|
|
'**Re-run the snapshot.** Shift-click this button with the '
|
|
'model open to rebuild the index from scratch, then try '
|
|
'again.',
|
|
DESIGN_RULE_NOTE.format(want or DESIGN_RULE_EXAMPLE)])
|
|
|
|
if status == core.DESIGN_INDEX_UNREAD:
|
|
return ('**No indexed piece carries a design number, so no donor '
|
|
'could be checked against {0}.**'.format(name),
|
|
['All {0} candidate piece(s) — of {1} in the index '
|
|
'— carry an **empty** design number. Nothing was read; '
|
|
'this is **not** "they all match".'
|
|
.format(candidates if candidates is not None else '?',
|
|
considered if considered is not None else '?'),
|
|
'**Re-run the snapshot.** The index was built without a '
|
|
'knowledge layer, or this plant has not mapped '
|
|
'`parameters.panel.design_number`. Shift-click this button '
|
|
'with the model open to rebuild it. If the numbers are still '
|
|
'empty afterwards the role is genuinely unmapped — map '
|
|
'it in the plant knowledge file.',
|
|
DESIGN_RULE_NOTE.format(want or DESIGN_RULE_EXAMPLE)])
|
|
|
|
if status == core.DESIGN_TARGET_UNKNOWN:
|
|
return ('**{0} has no readable design number, so nothing could be '
|
|
'matched to it.**'.format(name),
|
|
['This piece\'s own design number reads {0}, which the '
|
|
'nomenclature pattern could not turn into a design. Nothing '
|
|
'can be filtered against a design nobody knows, so nothing '
|
|
'was judged and nothing is offered.'
|
|
.format('`{0}`'.format(raw) if raw else '**empty**'),
|
|
'Check the piece carries a value for the '
|
|
'`panel.design_number` role, and that '
|
|
'`nomenclature.design_number_re` reads the way this job '
|
|
'writes them. The designs the pool does carry: {0}.'
|
|
.format(designs_seen_text(design.get('designs_seen')))])
|
|
|
|
# DESIGN_NONE_MATCH -- everything was read, and the answer is no.
|
|
return ('**No donor with design number {0} exists in this index, so {1} '
|
|
'was not judged.**'.format(want, name),
|
|
['{0} is design **{1}**. Of {2} indexed piece(s), {3} got as far '
|
|
'as the design test and **not one of them is {1}**. The designs '
|
|
'they do carry: {4}.'
|
|
.format(name, want,
|
|
considered if considered is not None else '?',
|
|
candidates if candidates is not None else '?',
|
|
designs_seen_text(design.get('designs_seen'))),
|
|
DESIGN_RULE_NOTE.format(want),
|
|
'**The way out:** detail one {0} piece by hand. The moment it is '
|
|
'marked `TICKET_IS_REINFORCED` and the index is rebuilt, it '
|
|
'becomes the template for every other {0} on the job. If you '
|
|
'expected a {0} donor to exist, check the design number on the '
|
|
'piece you have in mind — and check the rejection table '
|
|
'below, in case it was dropped for scope or thickness before the '
|
|
'design test ever saw it.'.format(want)])
|
|
|
|
|
|
# ------------------------------------------------- the featureless target
|
|
#
|
|
# Owner, 2026-09-01, after being handed a report that said nothing about it:
|
|
# "如果扫描到目标没有opening,那么就找另一块没有opening的板子给target当doner
|
|
# ... 这件儿不应该你告诉我,应该reinforce告诉我。" A target measured to have no
|
|
# openings used to read as "NOT JUDGED" and stop there -- true as far as it
|
|
# went (there is nothing to set-cover), but silent about the search that
|
|
# `reinforcebind.shape_search_for` now actually runs. This is that search,
|
|
# in words: what it means that the pool inverted, what it found, and -- the
|
|
# owner's own next sentence -- that the honest answer can be "these are
|
|
# indistinguishable" rather than a false ranking.
|
|
|
|
def shape_search_lines(name, search):
|
|
"""Paragraphs for `target_block`'s `no_features` case. `search` is
|
|
`reinforcebind`'s `shape_search_for` result, or None if it never ran
|
|
(an info dict built by hand, or a caller that predates this)."""
|
|
if not search or not search.get('ran'):
|
|
return []
|
|
eligible = search.get('eligible') or []
|
|
secondary = search.get('secondary') or []
|
|
considered = search.get('considered')
|
|
lines = [
|
|
'> **The search inverted.** A donor that has no ligaments has '
|
|
'nothing to offer a target that has openings to cover -- that is '
|
|
'why `require_features` drops one. But {0} has no openings either, '
|
|
'so the rule reads the other way here: a panel with no openings IS '
|
|
'the right donor for a panel with no openings, and the pool below '
|
|
'is exactly that search, not a gap where one used to be. Design '
|
|
'number, thickness, scope and self-exclusion all still apply -- '
|
|
'only the featureless rule inverted.'.format(name)]
|
|
|
|
if not eligible and not secondary:
|
|
lines.append(
|
|
'> **Nobody survived the filter.** Of {0} piece(s) considered, '
|
|
'not one is the same design and thickness as {1} -- featureless '
|
|
'or not. There is no donor to propose; detail this piece by '
|
|
'hand.'.format(considered if considered is not None else '?',
|
|
name))
|
|
return lines
|
|
|
|
if not eligible:
|
|
lines.append(
|
|
'> **No donor in the pool is also featureless.** {0} donor(s) '
|
|
'of the right design and thickness DO have openings of their '
|
|
'own. Their reinforcing around those openings has nothing to do '
|
|
'with this target, but the rest of the cage may still be a '
|
|
'usable template, so they are listed below rather than dropped '
|
|
'-- ranked last, because a featureless twin would have been the '
|
|
'better answer and there is not one here.'.format(len(secondary)))
|
|
return lines
|
|
|
|
if search.get('degenerate'):
|
|
lines.append(
|
|
'> **{0} featureless donor(s) found, and they cannot be told '
|
|
'apart on shape.** Same design, and thickness, length and width '
|
|
'equal within this tool\'s own measuring tolerance. Listed below '
|
|
'in name order -- that is alphabetical, not a ranking; nothing '
|
|
'here says one fits better than another.'.format(len(eligible)))
|
|
else:
|
|
lines.append(
|
|
'> **{0} featureless donor(s) found**, ranked by thickness, '
|
|
'then length, then width -- the only shape left to compare once '
|
|
'there are no ligaments to measure.'.format(len(eligible)))
|
|
if secondary:
|
|
lines.append(
|
|
'> A further {0} donor(s) of the right design and thickness DO '
|
|
'have openings of their own, listed after the featureless ones. '
|
|
'Their own opening reinforcing is irrelevant here, but the rest '
|
|
'of the cage may still be worth a look.'.format(len(secondary)))
|
|
return lines
|
|
|
|
|
|
def shape_search_rows(search):
|
|
"""One row per donor the inverted search considered, featureless tier
|
|
first -- `shape_search_for` already ranked within each tier."""
|
|
if not search:
|
|
return []
|
|
out = []
|
|
for row in (search.get('eligible') or ()):
|
|
out.append(_shape_row_cells(row, 'no openings'))
|
|
for row in (search.get('secondary') or ()):
|
|
out.append(_shape_row_cells(row, 'has openings'))
|
|
return out
|
|
|
|
|
|
def _shape_row_cells(row, openings_label):
|
|
return [
|
|
mark(row.get('name'), [row.get('name')] if row.get('master') else ()),
|
|
openings_label,
|
|
distance_in(row.get('thickness_diff_in')),
|
|
length_in(row.get('length_diff_ft')),
|
|
length_in(row.get('width_diff_ft')),
|
|
row.get('design_number') or '?',
|
|
]
|
|
|
|
|
|
SHAPE_SEARCH_COLUMNS = ['donor', 'openings', 'thickness diff', 'length diff',
|
|
'width diff', 'design']
|
|
|
|
|
|
def target_block(info, reinforced=None, on_hold=None):
|
|
"""What the button can say about the piece the engineer picked.
|
|
|
|
`reinforced` / `on_hold` are the target's own flags, 1 / 0 / None, read
|
|
off the assembly by the caller. They do not disqualify a target -- the
|
|
piece being reinforced is normally neither -- but a target that is
|
|
already reinforced, or on hold, is worth saying out loud rather than
|
|
answering as though it were an ordinary request.
|
|
"""
|
|
status = (info or {}).get('target_status')
|
|
name = (info or {}).get('target') or '?'
|
|
lines = []
|
|
|
|
if status == 'not_in_model':
|
|
return ('**{0} is not in this model.** Nothing was judged.'
|
|
.format(name), lines, False)
|
|
if status == 'not_in_index':
|
|
# OFFLINE ONLY, and deliberately not the sentence above. With no model
|
|
# open nobody can say whether the piece exists; all that is known is
|
|
# that the snapshot does not hold it. Three reasons, all worth acting
|
|
# on differently, so all three are named.
|
|
return ('**{0} is not in the index.** Nothing was judged — and '
|
|
'this is **not** "the piece does not exist": with no model '
|
|
'open nothing here can tell. The snapshot does not hold it, '
|
|
'which happens when the piece is outside the declared scope, '
|
|
'when it was added to the model after the index was last '
|
|
'built, or when the index was built by a version that stored '
|
|
'donors only. Open the model and refresh the index to find '
|
|
'out which.'.format(name), lines, False)
|
|
if status == 'no_target':
|
|
return ('**No target piece was resolved.** Nothing was judged.',
|
|
lines, False)
|
|
if status == 'unjudged':
|
|
return ('**{0} could not be measured, so nothing was judged.** This '
|
|
'is not "no suggestions" — its geometry would not read, '
|
|
'so the search never ran. Check the piece has a structural '
|
|
'framing member with readable concrete geometry.'.format(name),
|
|
lines, False)
|
|
if status == 'no_features':
|
|
# THE OWNER'S OWN COMPLAINT, ANSWERED HERE: "这件儿不应该你告诉我,应该
|
|
# reinforce告诉我。" A featureless target used to stop at the sentence
|
|
# below. It still says that sentence first -- it is still true, there
|
|
# is still nothing to set-cover -- but it no longer stops: whatever
|
|
# `shape_search_for` found is appended, so the pool that inverted for
|
|
# exactly this target is not left unsaid a second time.
|
|
lines = list(lines) + shape_search_lines(
|
|
name, (info or {}).get('shape_search'))
|
|
return ('**{0} was measured and has nothing to reinforce.** No '
|
|
'openings, and therefore no strips of concrete between them. '
|
|
'That is a measured answer, not a failure to look.'
|
|
.format(name), lines, False)
|
|
|
|
# LAST, and after the measurement answers on purpose. A piece whose
|
|
# geometry would not read is a different, more fundamental finding than a
|
|
# piece measured fine that has no same-design donor, and the reader needs
|
|
# the first one first.
|
|
refusal = design_refusal(info)
|
|
if refusal is not None:
|
|
return (refusal[0], list(refusal[1]), False)
|
|
|
|
n = len((info or {}).get('target_features') or [])
|
|
head = '**{0}** — {1} feature(s) to cover.'.format(name, n)
|
|
if (info or {}).get('target_approximate'):
|
|
lines.append(
|
|
'> **This target\'s own outline is approximated.** Its real shape '
|
|
'is not the rectangle the index stores, so its edge conditions '
|
|
'are not measured. Nothing about its edges may be called exact.')
|
|
if reinforced == 1:
|
|
lines.append(
|
|
'> This piece is already marked `TICKET_IS_REINFORCED`. It is '
|
|
'being used as a target anyway, as asked — but it is also '
|
|
'a donor for everyone else, so check you meant this one.')
|
|
if on_hold == 1:
|
|
lines.append(
|
|
'> This piece carries `ON_HOLD = 1`. A held piece may not be a '
|
|
'donor; nothing stops it being a target, but its own reinforcing '
|
|
'is not settled.')
|
|
return head, lines, True
|
|
|
|
|
|
# --------------------------------------------------------------- the verdict
|
|
|
|
def verdict(suggestion, info):
|
|
"""(headline, [paragraph, ...]) for the result of an actual judgement.
|
|
|
|
`suggestion is None` never reaches here: that case is the target block's,
|
|
and it means the target could not be judged, not that nothing was found.
|
|
"""
|
|
if suggestion is None:
|
|
return ('Nothing was judged.', [])
|
|
n = suggestion.target_feature_count
|
|
donors = (info or {}).get('donors')
|
|
considered = (info or {}).get('considered')
|
|
|
|
if suggestion.status == match.NO_DONORS:
|
|
# A COLD DONOR DB is a distinct flavour of NO_DONORS: the index holds
|
|
# no pieces at all, so there was nothing to filter, let alone judge.
|
|
# The fix is "run a Snapshot", not "no donor matches" -- and it must
|
|
# NEVER read as a silent all-clear, which is the cardinal sin here.
|
|
if (info or {}).get('cold_db'):
|
|
reason = (info or {}).get('cold_reason')
|
|
why = (' The previous index was discarded (**{0}**), so it has to '
|
|
'be rebuilt.'.format(reason) if reason else
|
|
' No snapshot has populated it yet.')
|
|
return ('NOT JUDGED — the donor DB is cold, run a Snapshot '
|
|
'first',
|
|
['The donor index holds **no pieces at all**, so **not one '
|
|
'comparison was made** -- this is not "no donor matches" '
|
|
'and it is not "all clear".' + why,
|
|
'Press **Snapshot** to index the model, then run this '
|
|
'again. Until then, {0} feature(s) of the target remain '
|
|
'un-judged.'.format(n)])
|
|
return ('NOT JUDGED — no eligible donor was offered',
|
|
['Of {0} indexed piece(s), **none** survived the donor filter, '
|
|
'so **not one comparison was made**. This is not "no piece '
|
|
'matches"; nothing was examined. The rejection table below '
|
|
'says why each one was dropped.'
|
|
.format(considered if considered is not None else '?'),
|
|
'{0} feature(s) of the target were never looked at.'
|
|
.format(n)])
|
|
|
|
if suggestion.status == match.NO_COVERAGE:
|
|
return ('JUDGED — no donor covers anything',
|
|
['{0} donor(s) were compared against all {1} target '
|
|
'feature(s) and **not one of them covers a single feature**. '
|
|
'This is a finding, not a gap in the search.'
|
|
.format(donors if donors is not None else
|
|
suggestion.donor_count, n)])
|
|
|
|
if suggestion.status == match.CAPPED:
|
|
reached = n - len(suggestion.capped_out)
|
|
return ('CAPPED — {0} donor(s) account for {1} of {2} '
|
|
'feature(s)'.format(suggestion.donors_needed, reached, n),
|
|
['Every feature of this piece **is** covered by somebody in '
|
|
'the pool, but no set of {0} donors or fewer reaches all of '
|
|
'them, and a proposal may name at most {0}. The {1} '
|
|
'feature(s) left out are listed under "Left out by the '
|
|
'{0}-donor cap" — a donor for each one exists; go and '
|
|
'find it, or accept these and detail the rest by hand.'
|
|
.format(suggestion.donor_cap, len(suggestion.capped_out))])
|
|
|
|
covered = n - len(suggestion.uncovered) - len(suggestion.capped_out)
|
|
if suggestion.status == match.COMPLETE:
|
|
return ('COMPLETE COVER — {0} donor(s) account for all {1} '
|
|
'feature(s)'.format(suggestion.donors_needed, n),
|
|
['{0} cover(s) of that size were found; the {1} best are '
|
|
'below. Fewest donors first, then most exact.'
|
|
.format(suggestion.cover_count, len(suggestion.proposals))])
|
|
|
|
lines = ['This is an answer, not a failure. The donors below cover {0} of '
|
|
'the {1} feature(s); {2} are covered by **no indexed donor at '
|
|
'all**, in any combination, and are listed under "Nobody has '
|
|
'these".'.format(covered, n, len(suggestion.uncovered))]
|
|
if suggestion.capped_out:
|
|
lines.append(
|
|
'A further **{0} feature(s) are covered by somebody**, but not '
|
|
'within the {1}-donor cap a proposal is allowed. Those are a '
|
|
'different problem from the ones nobody has, and they are listed '
|
|
'separately.'.format(len(suggestion.capped_out),
|
|
suggestion.donor_cap))
|
|
return ('PARTIAL COVER — {0} donor(s) account for {1} of {2} '
|
|
'feature(s)'.format(suggestion.donors_needed, covered, n), lines)
|
|
|
|
|
|
# ----------------------------------------------------------------- the rows
|
|
|
|
def proposal_rows(suggestion, master_names=None, same_elevation_names=None):
|
|
"""One row per proposal: the donor set and how well it does.
|
|
|
|
`master_names` / `same_elevation_names` may be left out and the table
|
|
still reads correctly -- the proposals carry their own lists. They are
|
|
accepted so a caller that already has `info['master_donors']` /
|
|
`info['same_elevation_donors']` need not reach into a Proposal.
|
|
"""
|
|
rows = []
|
|
for i, p in enumerate(suggestion.proposals if suggestion else ()):
|
|
names = set(master_names or ()) | set(p.masters or ())
|
|
elev_names = (set(same_elevation_names or ()) |
|
|
set(p.same_elevation or ()))
|
|
rows.append([
|
|
str(i + 1),
|
|
', '.join(mark(d, names, same_elevation_names=elev_names)
|
|
for d in p.donors),
|
|
str(len(p.donors)),
|
|
'{0} / {1}'.format(p.covered_count,
|
|
suggestion.target_feature_count),
|
|
str(p.exact_count),
|
|
str(len(p.masters or ())),
|
|
str(len(p.same_elevation or ())),
|
|
distance_in(p.total_distance),
|
|
])
|
|
return rows
|
|
|
|
|
|
PROPOSAL_COLUMNS = ['#', 'donors', 'how many', 'features covered',
|
|
'exact', 'reviewed (MASTER)', 'same elevation',
|
|
'total distance']
|
|
|
|
|
|
def donor_rows(explain_rows, approximate_names=None, master_names=None,
|
|
same_elevation_names=None):
|
|
"""One row per donor in the shown proposal: what that donor is bringing.
|
|
|
|
Grouped by donor rather than by feature, because the engineer's next
|
|
action is to open one donor's drawing and copy from it, and he needs to
|
|
know what he is going there for.
|
|
"""
|
|
approximate_names = set(approximate_names or ())
|
|
master_names = set(master_names or ())
|
|
same_elevation_names = set(same_elevation_names or ())
|
|
order, by = [], {}
|
|
for row in explain_rows or []:
|
|
key = row.get('donor')
|
|
if key is None:
|
|
continue
|
|
if key not in by:
|
|
order.append(key)
|
|
by[key] = {'n': 0, 'exact': 0, 'approx': False, 'master': False,
|
|
'kinds': {}}
|
|
d = by[key]
|
|
d['n'] += 1
|
|
if row.get('donor_master') or key in master_names:
|
|
d['master'] = True
|
|
if row.get('exact'):
|
|
d['exact'] += 1
|
|
if row.get('approximate') or key in approximate_names:
|
|
d['approx'] = True
|
|
label = short_label(row)
|
|
d['kinds'][label] = d['kinds'].get(label, 0) + 1
|
|
out = []
|
|
for key in order:
|
|
d = by[key]
|
|
kinds = ', '.join('{0} x {1}'.format(v, k)
|
|
for k, v in sorted(d['kinds'].items()))
|
|
out.append([
|
|
mark(key, [key] if d['master'] else (), d['approx'],
|
|
same_elevation_names),
|
|
'MASTER -- reviewed' if d['master'] else 'reinforced only',
|
|
str(d['n']),
|
|
str(d['exact']),
|
|
kinds,
|
|
])
|
|
return out
|
|
|
|
|
|
DONOR_COLUMNS = ['donor', 'provenance', 'features it covers',
|
|
'of those, exact', 'what']
|
|
|
|
|
|
def feature_id_cell(row):
|
|
"""The shared-vocabulary feature-ID pairing for one explain row.
|
|
|
|
'target #k' names the registry id of the TARGET feature's shape;
|
|
when a donor covers it, '<-> donor #j' names the donor feature's id too --
|
|
equal when the two shapes are identical at the index grain, which is what
|
|
lets a reader see the numbering the registry assigned. The ids are an INDEX
|
|
only (see `reinforcecore.Registry`): they never rank and never enter
|
|
`covers()`; this column just exposes them.
|
|
|
|
A target shape NO donor ever produced has no registry id and reads 'not in
|
|
donor vocabulary' -- the informative "nobody carries this shape" signal,
|
|
surfaced as uncovered/PARTIAL elsewhere, never an error and never blank.
|
|
"""
|
|
tid = row.get('target_feature_id')
|
|
left = 'target #{0}'.format(tid) if tid is not None else \
|
|
'not in donor vocabulary'
|
|
if row.get('donor') is None:
|
|
return left
|
|
did = row.get('donor_feature_id')
|
|
if did is None:
|
|
return '{0} ↔ donor (unregistered)'.format(left)
|
|
return '{0} ↔ donor #{1}'.format(left, did)
|
|
|
|
|
|
def feature_rows(explain_rows, suggestion=None):
|
|
"""One row per TARGET feature. Every feature gets a row, always.
|
|
|
|
A feature this proposal does not cover still occupies a line, and the
|
|
line says which kind of not-covered it is: another donor has it and this
|
|
set simply did not use it, or nobody has it at all.
|
|
"""
|
|
nobody = set((suggestion.uncovered if suggestion else ()) or ())
|
|
capped = set((suggestion.capped_out if suggestion else ()) or ())
|
|
cap = suggestion.donor_cap if suggestion else None
|
|
out = []
|
|
for row in explain_rows or []:
|
|
i = row.get('target_index')
|
|
if row.get('donor') is not None:
|
|
got = '{0} #{1}'.format(row['donor'], row.get('donor_index'))
|
|
elif i in nobody:
|
|
got = 'NOBODY HAS IT'
|
|
elif i in capped:
|
|
# Somebody has it; the cap is why it is not here. Two different
|
|
# findings, two different next actions, never the same words.
|
|
got = 'LEFT OUT BY THE {0}-DONOR CAP'.format(cap)
|
|
else:
|
|
got = 'not in this set (another donor has it)'
|
|
note = ''
|
|
if row.get('through_unjudged'):
|
|
note = 'penetration not judged'
|
|
out.append([
|
|
str(i),
|
|
feature_label(row),
|
|
rect(row.get('rect')),
|
|
(length_in(row.get('strip_ft'))
|
|
if row.get('strip_ft') is not None else '--'),
|
|
got,
|
|
exactness(row),
|
|
note,
|
|
feature_id_cell(row),
|
|
])
|
|
return out
|
|
|
|
|
|
FEATURE_COLUMNS = ['#', 'feature', 'rectangle (in from start of piece)',
|
|
'strip width', 'covered by', 'how close', 'note',
|
|
'feature-ID']
|
|
|
|
|
|
def uncovered_rows(explain_rows, suggestion):
|
|
"""The features NOBODY has. The most useful half of a partial answer."""
|
|
nobody = set((suggestion.uncovered if suggestion else ()) or ())
|
|
out = []
|
|
for row in explain_rows or []:
|
|
if row.get('target_index') not in nobody:
|
|
continue
|
|
out.append([
|
|
str(row.get('target_index')),
|
|
feature_label(row),
|
|
rect(row.get('rect')),
|
|
(length_in(row.get('strip_ft'))
|
|
if row.get('strip_ft') is not None else '--'),
|
|
])
|
|
return out
|
|
|
|
|
|
UNCOVERED_COLUMNS = ['#', 'feature', 'rectangle (in from start of piece)',
|
|
'strip width']
|
|
|
|
|
|
def capped_rows(explain_rows, suggestion):
|
|
"""The features some donor HAS but this proposal could not reach.
|
|
|
|
A separate table from "Nobody has these" on purpose: the two look
|
|
identical on a feature-by-feature line -- uncovered either way -- and they
|
|
call for opposite actions. Nobody-has means detail it from scratch.
|
|
Capped-out means the donor exists and the engineer can go and open it.
|
|
"""
|
|
left = set((suggestion.capped_out if suggestion else ()) or ())
|
|
out = []
|
|
for row in explain_rows or []:
|
|
if row.get('target_index') not in left:
|
|
continue
|
|
out.append([
|
|
str(row.get('target_index')),
|
|
feature_label(row),
|
|
rect(row.get('rect')),
|
|
(length_in(row.get('strip_ft'))
|
|
if row.get('strip_ft') is not None else '--'),
|
|
])
|
|
return out
|
|
|
|
|
|
CAPPED_COLUMNS = ['#', 'feature', 'rectangle (in from start of piece)',
|
|
'strip width']
|
|
|
|
|
|
def cap_note(suggestion):
|
|
"""`CAP_NOTE` filled in, when the cap actually bit. Empty otherwise --
|
|
a rule that changed nothing is noise on a page that is already long."""
|
|
if suggestion is None or not suggestion.capped_out:
|
|
return []
|
|
return [CAP_NOTE.format(suggestion.donor_cap)]
|
|
|
|
|
|
def rejection_rows(info):
|
|
"""Why the donor pool is the size it is.
|
|
|
|
A pool that shrank from hundreds to a handful without saying why is not a
|
|
filter, it is a silent exclusion.
|
|
"""
|
|
counts = (info or {}).get('rejected') or {}
|
|
why = {
|
|
'self': 'the target itself',
|
|
'unjudged': 'never measured -- could not be judged, not "has nothing"',
|
|
'no_features': 'measured, genuinely no openings and so no strips',
|
|
'not_eligible': 'not marked reinforced, or on hold',
|
|
'prefix': 'outside the declared scope -- another engineer\'s piece',
|
|
'design': 'a DIFFERENT DESIGN NUMBER -- unusable as a template, '
|
|
'however well it fits',
|
|
'design_unknown': 'its own design number could not be read -- not '
|
|
'judged, and never assumed to match',
|
|
'design_target_unknown': 'the TARGET has no readable design number, '
|
|
'so nothing could be compared to it',
|
|
'thickness': 'different panel thickness',
|
|
'thickness_unknown': 'thickness could not be compared',
|
|
'width': 'panel height outside the allowed difference',
|
|
'width_unknown': 'panel height could not be compared',
|
|
}
|
|
out = []
|
|
for key in sorted(counts, key=lambda k: -counts[k]):
|
|
out.append([key, str(counts[key]), why.get(key, '')])
|
|
return out
|
|
|
|
|
|
REJECTION_COLUMNS = ['rejected for', 'pieces', 'what that means']
|
|
|
|
|
|
def master_note(info, suggestion=None):
|
|
"""What the MASTER mark is, and what it is not. Printed once, when it
|
|
can matter -- which is whenever the pool contains one, whether or not a
|
|
proposal happened to pick it.
|
|
|
|
Also says the opposite thing out loud when the pool contains NONE. A
|
|
reader who sees no daggers has to be able to tell "no reviewed template
|
|
was available" from "the mark is not being shown", and only the first is
|
|
a fact about this job.
|
|
"""
|
|
names = list((info or {}).get('master_donors') or [])
|
|
pool = (info or {}).get('donors')
|
|
if (info or {}).get('master_unjudged'):
|
|
return ['> **The reviewed-template check did not run.** This plant '
|
|
'has not mapped `parameters.member.identity_description`, so '
|
|
'no piece was read for the MASTER marker and the ranking '
|
|
'below has no provenance in it at all. That is "nobody '
|
|
'looked", **not** "no donor is reviewed". Map the role in '
|
|
'the plant knowledge file to turn it on.']
|
|
if not names:
|
|
if not pool:
|
|
return []
|
|
return ['> **No donor in this pool is typed MASTER.** Every one of '
|
|
'the {0} is marked `TICKET_IS_REINFORCED`, which means '
|
|
'somebody drew reinforcing on it and **not** that anybody '
|
|
'reviewed it. Nothing below carries a review behind it.'
|
|
.format(pool)]
|
|
shown = ', '.join(sorted(names)[:20]) + (' ...' if len(names) > 20 else '')
|
|
return ['> **Typed MASTER in this pool: {0} of {1} donor(s)** '
|
|
'— reviewed, not merely reinforced: {2}. A MASTER is '
|
|
'preferred **only between donor sets that fit equally well**; it '
|
|
'never outranks a better fit.'
|
|
.format(len(names), pool if pool is not None else len(names),
|
|
shown)]
|
|
|
|
|
|
def needs_master_footnote(explain_rows, info=None, suggestion=None):
|
|
"""True when a MASTER mark is actually printed somewhere."""
|
|
if (info or {}).get('master_donors'):
|
|
return True
|
|
for row in explain_rows or []:
|
|
if row.get('donor_master'):
|
|
return True
|
|
for p in (suggestion.proposals if suggestion else ()):
|
|
if p.masters:
|
|
return True
|
|
return False
|
|
|
|
|
|
def band_label(band):
|
|
"""A geometric elevation band, human-readable: its axis plus the
|
|
coordinate range on it, in INCHES -- this module's stated print
|
|
convention (see the unit-seam note in `reinforcecore`: a stored
|
|
coordinate converts to inches only at the moment of printing, never
|
|
earlier). None for a band that is None or malformed -- the same
|
|
could-not-judge answer either way.
|
|
"""
|
|
try:
|
|
axis = band.get('axis')
|
|
lo, hi = band.get('min'), band.get('max')
|
|
except AttributeError:
|
|
return None
|
|
if axis is None or lo is None or hi is None:
|
|
return None
|
|
return '{0} @ {1} to {2}'.format(axis, length_in(lo), length_in(hi))
|
|
|
|
|
|
def elevation_signal_label(signal):
|
|
"""A target's own elevation signal -- `{'group': ..., 'band': ...}`,
|
|
`info['target_elevation']`'s shape -- rendered for a reader.
|
|
|
|
The group, printed as-is, when the piece carries one (the ACCELERATOR:
|
|
a plant's own name for the wall plane, e.g. a drafting sheet). Falls
|
|
back to the GEOMETRIC band otherwise -- the floor every piece has an
|
|
answer from, or None if neither could be determined. Neither is
|
|
privileged over the other in what it MEANS; this only picks which is
|
|
more informative to print when both happen to be present.
|
|
"""
|
|
signal = signal or {}
|
|
group = signal.get('group')
|
|
if group:
|
|
return 'sheet {0}'.format(group)
|
|
return band_label(signal.get('band'))
|
|
|
|
|
|
def elevation_source_label(source):
|
|
"""`core.ELEVATION_BY_GROUP` / `core.ELEVATION_BY_BAND` -> a short
|
|
phrase naming which source decided one donor. None for neither."""
|
|
if source == core.ELEVATION_BY_GROUP:
|
|
return 'same sheet'
|
|
if source == core.ELEVATION_BY_BAND:
|
|
return 'same geometry'
|
|
return None
|
|
|
|
|
|
def elevation_note(info):
|
|
"""What the SAME-ELEVATION mark is, and what it is not. Mirrors
|
|
`master_note`: printed whenever elevation could matter, which is
|
|
whenever the target's OWN elevation -- by EITHER source -- was
|
|
determined at all.
|
|
|
|
THREE ANSWERS, not two, same discipline `master_note` and
|
|
`design_refusal` already use. Neither source could determine the
|
|
target's elevation (nobody could be preferred, and that is "nobody
|
|
looked" -- not "no donor is on this elevation"); it was determined and
|
|
nothing in the pool shares it (a real finding: the tie-break simply had
|
|
nothing to prefer); or it was determined and some donor(s) share it,
|
|
named the same way MASTER donors are -- each tagged with WHICH source
|
|
answered for it (`info['same_elevation_sources']`), because "same
|
|
sheet" and "same geometry" are different enough claims that a reader
|
|
should not have to guess which one fired.
|
|
|
|
Elevation has TWO SOURCES since the owner's 2026-09-02 ruling (see
|
|
`reinforcecore.elevation_match`) -- geometry is the ALWAYS-ON floor, a
|
|
plant-declared grouping parameter (MC's `Tagged_Sheet`) an OPTIONAL
|
|
accelerator most jobs do not map. `info['target_elevation']` carries
|
|
both halves of what the TARGET itself has; `elevation_signal_label`
|
|
turns it back into words.
|
|
"""
|
|
target = (info or {}).get('target')
|
|
target_signal = (info or {}).get('target_elevation') or {}
|
|
same = list((info or {}).get('same_elevation_donors') or [])
|
|
sources = (info or {}).get('same_elevation_sources') or {}
|
|
pool = (info or {}).get('donors')
|
|
if not target_signal.get('group') and not target_signal.get('band'):
|
|
return ['> **{0}\'s own building elevation could not be determined.'
|
|
'** Neither a plant-declared grouping parameter (most jobs '
|
|
'map none) nor the geometry answered -- the panel is likely '
|
|
'near-square (which dimension is the thickness is '
|
|
'ambiguous) or its thickness is not cleanly aligned to a '
|
|
'global axis. Nothing in the pool could be preferred on it '
|
|
'-- that is "nobody looked", **not** "no donor is on this '
|
|
'elevation". Every other tie-break above it (fewest donors, '
|
|
'most exact, most reviewed, total distance) still applies '
|
|
'in full.'
|
|
.format(target or 'This piece')]
|
|
label = elevation_signal_label(target_signal) or 'an undetermined signal'
|
|
if not same:
|
|
if not pool:
|
|
return []
|
|
return ['> **No donor in this pool shares {0}\'s building elevation '
|
|
'({1}).** It is the **last** tie-break -- after fewest '
|
|
'donors, most exact, most reviewed and total distance -- so '
|
|
'this never excluded a better-fitting donor; there simply '
|
|
'was not one on this wall plane to prefer.'
|
|
.format(target or 'the target', label)]
|
|
shown = ', '.join(
|
|
'{0} ({1})'.format(name, elevation_source_label(sources.get(name))
|
|
or 'elevation')
|
|
for name in sorted(same)[:20]) + (' ...' if len(same) > 20 else '')
|
|
return ['> **Same building elevation as {0} ({1}): {2} of {3} donor(s)** '
|
|
'— {4}. It is the **last** tie-break in the ranking: it '
|
|
'never outranks a donor that fits better, it only decides who '
|
|
'wins among donors everything above it left dead level.'
|
|
.format(target or 'the target', label, len(same),
|
|
pool if pool is not None else len(same), shown)]
|
|
|
|
|
|
def needs_elevation_footnote(info=None, suggestion=None):
|
|
"""True when a same-elevation mark is actually printed somewhere."""
|
|
if (info or {}).get('same_elevation_donors'):
|
|
return True
|
|
for p in (suggestion.proposals if suggestion else ()):
|
|
if p.same_elevation:
|
|
return True
|
|
return False
|
|
|
|
|
|
def needs_footnote(explain_rows, info=None):
|
|
"""True when anything printed carries a star and the footnote must show."""
|
|
if (info or {}).get('target_approximate'):
|
|
return True
|
|
if (info or {}).get('approximate_in_pool'):
|
|
return True
|
|
for row in explain_rows or []:
|
|
if row.get('approximate'):
|
|
return True
|
|
return False
|
|
|
|
|
|
def scope_lines(info):
|
|
"""What the DECLARED SCOPE kept out of the donor pool. Always printed.
|
|
|
|
A pool bounded by a scope and a pool bounded by nothing produce the same
|
|
shaped answer, so the report says which it had every time -- and, when
|
|
the target itself is outside the scope, that the answer rests on
|
|
knowledge nobody has validated for that kind of piece.
|
|
"""
|
|
scope = (info or {}).get('scope') or {}
|
|
if not scope:
|
|
return []
|
|
out = ['> ' + scope['line']]
|
|
if not scope.get('target_in_scope') and scope.get('target_annotation'):
|
|
out.append('> ' + scope['target_annotation'])
|
|
for note in (scope.get('declaration') or []):
|
|
out.append('_{0}_'.format(note))
|
|
return out
|
|
|
|
|
|
def read_only_line():
|
|
"""Said on every report, because a suggestion tool is exactly the thing
|
|
people ask to 'just copy it in for me'."""
|
|
return ('This tool **reads** the model and proposes. It writes nothing '
|
|
'— no parameter, no rebar, no copy. A human decides and a '
|
|
'human draws it.')
|
|
|
|
|
|
# --------------------------------------------------------- when it goes stale
|
|
#
|
|
# A caveat that is always printed is a caveat nobody reads. `vibe.foldreport`
|
|
# folds the routine ones and opens the ones that bite, so something has to
|
|
# decide which this is -- and that decision is a fact about the index, not a
|
|
# formatting choice, so it lives here beside the words it governs.
|
|
#
|
|
# ASCII ONLY IN A SECTION'S title / summary / problem. Those three strings go
|
|
# through `foldreport._section_html`, which escapes and does NOT restore
|
|
# entities -- an `—` there renders as the literal text `—` in the
|
|
# panel. Entities belong in the markdown BLOCKS, which are restored. The
|
|
# convention across the tab is a plain ASCII `--` in those three fields.
|
|
|
|
STALE_AFTER_HOURS = 24
|
|
|
|
|
|
def stale_problem(cache, offline=False, rebuilt_all=False, now=None):
|
|
"""A sentence when the staleness caveat actually bites. None when routine.
|
|
|
|
OFFLINE ALWAYS BITES: nothing was checked against the model at all, and
|
|
that is the single most important thing on an offline page.
|
|
|
|
LIVE, a full rebuild rules it out entirely, and an index built minutes ago
|
|
by the press the reader is looking at is not a caveat worth opening a
|
|
section for -- the standing note is still printed inside it, folded, which
|
|
is what the owner asked for. What DOES bite is an index whose stamp is a
|
|
day or more old: geometry edited since then is invisible and nothing on
|
|
the press catches it.
|
|
|
|
An index with no stamp at all bites too, and reads as could-not-tell
|
|
rather than as fine.
|
|
"""
|
|
built = (cache or {}).get('built_at')
|
|
if offline:
|
|
return ('nothing was checked against the model -- built {0}'
|
|
.format(built or 'never'))
|
|
if not built:
|
|
return 'this index carries no build stamp, so its age is unknown'
|
|
if rebuilt_all:
|
|
return None
|
|
hours = _hours_since(built, now)
|
|
if hours is None:
|
|
return 'the build stamp could not be read: {0}'.format(built)
|
|
if hours >= STALE_AFTER_HOURS:
|
|
return ('the index is {0} day(s) old -- geometry edited since then is '
|
|
'invisible'.format(int(hours // 24)))
|
|
return None
|
|
|
|
|
|
def _hours_since(stamp, now=None):
|
|
"""Hours between an index stamp and now, or None if it will not parse.
|
|
|
|
`time.strptime` and a manual difference in seconds rather than
|
|
`datetime`: this file loads under IronPython 2.7 inside Revit, where the
|
|
fewer library corners touched the better.
|
|
"""
|
|
import time
|
|
try:
|
|
then = time.mktime(time.strptime(str(stamp), '%Y-%m-%d %H:%M:%S'))
|
|
except (ValueError, TypeError, OverflowError):
|
|
return None
|
|
return ((time.time() if now is None else now) - then) / 3600.0
|
|
|
|
|
|
# ------------------------------------------------------------- the whole page
|
|
#
|
|
# THE SHAPE THE OWNER ASKED FOR, 2026-08-30: the target and the donor
|
|
# proposals are the page -- biggest, first. Everything else follows, smaller,
|
|
# and FOLDED BY DEFAULT, because most of it is information he will not read
|
|
# unless something is wrong. A section that has a problem in it opens itself,
|
|
# which turns the page into a contract: if nothing below the headline is open,
|
|
# nothing needs him.
|
|
#
|
|
# The rule itself lives in `vibe.foldreport` rather than here. It is not about
|
|
# reinforcing, and the same treatment is wanted across the rest of the tab.
|
|
#
|
|
# WHAT `render` RETURNS IS SECTIONS, NOT A PAGE. Three renderings of the same
|
|
# content exist and none of them may reword it: the button prints the headline
|
|
# through pyRevit (which can linkify an element id) and the rest as folding
|
|
# HTML; the offline CLI prints plain text with the routine sections folded to
|
|
# one line each; and both write the whole thing out unfolded for the record.
|
|
# Sections are what keep those the same report.
|
|
#
|
|
# EVERY SENTENCE THE OLD FLAT REPORT PRINTED IS STILL HERE. Folded is not
|
|
# gone: a folded section prints its title and what is inside it, and the full
|
|
# text is written somewhere the page names.
|
|
|
|
def render(suggestion, info, explain_rows, cache, model=None,
|
|
refresh_report=None, rebuilt_all=False, refreshed=False,
|
|
offline=False, reinforced=None, on_hold=None, full_path=None,
|
|
now=None, donor_link=None):
|
|
"""The report as `vibe.foldreport.Section`s, headline first. Pure.
|
|
|
|
`explain_rows` is `reinforcebind.explain(suggestion, info, best)`, passed
|
|
in rather than computed, so this module stays uncoupled from `bind`.
|
|
`full_path` is where the unfolded text was written, if anywhere -- named
|
|
on the page so a folded section is never the only copy.
|
|
|
|
`donor_link(name, record)` returns extra markup for a donor's own line, or
|
|
None. It is a parameter because only the button can make one: pyRevit's
|
|
`output.linkify` produces a link that selects the assembly in the model,
|
|
and there is nothing to click at a command line. Everything else on the
|
|
line is identical either way.
|
|
"""
|
|
from vibe import foldreport as fold
|
|
|
|
name = (info or {}).get('target') or '?'
|
|
rows = explain_rows or []
|
|
head = fold.Section('headline', 'Answer', headline=True)
|
|
head.md('# {0} — {1}'.format(TITLE, name))
|
|
if offline:
|
|
head.md(offline_banner(cache))
|
|
if model:
|
|
head.md('Model **{0}**'.format(model))
|
|
head.md(index_line(cache, refresh_report, rebuilt_all, refreshed,
|
|
offline=offline))
|
|
|
|
target_head, target_lines, judged = target_block(
|
|
info, reinforced=reinforced, on_hold=on_hold)
|
|
if not judged:
|
|
search = (info or {}).get('shape_search')
|
|
# A FEATURELESS TARGET IS STILL "NOT JUDGED" IN THE SET-COVER SENSE --
|
|
# there is nothing to cover -- but it is not silent any more, so it
|
|
# does not get the same bare heading as a piece nobody could measure
|
|
# at all. The table is on the headline section (never folded) rather
|
|
# than in the folded tail, because this IS the answer the owner asked
|
|
# for, not a routine detail.
|
|
head.md('## {0}'.format(
|
|
'MEASURED -- NO OPENINGS' if search and search.get('ran')
|
|
else 'NOT JUDGED'))
|
|
head.md(target_head)
|
|
# A refusal that names WHY, WHAT WAS THERE and THE WAY OUT is three
|
|
# paragraphs, not one. They used to be dropped here -- harmless while
|
|
# every not-judged case was a single sentence, and silently lossy the
|
|
# moment one was not.
|
|
for line in target_lines:
|
|
head.md(line)
|
|
if search and search.get('ran'):
|
|
shape_rows = shape_search_rows(search)
|
|
if shape_rows:
|
|
head.table(SHAPE_SEARCH_COLUMNS, shape_rows)
|
|
return [head] + _tail_sections(suggestion, info, rows, cache, offline,
|
|
rebuilt_all, full_path, now)
|
|
|
|
head.md('## Target')
|
|
head.md(target_head)
|
|
for line in target_lines:
|
|
head.md(line)
|
|
|
|
verdict_head, verdict_lines = verdict(suggestion, info)
|
|
head.md('## {0}'.format(verdict_head))
|
|
for line in verdict_lines:
|
|
head.md(line)
|
|
if suggestion is not None:
|
|
head.md('> ' + suggestion.summary())
|
|
|
|
best = suggestion.best if suggestion is not None else None
|
|
if suggestion is not None and suggestion.proposals:
|
|
head.md('## Proposals — fewest donors first, then most exact, '
|
|
'then most reviewed, then same elevation')
|
|
head.table(PROPOSAL_COLUMNS,
|
|
proposal_rows(suggestion, info.get('master_donors'),
|
|
info.get('same_elevation_donors')))
|
|
head.md('## What each donor brings — proposal 1')
|
|
head.table(DONOR_COLUMNS,
|
|
donor_rows(rows, info.get('approximate_in_pool'),
|
|
info.get('master_donors'),
|
|
info.get('same_elevation_donors')))
|
|
for donor in (best.donors if best else ()):
|
|
record = ((cache or {}).get('pieces') or {}).get(donor) or {}
|
|
head.md(_donor_line(donor, record, donor_link))
|
|
|
|
return [head] + _tail_sections(suggestion, info, rows, cache, offline,
|
|
rebuilt_all, full_path, now)
|
|
|
|
|
|
def _tail_sections(suggestion, info, rows, cache, offline, rebuilt_all,
|
|
full_path, now):
|
|
"""Everything below the answer. Folded unless it has a problem in it."""
|
|
from vibe import foldreport as fold
|
|
|
|
out = []
|
|
info = info or {}
|
|
|
|
# ---- what nobody has, and what the cap dropped. Always a problem: these
|
|
# are the two things on the page that name work somebody has to do.
|
|
uncovered = uncovered_rows(rows, suggestion) if suggestion else []
|
|
capped = capped_rows(rows, suggestion) if suggestion else []
|
|
if uncovered or capped:
|
|
gaps = fold.Section(
|
|
'gaps', 'What this proposal does not cover',
|
|
summary='{0} nobody has, {1} left out by the cap'
|
|
.format(len(uncovered), len(capped)),
|
|
problem='{0} feature(s) still need somebody'
|
|
.format(len(uncovered) + len(capped)))
|
|
if uncovered:
|
|
gaps.md('**Nobody has these.** No indexed donor covers them, in '
|
|
'any combination. This is the part of the piece that has '
|
|
'to be detailed from scratch.')
|
|
gaps.table(UNCOVERED_COLUMNS, uncovered)
|
|
if capped:
|
|
gaps.md('**Left out by the {0}-donor cap.** Somebody in the pool '
|
|
'**does** cover these; they are not in the proposal above '
|
|
'only because a proposal may name at most {0} donors.'
|
|
.format(suggestion.donor_cap))
|
|
gaps.table(CAPPED_COLUMNS, capped)
|
|
for line in cap_note(suggestion):
|
|
gaps.md(line)
|
|
out.append(gaps)
|
|
|
|
# ---- feature by feature. The longest table on the page, and the one the
|
|
# owner is least likely to read. Routine by construction.
|
|
if rows:
|
|
out.append(fold.Section(
|
|
'features', 'Feature by feature -- proposal 1',
|
|
summary='{0} target feature(s)'.format(len(rows)),
|
|
blocks=[('md', 'Every feature of the target gets a line, '
|
|
'including the ones this set does not cover. '
|
|
'Positions are absolute INCHES from the start of '
|
|
'the piece — never a fraction of panel '
|
|
'length.'),
|
|
('table', FEATURE_COLUMNS,
|
|
feature_rows(rows, suggestion))]))
|
|
|
|
# ---- provenance: the MASTER signal, same-elevation affinity, and
|
|
# approximated outlines. A check that did not run is a problem; a check
|
|
# that ran and found nothing is not.
|
|
prov = fold.Section('provenance', 'Provenance -- reviewed templates, '
|
|
'building elevation and approximated '
|
|
'outlines')
|
|
for line in master_note(info, suggestion):
|
|
prov.md(line)
|
|
for line in elevation_note(info):
|
|
prov.md(line)
|
|
if needs_footnote(rows, info):
|
|
prov.md(APPROX_FOOTNOTE)
|
|
if needs_master_footnote(rows, info, suggestion):
|
|
prov.md(MASTER_FOOTNOTE)
|
|
if needs_elevation_footnote(info, suggestion):
|
|
prov.md(ELEVATION_FOOTNOTE)
|
|
if prov.blocks:
|
|
prov.summary = '{0} reviewed donor(s) in the pool'.format(
|
|
len(info.get('master_donors') or []))
|
|
if info.get('master_unjudged'):
|
|
prov.problem = ('the reviewed-template check DID NOT RUN -- '
|
|
'nobody was read for the MASTER marker')
|
|
elif _approximate_in_answer(rows, info):
|
|
prov.problem = ('this answer rests on a piece whose outline the '
|
|
'index cannot see')
|
|
out.append(prov)
|
|
|
|
# ---- what the index could not see at all.
|
|
notes = coverage_note(info)
|
|
if notes:
|
|
unjudged = len(info.get('coverage_unjudged') or [])
|
|
cover = fold.Section(
|
|
'coverage', 'What the index could not see',
|
|
summary='{0} piece(s) unmeasured'.format(unjudged),
|
|
problem=('{0} indexed piece(s) could not be measured'
|
|
.format(unjudged) if unjudged else
|
|
'the outline audit has never been run on this model'))
|
|
for line in notes:
|
|
cover.md(line)
|
|
out.append(cover)
|
|
|
|
# ---- the pool: scope and rejections. Routine, unless the target itself
|
|
# is outside the scope, which makes every verdict on the page an opinion.
|
|
scope = info.get('scope') or {}
|
|
pool = fold.Section(
|
|
'pool', 'The donor pool -- who was considered, and who was not',
|
|
summary='{0} looked at, {1} became candidates'
|
|
.format(info.get('considered'), info.get('donors')))
|
|
for line in scope_lines(info):
|
|
pool.md(line)
|
|
rejections = rejection_rows(info)
|
|
if rejections:
|
|
pool.table(REJECTION_COLUMNS, rejections)
|
|
if scope and not scope.get('target_in_scope'):
|
|
pool.problem = ('the target is OUTSIDE the declared scope -- these '
|
|
'verdicts rest on unvalidated knowledge')
|
|
if pool.blocks:
|
|
out.append(pool)
|
|
|
|
# ---- how to read it, and the standing reminder. Never a problem.
|
|
howto = fold.Section('howto', 'How to read the numbers, and what this '
|
|
'tool will not do',
|
|
summary='inches, on a 1/8 in grain; read only')
|
|
howto.md(GRAIN_NOTE)
|
|
howto.md(read_only_line())
|
|
howto.md('**Reminder:** nothing here was written to the model, and the '
|
|
'middle band of a panel vertical reinforcing is PER DESIGN '
|
|
'— an engineer calculation for that one piece. It is not '
|
|
'a thing to copy across from a donor.')
|
|
if full_path:
|
|
howto.md('The whole report, nothing folded, was written to `{0}`.'
|
|
.format(full_path))
|
|
out.append(howto)
|
|
|
|
# ---- staleness. Folded when it is the standing note; open when it bites.
|
|
stale = fold.Section(
|
|
'staleness', 'How current this is',
|
|
summary='built {0}'.format((cache or {}).get('built_at') or 'never'),
|
|
problem=stale_problem(cache, offline=offline,
|
|
rebuilt_all=rebuilt_all, now=now))
|
|
stale.md(staleness_note(rebuilt_all, offline=offline))
|
|
out.append(stale)
|
|
return out
|
|
|
|
|
|
def _approximate_in_answer(rows, info):
|
|
"""An approximated outline in the proposal being SHOWN, not merely in the
|
|
pool. A donor nobody used is not a caveat on this answer."""
|
|
for row in rows or []:
|
|
if row.get('donor') is not None and row.get('approximate'):
|
|
return True
|
|
return bool((info or {}).get('target_approximate'))
|
|
|
|
|
|
def _donor_line(name, record, donor_link=None):
|
|
"""The donor's own line. The button hands in a linkify that selects the
|
|
assembly; with no Revit there is nothing to click, so the id is printed as
|
|
text -- which is what a reader types into Revit's Select by ID."""
|
|
design = (record or {}).get('design_number')
|
|
bits = ['- **{0}**'.format(name)]
|
|
if design:
|
|
bits.append('design {0}'.format(design))
|
|
link = None
|
|
if donor_link is not None:
|
|
try:
|
|
link = donor_link(name, record)
|
|
except Exception: # pylint: disable=W0703
|
|
link = None
|
|
if link:
|
|
bits.append(link)
|
|
elif (record or {}).get('asm_id'):
|
|
bits.append('assembly id {0}'.format(record['asm_id']))
|
|
return ' '.join(bits)
|