8e6ee31596
ci / test (push) Successful in 6m18s
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.
339 lines
16 KiB
Python
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())
|