Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

227 lines
8.4 KiB
Python

# -*- 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())