Files
willtools-demo/tools/reinforce_offline.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

339 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""Reinforcing suggestions with NO REVIT RUNNING -- answered from the index.
python tools/reinforce_offline.py WP157
python tools/reinforce_offline.py WP157 --index "<...>/reinforce/index.json"
python tools/reinforce_offline.py --model sample_job WP157
python tools/reinforce_offline.py --list WP
python tools/reinforce_offline.py WP157 --all --out report.txt
WHAT IT PRINTS, AND WHY MOST OF IT IS FOLDED
---------------------------------------------
The owner's rule for this report, 2026-08-30: the target and the donor
proposals are the answer and everything else starts folded, because "除非有啥
问题我感觉我不会读" -- unless something is wrong, he will not read it. A
section that HAS something wrong in it opens itself and says why, so the page
carries a contract: if nothing below the answer is open, nothing needs you.
`--all` opens every section; `--out FILE` writes the whole thing unfolded, so
a folded section is never the only copy of those words. The folding itself is
`vibe.foldreport`, shared with the button.
WHY THIS CAN EXIST AT ALL
-------------------------
A Revit model file cannot be parsed outside Revit -- it is a proprietary
format and nothing here pretends otherwise. (Spelling out the extension would
trip `tools/scrub.py`, which reads `<word>.rvt` as a leaked model filename.)
But the model is not what the matcher reads.
It reads the INDEX: `<jobs root>/<model>/reinforce/index.json`, roughly a
megabyte, which already holds every measurement the search needs. Measured on
this project, 2026-08-30: a full index rebuild inside Revit is ~10-16 s, and
the match itself is about 0.05 s of ordinary CPython.
Up to schema 4 exactly one thing on that path still needed a document: the
TARGET was measured live, because donor eligibility kept every not-yet-
reinforced piece -- which is to say, every ordinary target -- out of the file.
Schema 5 measures in-scope non-donors too (`reinforcecore.plan(include=...)`),
and with that gone the whole chain runs off the snapshot. Refresh the index
once with the model open, close Revit, and suggestions run all day.
WHAT THAT COSTS, SAID ON EVERY PAGE THIS PRINTS
------------------------------------------------
The answer is as true as the model was when the index was written and no
truer. Inside Revit the cheap signature pass reruns on every press, so a
member added, removed or swapped, a piece newly reinforced, held or typed
MASTER is caught; only a geometry edit that leaves the member set alone
(an opening dragged, a panel stretched) can go stale. Offline NONE of it is
caught, because nothing looks at the model at all. Every report this prints
opens with the OFFLINE banner and the build stamp, and closes with
`reinforcereport.staleness_note(offline=True)` spelling the whole list out.
NO REVIT IMPORT IS REACHABLE FROM HERE, and that is a property of the call
graph, not a hope: `reinforcecore`, `reinforcematch`, `reinforcereport`,
`scope` and `compat` touch nothing; `reinforceindex` imports the Revit API
only inside the functions that need a document, none of which this calls;
`reinforcebind` likewise, and the two functions in it that do need one --
`outline_audit` and `suggest_for_piece` -- are the two this file never calls.
It calls `suggest_from_index`, which is the same code with the cached record
in place of the live measurement. `tests/test_reinforceoffline.py` asserts
that no `Autodesk` module was loaded after a full offline run.
CPython 3.12 -- this file is a `tools/` script, so modern syntax is fine here.
The `vibe` modules it imports are IronPython 2.7 and stay that way.
"""
from __future__ import print_function
import argparse
import json
import os
import sys
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib')
sys.path.insert(0, _LIB)
# The same trick `tools/ticket_delta.py` and the test conftest use, for the
# same reason: `vibe/__init__.py` imports the Revit API at module scope, which
# is right inside Revit and fatal here. Register a bare `vibe` package pointed
# at the directory so the Revit-free submodules import normally and
# `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(_LIB, 'vibe')]
sys.modules['vibe'] = _stub
from vibe import foldreport as fold # noqa: E402
from vibe import reinforcebind as bind # noqa: E402
from vibe import reinforcecore as core # noqa: E402
from vibe import reinforceindex as index # noqa: E402
from vibe import reinforcereport as R # noqa: E402
JOBS_ENV = 'WILLTOOLS_JOBS'
WIDTH = 78
# ------------------------------------------------------------ finding the file
def resolve_index(index_arg=None, jobs=None, model=None):
"""(path, how) for the index to read. Never guesses silently.
Order: an explicit `--index` path; else `<jobs root>/<model>/reinforce/
index.json`, which is what WILLTOOLS_JOBS means. With a jobs root and no
`--model`, a root holding exactly one model is used and named in the
output; a root holding several refuses and lists them, because picking one
of somebody's jobs at random is the kind of quiet wrong answer this
project spends its comments avoiding.
"""
if index_arg:
return os.path.abspath(index_arg), 'given with --index'
root = jobs or os.environ.get(JOBS_ENV)
if not root:
raise SystemExit(
'No index to read. Give one of:\n'
' --index <path to reinforce/index.json>\n'
' --jobs <jobs root> --model <model name>\n'
' set %s and pass --model <model name>' % JOBS_ENV)
if not os.path.isdir(root):
raise SystemExit('jobs root does not exist: %s' % root)
if not model:
names = sorted(n for n in os.listdir(root)
if os.path.isdir(os.path.join(root, n)))
if len(names) != 1:
raise SystemExit(
'Which model? %s holds %d:\n%s\n\nPass --model <name>.'
% (root, len(names), '\n'.join(' ' + n for n in names)))
model = names[0]
return (index.index_path_in(index.jobs_folder(root, model)),
'jobs root %s, model %s' % (root, model))
def load_index(path):
"""The cache, or a refusal that says what to do about it.
A schema mismatch is not a corrupt file and must not be reported as one:
it means the index was written by an older version of this tool, and the
fix is one refresh inside Revit. The cache is discarded WHOLE -- see
`reinforcecore.load` -- because a schema-4 file holds eligible donors only
and adopting its pieces would answer "that piece is not in the index" for
every ordinary target while looking entirely healthy.
"""
if not os.path.isfile(path):
raise SystemExit(
'No index at %s\n\n'
'The index is written from inside Revit -- press Reinforcing '
'Suggestion once with the model open, then this will work with '
'Revit closed.' % path)
cache = core.load(path)
problem = cache.get('_load_error')
if problem:
raise SystemExit(
'The index at\n %s\ncould not be used: %s\n\n'
'Nothing was adopted from it -- an index from another schema is '
'discarded whole rather than half-read. Open the model in Revit, '
'press Reinforcing Suggestion once to rewrite it (a full rebuild '
'measured ~10-16 s), and run this again.' % (path, problem))
return cache
# ---------------------------------------------------------------- printing
# The plain-text renderers live in `vibe.foldreport`, not here, because the
# button needs the identical ones to write its unfolded copy to disk. These
# are thin names over them so the CLI reads as a CLI.
plain = fold.md_text
table = fold.table_text
def emit(sections, show_all=False):
"""Print the report, folding the routine sections to one line each.
The owner's rule, applied at a terminal: the answer, then a short list of
what else there is, and full text only for the sections that have a
problem in them. `--all` prints everything.
"""
print(fold.text(sections, show_all=show_all, width=WIDTH))
# -------------------------------------------------------------------- the run
def run(cache, name, folder, include_voids=True, prefixes=None,
no_scope=False, max_proposals=5, max_donors=4, full_path=None):
"""(suggestion, info, sections) for one target, entirely from the snapshot."""
the_scope, stamped = index.scope_in(cache)
if no_scope:
from vibe import scope as scopelib
the_scope = scopelib.Scope()
elif prefixes:
from vibe import scope as scopelib
the_scope = scopelib.Scope(prefixes=prefixes,
source='the --prefix option')
# The outline audit's RESULT, read from the file the live run left beside
# the index. It cannot be recomputed here -- it needs each panel's real
# solid -- and never having been run is not the same as every outline
# being a clean rectangle, which is why `load_audit_at` returns None
# rather than an empty set and the report says which it got.
approximate = bind.load_audit_at(folder)
suggestion, info = bind.suggest_from_index(
cache, name, approximate=approximate, the_scope=the_scope,
include_voids=include_voids, max_proposals=max_proposals,
max_donors=max_donors)
info['scope_stamped'] = stamped
target = (cache.get('pieces') or {}).get(name) or {}
rows = (bind.explain(suggestion, info, suggestion.best)
if suggestion is not None and suggestion.best else [])
sections = R.render(suggestion, info, rows, cache,
model=cache.get('doc_key'), offline=True,
reinforced=target.get('reinforced'),
on_hold=target.get('on_hold'),
full_path=full_path)
if not stamped:
# An index that never recorded a scope is a problem, not a footnote:
# every count below rests on a pool bounded by nobody knows what.
sections[0].md(
'> **This index carries no declared scope.** It was built before '
'the scope was stamped into the file, so the donor pool below is '
'bounded by nothing except the filters named in the rejection '
'table. That is "nobody recorded it", not "no scope was '
'declared". Pass `--prefix` to bound it yourself.')
return suggestion, info, sections
def listing(cache, prefix=None):
"""What this snapshot can answer about. Donors and targets kept apart."""
donors = [r['name'] for r in core.donors(cache, include_unjudged=True)]
targets = [r['name'] for r in core.targets(cache)]
if prefix:
donors = [n for n in donors if n.startswith(prefix)]
targets = [n for n in targets if n.startswith(prefix)]
return donors, targets
def main(argv=None):
ap = argparse.ArgumentParser(
description='Reinforcing suggestions from the cached index, with no '
'Revit running.',
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('mark', nargs='?', help='the piece to suggest donors for')
ap.add_argument('--index', help='path to reinforce/index.json')
ap.add_argument('--jobs', help='jobs root (default: $%s)' % JOBS_ENV)
ap.add_argument('--model', help='model name under the jobs root')
ap.add_argument('--list', dest='list_prefix', nargs='?', const='',
help='list what the index holds, optionally by prefix')
ap.add_argument('--prefix', action='append',
help='bound the donor pool to these mark prefixes, '
'instead of the scope stamped in the index')
ap.add_argument('--no-scope', action='store_true',
help='ignore the stamped scope; every donor may donate')
ap.add_argument('--no-voids', action='store_true',
help='ligaments only -- do not ask a donor to account '
'for the target\'s openings as well')
ap.add_argument('--max-donors', type=int, default=4)
ap.add_argument('--max-proposals', type=int, default=5)
ap.add_argument('--all', dest='show_all', action='store_true',
help='print every section, including the folded ones')
ap.add_argument('--out', dest='out_path',
help='write the whole report, nothing folded, to a file')
ap.add_argument('--json', dest='json_out',
help='write the raw (info minus features) to a file')
args = ap.parse_args(argv)
path, how = resolve_index(args.index, args.jobs, args.model)
cache = load_index(path)
folder = os.path.dirname(path)
if args.list_prefix is not None:
donors, targets = listing(cache, args.list_prefix or None)
print('index: %s (%s)' % (path, how))
print('built: %s' % (cache.get('built_at') or 'never'))
print('%d donor(s), %d piece(s) indexed only as targets'
% (len(donors), len(targets)))
print('\ndonors (may be copied from):')
print(_columns(donors))
print('\ntargets only (not reinforced, or on hold -- ask about these):')
print(_columns(targets))
if not targets:
print(' (none -- this index was built without target pieces. '
'Refresh it inside Revit to add them.)')
return 0
if not args.mark:
ap.error('give a piece mark, or --list')
suggestion, info, sections = run(
cache, args.mark, folder,
include_voids=not args.no_voids,
prefixes=args.prefix, no_scope=args.no_scope,
max_proposals=args.max_proposals, max_donors=args.max_donors,
full_path=args.out_path)
if args.out_path:
# FOLDED IS NOT GONE. When the reader has asked for a file, it holds
# every section unfolded -- so a section the terminal showed as one
# line is never the only copy of those words.
with open(args.out_path, 'w', encoding='utf-8') as fh:
fh.write(fold.text(sections, show_all=True, width=WIDTH))
emit(sections, show_all=args.show_all)
print('')
folded = [s for s in sections if not s.is_open and not s.headline]
opened = fold.sections_with_problems(sections)
if folded and not args.show_all:
print(' %d section(s) folded: %s'
% (len(folded), ', '.join(s.key for s in folded)))
# THE CONTRACT, SAID OUT LOUD. Claiming "nothing needs you" on a page
# where a section opened itself would be exactly the lie the whole rule
# exists to prevent, so the two cases get two different sentences.
if opened:
print(' %d section(s) opened themselves: %s'
% (len(opened), ', '.join(s.key for s in opened)))
elif not args.show_all:
print(' nothing below the answer needs you. --all opens them.')
print(' index: %s' % path)
print(' found: %s' % how)
if args.out_path:
print(' full: %s' % args.out_path)
if args.json_out:
slim = dict((k, v) for k, v in info.items()
if k not in ('target_features', 'features_by_donor'))
slim['status'] = suggestion.status if suggestion else None
with open(args.json_out, 'w') as fh:
json.dump(slim, fh, indent=1, default=str, sort_keys=True)
print(' json: %s' % args.json_out)
return 0
def _columns(names, per_line=8):
if not names:
return ' (none)'
out = []
for i in range(0, len(names), per_line):
out.append(' ' + ' '.join(n.ljust(10) for n in names[i:i + per_line]))
return '\n'.join(out).rstrip()
if __name__ == '__main__':
sys.exit(main())