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,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Revision delta for precast shop tickets -- CLI.
|
||||
|
||||
python tools/ticket_delta.py OLD NEW [--crops DIR] [--json OUT.json]
|
||||
|
||||
OLD and NEW may each be a single PDF or a folder of them. Folders are paired
|
||||
by mark, because the two issues of a real set were named differently (old by
|
||||
mark, new by job-control-mark) and the mark is the only identifier both carry.
|
||||
|
||||
Needs pymupdf on the path. It is not a dependency of the extension -- the
|
||||
Revit side runs IronPython and cannot import it -- so this script is the only
|
||||
thing in the repo that touches a PDF.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
# Same trick the test conftest uses, and for the same reason: vibe/__init__.py
|
||||
# imports the Revit API at module scope, which is right inside Revit and fatal
|
||||
# on host CPython. 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 ticketdelta # noqa: E402
|
||||
|
||||
|
||||
def _load_extract():
|
||||
try:
|
||||
from vibe import ticketextract
|
||||
import pymupdf # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
'ticket_delta needs pymupdf on PYTHONPATH (import name pymupdf, '
|
||||
'not fitz). %s' % exc)
|
||||
return ticketextract
|
||||
|
||||
|
||||
SEV_MARK = {
|
||||
ticketdelta.MATERIAL: '!!',
|
||||
ticketdelta.UNCERTAIN: '??',
|
||||
ticketdelta.ADVISORY: ' *',
|
||||
ticketdelta.HOUSEKEEP: ' ',
|
||||
}
|
||||
|
||||
|
||||
def _pdfs(path):
|
||||
if os.path.isdir(path):
|
||||
return sorted(f for f in os.listdir(path) if f.lower().endswith('.pdf'))
|
||||
return [os.path.basename(path)]
|
||||
|
||||
|
||||
def _dir(path):
|
||||
return path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path))
|
||||
|
||||
|
||||
def render_crop(pdf, page, x, y, out_png, dpi=300, pad=90):
|
||||
"""Render the neighbourhood of a finding so a human can read the number.
|
||||
|
||||
This exists because the text layer is not the sheet. Four independent
|
||||
text-layer failure modes have been measured on this project's sources, and
|
||||
each one produces a confident wrong number rather than an error. Every
|
||||
reported change ships with the picture it was read from; a reviewer who
|
||||
disagrees with the tool can see immediately which of them is wrong.
|
||||
"""
|
||||
import pymupdf
|
||||
doc = pymupdf.open(pdf)
|
||||
pg = doc[page]
|
||||
clip = pymupdf.Rect(max(0, x - pad), max(0, y - pad),
|
||||
min(pg.rect.x1, x + pad), min(pg.rect.y1, y + pad))
|
||||
pix = pg.get_pixmap(dpi=dpi, clip=clip)
|
||||
pix.save(out_png)
|
||||
doc.close()
|
||||
return out_png
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('old')
|
||||
ap.add_argument('new')
|
||||
ap.add_argument('--crops', help='directory to render a PNG per material '
|
||||
'finding into')
|
||||
ap.add_argument('--json', dest='json_out')
|
||||
ap.add_argument('--all', action='store_true',
|
||||
help='print housekeeping findings too')
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
tx = _load_extract()
|
||||
|
||||
old_dir, new_dir = _dir(args.old), _dir(args.new)
|
||||
plan = ticketdelta.pair_sets(_pdfs(args.old), _pdfs(args.new))
|
||||
|
||||
print('=' * 78)
|
||||
print('REVISION DELTA')
|
||||
print(' old issue: %s (%d file(s))' % (args.old, plan['naming']['old_count']))
|
||||
print(' new issue: %s (%d file(s))' % (args.new, plan['naming']['new_count']))
|
||||
if plan['naming']['changed']:
|
||||
print('')
|
||||
print(' !! THE FILE NAMING CHANGED BETWEEN ISSUES.')
|
||||
print(' old files carry a control number on %d of %d;'
|
||||
' new on %d of %d.'
|
||||
% (plan['naming']['old_has_control'], plan['naming']['old_count'],
|
||||
plan['naming']['new_has_control'], plan['naming']['new_count']))
|
||||
print(' These two issues therefore CANNOT be paired by filename.')
|
||||
print(' Pairing below is on the mark, the only identifier both'
|
||||
' namings carry.')
|
||||
if plan['naming']['fan_out']:
|
||||
print('')
|
||||
print(' !! ONE OLD FILE MAPS TO SEVERAL NEW ONES:')
|
||||
for m, n in sorted(plan['naming']['fan_out'].items()):
|
||||
print(' %s: 1 old file -> %d new files' % (m, n))
|
||||
print(' One ticket per control number: several identical panels'
|
||||
' now get')
|
||||
print(' several tickets. The comparison below runs against each'
|
||||
' of them.')
|
||||
for f in plan['old_only']:
|
||||
print(' !! WITHDRAWN: %s is in the old issue and has no counterpart'
|
||||
' in the new' % f)
|
||||
for f in plan['new_only']:
|
||||
print(' !! ADDED: %s is in the new issue and has no counterpart'
|
||||
' in the old' % f)
|
||||
print('=' * 78)
|
||||
|
||||
cache = {}
|
||||
|
||||
def get(path):
|
||||
if path not in cache:
|
||||
cache[path] = tx.extract(path)
|
||||
return cache[path]
|
||||
|
||||
results = []
|
||||
for orec, nrecs in plan['pairs']:
|
||||
a = get(os.path.join(old_dir, orec['file']))
|
||||
for nrec in nrecs:
|
||||
b = get(os.path.join(new_dir, nrec['file']))
|
||||
fs = ticketdelta.compare(a, b, tx.parse_dim, tx.format_inches,
|
||||
tx.locate)
|
||||
results.append({'old': orec['file'], 'new': nrec['file'],
|
||||
'mark': orec['mark'], 'control': nrec['control'],
|
||||
'findings': fs})
|
||||
|
||||
for r in results:
|
||||
counts = ticketdelta.summarise(r['findings'])
|
||||
print('')
|
||||
print('-' * 78)
|
||||
print('%s %s -> %s' % (r['mark'], r['old'], r['new']))
|
||||
print(' %d material, %d uncertain, %d advisory, %d housekeeping'
|
||||
% (counts[ticketdelta.MATERIAL], counts[ticketdelta.UNCERTAIN],
|
||||
counts[ticketdelta.ADVISORY], counts[ticketdelta.HOUSEKEEP]))
|
||||
shown = 0
|
||||
for i, f in enumerate(r['findings']):
|
||||
if f['severity'] == ticketdelta.HOUSEKEEP and not args.all:
|
||||
continue
|
||||
shown += 1
|
||||
print(' %s [%s] %s' % (SEV_MARK[f['severity']], f['kind'], f['where']))
|
||||
print(' %s' % f['what'])
|
||||
if f['old'] is not None or f['new'] is not None:
|
||||
print(' %s -> %s'
|
||||
% (_short(f['old']), _short(f['new'])))
|
||||
if f['note']:
|
||||
print(' (%s)' % f['note'])
|
||||
if not shown:
|
||||
print(' nothing above housekeeping.')
|
||||
|
||||
if args.crops:
|
||||
if not os.path.isdir(args.crops):
|
||||
os.makedirs(args.crops)
|
||||
n = 0
|
||||
for r in results:
|
||||
for i, f in enumerate(r['findings']):
|
||||
if f['severity'] != ticketdelta.MATERIAL:
|
||||
continue
|
||||
for side, key, pdfdir, fname in (
|
||||
('old', 'loc_old', old_dir, r['old']),
|
||||
('new', 'loc_new', new_dir, r['new'])):
|
||||
loc = f.get(key)
|
||||
if not loc:
|
||||
continue
|
||||
stem = os.path.splitext(fname)[0]
|
||||
out = os.path.join(args.crops, '%s_%02d_%s.png'
|
||||
% (stem, i, side))
|
||||
try:
|
||||
render_crop(os.path.join(pdfdir, fname), loc['page'],
|
||||
loc['x'], loc['y'], out)
|
||||
f.setdefault('crops', {})[side] = out
|
||||
n += 1
|
||||
except Exception as exc:
|
||||
print(' crop failed: %s' % exc)
|
||||
print('\n%d crop(s) written to %s' % (n, args.crops))
|
||||
|
||||
if args.json_out:
|
||||
with open(args.json_out, 'w') as fh:
|
||||
json.dump({'plan': {k: v for k, v in plan.items() if k != 'pairs'},
|
||||
'results': results}, fh, indent=1, default=str)
|
||||
print('\njson written to %s' % args.json_out)
|
||||
|
||||
total_material = sum(ticketdelta.summarise(r['findings'])[ticketdelta.MATERIAL]
|
||||
for r in results)
|
||||
print('')
|
||||
print('=' * 78)
|
||||
print('%d pair(s) compared, %d material change(s) in total.'
|
||||
% (len(results), total_material))
|
||||
return 0
|
||||
|
||||
|
||||
def _short(v, n=110):
|
||||
if v is None:
|
||||
return '(none)'
|
||||
v = str(v)
|
||||
return v if len(v) <= n else v[:n - 3] + '...'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user