WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

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:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30), about the reinforcing report:
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
"""Reports that fold: a headline you read, and detail that opens only when it
has a problem in it. No Revit here, and nothing report-specific either.
THE RULE THIS IMPLEMENTS, AND WHOSE IT IS
------------------------------------------
The owner, 2026-08-30, about the reinforcing report:
"The biggest, top-most part is the target and the donor. Everything
else follows after, smaller, and made foldable."
"There is actually a large amount of information in here that I do not
care about. Unless something is wrong I don't think I will read it.
Make this kind of thing default-folded."
Which is a stronger statement than a layout preference, and it is written down
here rather than in one button because it generalises to every report this
tool prints:
* THE ANSWER IS THE HEADLINE. Biggest, first, never folded.
* EVERYTHING ELSE STARTS CLOSED. He will not read it and does not want to
see it.
* A SECTION THAT CARRIES A PROBLEM OPENS ITSELF. A check that did not run, a
caveat that actually bites, a gap somebody has to act on. The reader's
contract becomes: **if nothing below the headline is open, nothing needs
me.** That contract is the whole value, and it is worth more than any
individual section being visible -- which is why `problem` is a SENTENCE
naming what is wrong, not a boolean: a section that opens has to say why
it opened.
* FOLDED IS NOT GONE. Every closed section still prints a one-line header
saying what is inside it and how much. Nothing is hidden; things are
folded. A caveat that has been removed from the page is a caveat the tool
has decided the reader does not need, and this project has already paid
for one of those.
WHAT THE OUTPUT PANEL ACTUALLY IS, AND WHY THE HTML LOOKS DATED
----------------------------------------------------------------
pyRevit's output window is a `System.Windows.Forms.WebBrowser`
(`pyrevit/output/__init__.py`, line 106) -- the IE engine hosted in a WinForms
control, NOT a modern browser and not the environment the panellift HTML
report renders in. A WinForms WebBrowser runs in IE7 document mode unless the
host process has a `FEATURE_BROWSER_EMULATION` registry entry, and pyRevit
sets none. So:
* `<details>` / `<summary>` DO NOT EXIST here. They are HTML5. This is the
trap, because they are the obvious answer and they fail silently -- an
unknown element renders as an inline span and the "collapsed" content is
simply always visible.
* `addEventListener`, `classList`, `querySelector` are all out.
* `document.getElementById(id).style.display` and an inline `onclick`
attribute are IE5-era DOM and work in every mode. That is what this uses.
JavaScript definitely runs in the panel: pyRevit's own charts feature injects
Chart.js into it (`coreutils/charts.py`). So the toggle should work. But it
has NOT been exercised inside Revit by the agent that wrote it -- there was no
bridge available -- so the failure is designed for rather than hoped against:
* A PROBLEM SECTION IS NEVER FOLDED IN THE FIRST PLACE. It renders open, as
plain HTML, with no dependence on the script running at all. If the toggle
is dead, every section that matters is still on the page.
* A folded section that will not open loses nothing either, as long as the
caller writes the full text out as well -- `text(sections, show_all=True)`
is that, and `reinforcereport` hands the button a path to say so in the
footer.
THE BLOCK SHAPE
---------------
A section holds `('md', text)` and `('table', columns, rows)` tuples, the same
two the reports already produce. Markdown, not HTML, because the same sections
have to render as plain text at a command line -- `tools/reinforce_offline.py`
has no panel -- and a report that became HTML-only would have to be written
twice and would drift.
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
import re
import textwrap
MD = 'md'
TABLE = 'table'
class Section(object):
"""One foldable part of a report.
`key` an id, unique on the page. Used for the toggle's element id.
`title` the one-line header. ALWAYS shown, open or closed.
`summary` what is inside, in numbers, shown on the header when closed.
A header that says only "details" tells the reader nothing
about whether to open it.
`blocks` ('md', text) / ('table', columns, rows).
`problem` None for routine. A SENTENCE when something in here needs the
reader -- it is printed on the header and it forces the
section open.
`headline` True for the part that is the answer: printed big, first, and
never given a fold at all.
"""
def __init__(self, key, title, summary=None, blocks=None, problem=None,
headline=False):
self.key = key
self.title = title
self.summary = summary
self.blocks = list(blocks or [])
self.problem = problem or None
self.headline = bool(headline)
@property
def is_open(self):
"""Open when it is the answer, or when it has a problem in it."""
return self.headline or bool(self.problem)
def add(self, block):
self.blocks.append(block)
return self
def md(self, text):
return self.add((MD, text))
def table(self, columns, rows):
return self.add((TABLE, columns, rows))
def __repr__(self):
return '<Section {0} {1}>'.format(
self.key, 'OPEN' if self.is_open else 'folded')
def sections_with_problems(sections):
"""The ones that opened themselves, in order. What the reader must see."""
return [s for s in sections if s.problem]
def problem_count(sections):
return len(sections_with_problems(sections))
# ------------------------------------------------------------------ as blocks
def blocks(sections, show_all=False):
"""Every section flattened to blocks, folding the routine ones.
A folded section becomes ONE line -- its title, its summary and nothing
else. `show_all` prints everything, which is what a file written for the
record wants and what `--all` gives at a command line.
"""
out = []
for section in sections or []:
if section.headline:
out.extend(section.blocks)
continue
if section.is_open or show_all:
out.append((MD, _open_header(section)))
out.extend(section.blocks)
else:
out.append((MD, _folded_header(section)))
return out
def _open_header(section):
head = '## ' + _text(section.title)
if section.problem:
head += ' &mdash; ' + _text(section.problem)
return head
def _folded_header(section):
bits = ['**' + _text(section.title) + '**']
if section.summary:
bits.append(_text(section.summary))
return '- ' + _text(' &mdash; ').join(bits) + ' _(folded)_'
# ------------------------------------------------------------------- as text
def _text(value):
"""`value` as the interpreter's own text type -- never through `str()`.
THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS PATH, measured
2026-08-31 in the sibling renderer: `last-ticketqc.txt` was written with
every CJK character of the job file's mute sentences removed -- the ASCII
fragments and the gaps between them survived -- while the same sentences
rendered correctly in the panel and in `qc-run.json`. A file promised as
the complete record that silently drops half a sentence is worse than one
that fails to write.
`vibe.compat` is the project's one answer to this class: it returns the
engine's text type, decoding bytes as UTF-8, and the caller encodes once
on the way to disk. Imported lazily so this module keeps the standard-
library-only shape it has at import time.
"""
from vibe import compat
return compat.as_text('' if value is None else value)
def text(sections, show_all=False, width=78):
"""The whole report as plain text. What a terminal and a log file want."""
lines = []
for block in blocks(sections, show_all=show_all):
lines.append(_text(as_text(block, width=width)))
return _text('\n').join(lines)
def as_text(block, width=78):
if block[0] == TABLE:
return table_text(block[1], block[2])
return md_text(block[1], width=width)
def md_text(md, width=78):
"""Markdown as these reports write it -> plain text.
Nothing is reworded. Entities become ASCII (the extension is IronPython
2.7, where a non-ASCII source literal is a byte string, so the reports
write `&mdash;` rather than the character), emphasis marks are dropped,
and headings get a rule under them.
NOR IS ANYTHING DROPPED. Every line goes out through `_fill`, and
`_flat` coerces with `_text` on the way in -- read both before
putting a bare `textwrap` call or a `str()` back here.
"""
body = (md.replace('&mdash;', '--').replace('&nbsp;', ' ')
.replace('&dagger;', '+').replace('&amp;', '&'))
if body.startswith('# '):
title = _flat(body[2:])
return '\n' + '=' * width + '\n' + title + '\n' + '=' * width
if body.startswith('## '):
title = _flat(body[3:])
return '\n' + title + '\n' + '-' * min(width, len(title))
if body.strip() == '---':
return '-' * width
quoted = body.startswith('> ')
if quoted:
body = body[2:]
listed = body.startswith('- ')
prefix = ' ! ' if quoted else ' '
# A folded section's header is a list item, and its continuation lines are
# indented past the dash so a two-line header cannot read as two headers.
tail = ' ' * (len(prefix) + (2 if listed else 0))
# NOTHING LONG IS EVER BROKEN. A file path or a piece mark split across
# two lines is not a wrapped sentence, it is an unusable value: the reader
# copies these into Revit's Select by ID or into a shell. Better a line
# that runs past the margin than one that has to be reassembled.
return _fill(_flat(body), width, prefix, tail)
def _fill(body, width, initial_indent, subsequent_indent):
"""`textwrap.fill`, with its whitespace TRANSLATE step taken out of
the path -- and the reason is invisible on the interpreter the suite
runs on.
`textwrap` munges whitespace by calling `text.translate(<a 256-entry
table>)` on anything it takes for a `str`. On CPython that table is
applied to a byte string, where every byte has an entry; the engine
this extension ships to is IronPython 2.7, whose `str` holds .NET
UTF-16 text, so A CHARACTER ABOVE U+00FF HAS NO ENTRY IN THE TABLE AT
ALL -- and the report came back missing exactly those characters and
nothing else. Measured on the sibling renderer, 2026-08-31.
Everything reaching here has already been flattened by `_flat`, so
there is no whitespace left to munge and `textwrap` is told not to
try. Nothing about the wrapping changes.
`ticketpage._wrap_text` is the same cure at the other renderer; the
two are one measured defect, not two style preferences.
NOTHING LONG IS EVER BROKEN, unchanged: a file path or a piece mark
split across two lines is not a wrapped sentence, it is an unusable
value -- the reader copies these into Revit or into a shell.
"""
return textwrap.fill(_text(body), width=width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=False, break_on_hyphens=False,
expand_tabs=False, replace_whitespace=False)
def _flat(body):
return (' '.join(_text(body).split())
.replace('**', '').replace('`', '').replace('__', ''))
def table_text(columns, rows):
"""A fixed-width table. Nothing is truncated: every cell is a measurement
or a piece mark, and a silently cut mark is a wrong answer that looks like
a right one.
The column widths are counted in CHARACTERS, so a cell of CJK -- two
columns wide in a terminal font and one character long -- leaves its
column looking ragged. That is cosmetic and it is the only thing this
gets wrong about such a cell: nothing is dropped, cut or reordered.
Straightening it needs east-asian-width tables; nobody has asked."""
if not rows:
return ' (nothing)'
grid = [[_cell(c) for c in columns]]
grid += [[_cell(c) for c in row] for row in rows]
width = [max(len(row[i]) for row in grid) for i in range(len(columns))]
out = [' ' + ' '.join(c.ljust(width[i]) for i, c in enumerate(grid[0])),
' ' + ' '.join('-' * w for w in width)]
for row in grid[1:]:
out.append(' ' + ' '.join(c.ljust(width[i])
for i, c in enumerate(row)))
return '\n'.join(out)
def _cell(value):
# `_text`, not `str`: a cell can hold a mark, a note, or a sentence
# somebody typed. This is the text path's OTHER coercion boundary --
# `md_text` itself has none, its only exposure was the wrap.
return (_text(value).replace('&mdash;', '--').replace('&dagger;', '+')
.replace('&nbsp;', ' ').replace('&amp;', '&'))
# ------------------------------------------------------------------- as HTML
#
# Everything below is written for an IE-engine WebBrowser control. See the
# module docstring: no HTML5 elements, no modern DOM, inline handlers only.
STYLE = """
.wt-fold { margin: 10px 0 0 0; }
.wt-head { background: #eef1f5; border-left: 4px solid #9fb0c4;
padding: 5px 8px; cursor: pointer; font-size: 8pt; color: #445; }
.wt-head-open { background: #f9f2f4; border-left: 4px solid #c7254e;
color: #c7254e; cursor: pointer; padding: 5px 8px;
font-size: 8pt; }
.wt-body { padding: 2px 8px 8px 14px; font-size: 8pt; }
.wt-why { font-weight: bold; }
.wt-hint { color: #889; font-size: 7pt; }
"""
# The toggle. `document.getElementById` and `style.display` are IE5-era DOM
# and work in every document mode a WinForms WebBrowser can be in; nothing
# here uses addEventListener, classList or querySelector, and nothing uses
# <details>, which does not exist in this engine at all.
SCRIPT = """
function wtFold(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.display == 'none') {
el.style.display = 'block';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
} else {
el.style.display = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
}
}
function wtClip(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.maxHeight == 'none') {
el.style.maxHeight = el.getAttribute('wtcap');
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
} else {
el.style.maxHeight = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
}
}
"""
# `wtClip` IS `wtFold`'S SIBLING, NOT A SECOND MECHANISM.
#
# Same conventions, deliberately: an inline `onclick`, an element id, a
# header at `id + '_h'`, and the same `[+]`/`[-]` marker swap. It exists
# because the two folds hide differently and one function cannot do both:
#
# wtFold toggles `display`, for a block that is ABSENT when closed. Used
# by every section of a folding report.
# wtClip toggles `max-height` against the element's own `wtcap`
# attribute, for a block that is WHOLLY PRESENT and merely too
# tall -- the checklist's note cells (`ticketpage.note_cell_html`).
# Its text stays in the document when collapsed, which is exactly
# why it is not `display: none`: a reader can still select and
# copy the part he cannot see, and a browser's own find still
# reaches it.
#
# Both are IE5-era DOM. `style.maxHeight` and `getAttribute` are as old as
# `style.display`, and nothing here uses addEventListener, classList,
# querySelector or `<details>`.
def html(sections, md_to_html=None, headline=False):
"""The folding half of the report as one HTML string.
`headline` sections are skipped by default: the button prints those
through pyRevit's own `print_md` / `print_table`, because that is what can
linkify an element id and what the reader is used to reading. Pass
`headline=True` to render everything.
`md_to_html` converts one markdown block. The default handles exactly the
vocabulary these reports use; a caller with a real markdown library (the
button has `pyrevit.coreutils.markdown`) can pass that instead.
"""
md_to_html = md_to_html or md_html
out = []
for section in sections or []:
if section.headline and not headline:
continue
out.append(_section_html(section, md_to_html))
# ONE LINE, NO NEWLINE ANYWHERE IN IT. pyRevit's `print_html` is a
# `print(..., end='')` into the panel's stream, and every newline in that
# stream starts a new output entry -- which would cut a `<div>` in half
# and leave a fold's body outside its own container. So joined with
# nothing, here and inside each section.
return ''.join(out)
def _section_html(section, md_to_html):
body = []
for block in section.blocks:
if block[0] == TABLE:
body.append(table_html(block[1], block[2]))
else:
body.append(md_to_html(block[1]))
shown = section.is_open
mark = '[-]' if shown else '[+]'
# Escaped, then the report's own entities put back -- exactly as
# `_cell_html` and `md_html` do. A header may carry `&mdash;` like any
# other report string; without the restore it would reach the panel as
# the literal source text.
head = [mark, '<b>' + _entities(_escape(section.title)) + '</b>']
if section.problem:
head.append('<span class="wt-why">&mdash; ' +
_entities(_escape(section.problem)) + '</span>')
elif section.summary:
head.append('&mdash; ' + _entities(_escape(section.summary)))
if not shown:
head.append('<span class="wt-hint">(click to open)</span>')
return (
'<div class="wt-fold">'
'<div id="{0}_h" class="{1}" onclick="wtFold(\'{0}\')">{2}</div>'
'<div id="{0}" class="wt-body" style="display:{3}">{4}</div>'
'</div>').format(
_id(section.key),
'wt-head-open' if section.problem else 'wt-head',
' '.join(head),
'block' if shown else 'none',
''.join(body))
def _id(key):
return 'wt_' + re.sub(r'[^0-9A-Za-z_]', '_', str(key))
def table_html(columns, rows):
if not rows:
return '<p><i>(nothing)</i></p>'
out = ['<table><tr>']
for column in columns:
out.append('<th>' + _escape(str(column)) + '</th>')
out.append('</tr>')
for row in rows:
out.append('<tr>')
for cell in row:
out.append('<td>' + _cell_html(cell) + '</td>')
out.append('</tr>')
out.append('</table>')
return ''.join(out)
def _cell_html(value):
# Cells already carry the report's own entities (`&mdash;`, `&dagger;`)
# and its marks. They are escaped and the entities put back, so a mark
# containing an ampersand cannot break the row and a dagger still prints.
return _entities(_escape(str(value)))
# The report's whole markdown vocabulary, and nothing else: it is a fixed set
# written by one module, not arbitrary user text, so a 30-line converter is
# honest here where a general one would be a dependency.
_BOLD = re.compile(r'\*\*(.+?)\*\*')
_CODE = re.compile(r'`(.+?)`')
_ITALIC = re.compile(r'(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])')
def md_html(md):
"""One markdown block -> HTML, for the panel."""
text_ = md or ''
if text_.strip() == '---':
return '<hr/>'
tag, body = 'p', text_
if body.startswith('### '):
tag, body = 'h4', body[4:]
elif body.startswith('## '):
tag, body = 'h3', body[3:]
elif body.startswith('# '):
tag, body = 'h2', body[2:]
quoted = body.startswith('> ')
if quoted:
body = body[2:]
body = _escape(body)
body = _BOLD.sub(r'<b>\1</b>', body)
body = _CODE.sub(r'<code>\1</code>', body)
body = _ITALIC.sub(r'<i>\1</i>', body)
body = _entities(body)
if quoted:
return ('<div style="border-left:3px solid #9fb0c4;padding-left:8px">'
'<' + tag + '>' + body + '</' + tag + '></div>')
return '<' + tag + '>' + body + '</' + tag + '>'
def _escape(body):
return (body.replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;'))
def _entities(body):
"""Put back the handful of entities the reports write on purpose.
They are escaped along with everything else on the way in -- that is the
point of escaping -- and then restored by name here, so the reports keep
writing `&mdash;` (ASCII source, IronPython 2.7 safe) and the panel keeps
printing an em dash.
"""
for name in ('mdash', 'dagger', 'nbsp', 'ndash', 'hellip'):
body = body.replace('&amp;' + name + ';', '&' + name + ';')
return body