Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
# -*- 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())
|
||||
Reference in New Issue
Block a user