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
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""Where an extracted design spec is saved, and what is still unresolved in it.
Nothing here touches Revit, a document, a form or the extraction itself -- it
is paths and text in, paths and text out. That is deliberate, and it is the
same boundary `specformat.py` sits on: the rules the Extract Design button
follows when it asks the engineer where to put a file, and when it lists what
the reader could not place, are exercised by an ordinary CPython test with no
licence and no model (see `tests/conftest.py`).
THE TWO THINGS THIS EXISTS FOR
------------------------------
1. A SECOND COPY, NOT A MOVE. The canonical spec has to stay in
`<model>/_ticketqc/specs/` -- `designspec.list_specs` reads that folder and
nothing else, so Ticket QC and Panel Lift Check only ever see what is
there. A spec "saved" somewhere else and nowhere else would look extracted
and be invisible to every check downstream. So the engineer's chosen path
gets a COPY, and `targets()` is the rule for naming it.
2. UNRESOLVED BY NAME, NOT BY COUNT. "3 unresolved notes" tells a reader
there is something to look at and nothing about whether to look now.
The entries are short, they are the whole point of the extraction being
honest, and they belong on the page.
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib.
"""
# pylint: disable=invalid-name
import io
import os
from vibe import specformat
from vibe.compat import as_text, string_types
# The pointer file that remembers where an extraction was last saved, kept
# beside the rest of the job's QC data. It is a convenience, not a setting:
# it only stops the dialog opening in the wrong folder every single press.
# Where the files BELONG is the knowledge/config layers' business, and a
# pointer that is stale or gone must never do anything but be ignored.
POINTER = 'last-extract-dir.txt'
def safe_name(name):
"""A design number as a filename. The one rule -- `designspec._safe`
delegates here so the copy cannot be named differently from the original.
"""
out = []
for ch in (name or 'unnamed'):
out.append(ch if (ch.isalnum() or ch in '._- ') else '_')
return ''.join(out).strip()
# ------------------------------------------------------------ where it goes
def default_name(designs, ext='yaml'):
"""The name to seed the save dialog with.
One design puts its own number in the box, which is the name the file
already has and the one the engineer expects to see. Several designs
cannot share a file name, so the box carries the count instead -- it is
the FOLDER that is being picked in that case, and `targets()` says so.
"""
designs = [d for d in (designs or []) if d]
if len(designs) == 1:
return '{0}.{1}'.format(safe_name(designs[0]), ext)
if not designs:
return 'design-extraction.{0}'.format(ext)
return 'design-extraction-{0}-designs.{1}'.format(len(designs), ext)
def targets(chosen, designs, ext='yaml'):
"""[(design, path)] -- where each design's copy is written.
A single design is written to EXACTLY the path that was typed. A file
name box that quietly does nothing is a trap (CLAUDE.md says this about
the config file and it is the same trap here), so where the name can be
honoured it is.
Several designs cannot all be one file. The folder that was picked is
used and each design keeps its own name, because a design number is how
every other part of this tool finds the file -- renaming them to
`something-1.yaml`, `something-2.yaml` would make the set unreadable.
The button says this in the output rather than leaving it to be
discovered.
"""
designs = [d for d in (designs or []) if d]
if not chosen or not designs:
return []
if len(designs) == 1:
return [(designs[0], chosen)]
folder = os.path.dirname(chosen) or '.'
suffix = os.path.splitext(chosen)[1] or ('.' + ext)
return [(d, os.path.join(folder, safe_name(d) + suffix)) for d in designs]
def href(path):
"""A `file:///` URL for a Windows path.
A bare Windows path in a markdown link is a DEAD LINK in pyRevit's output
panel: backslashes and no scheme, so the browser control renders the link
and navigates nowhere. Same fix as PanelLiftCheck -- only the href gets
the URL form; the visible text stays the real path, because that is what
gets copied into Explorer.
"""
return 'file:///' + (path or '').replace('\\', '/')
def link(path):
"""`[the real path](file:///the/real/path)`, ready for print_md."""
return '[{0}]({1})'.format(path, href(path))
def same_file(one, other):
"""Whether two paths name the same file.
The dialog opens on the folder the specs already live in, so pressing
Save without moving is a perfectly ordinary thing to do -- and a "copy"
onto itself is at best a pointless rewrite of a file something else may
be reading. Compared by path rather than by `os.path.samefile`, which
IronPython 2.7 does not carry on every Windows build; both paths come
from this process, so normcase is enough.
"""
if not one or not other:
return False # no path is never "the same file"
return (os.path.normcase(os.path.abspath(one)) ==
os.path.normcase(os.path.abspath(other)))
# ------------------------------------------------- remembering the last one
def _pointer_path(root):
return os.path.join(root or '', POINTER)
def recall_dir(root):
"""The folder an extraction was last saved to, or '' -- never a guess.
A pointer to a folder that has gone is not a folder: it is dropped, and
the caller falls back to its own default. Silently seeding a dialog with
a path that does not exist is how a save lands somewhere nobody meant.
"""
try:
with io.open(_pointer_path(root), 'r', encoding='utf-8') as fh:
folder = fh.read().strip()
except Exception:
return ''
if folder and os.path.isdir(folder):
return folder
return ''
def remember_dir(root, folder):
"""Record where the engineer just saved. Failure here is never fatal."""
if not folder:
return ''
try:
with io.open(_pointer_path(root), 'w', encoding='utf-8') as fh:
fh.write(as_text(folder))
return folder
except Exception:
return ''
# ------------------------------------------------------ what is not resolved
def unresolved_entries(spec):
"""[(text, reason)] from the spec's `unresolved` list, verbatim.
Tolerant on the way in: an entry written as a bare string by an older
extractor still reads as a note with no reason given, rather than
disappearing out of the one list whose whole job is not losing lines.
"""
out = []
for entry in (specformat.get_path(spec, 'unresolved', []) or []):
if isinstance(entry, string_types):
out.append((entry, ''))
elif isinstance(entry, dict):
out.append((entry.get('text') or '', entry.get('reason') or ''))
else:
out.append(('{0}'.format(entry), ''))
return [(t, r) for t, r in out if t]
def shared_reason(entries):
"""The one reason every entry gives, or ''.
The mechanical reader gives the same sentence to every note it could not
place. Printed once above the list it is an explanation; printed on all
fourteen lines it is what stops the list being read.
"""
reasons = set([r for _t, r in (entries or [])])
if len(reasons) == 1:
return list(reasons)[0]
return ''
# The fields something downstream actually reads off a spec. A null here is
# not necessarily an error -- the summary may genuinely not state it -- but a
# blank field is the OTHER way a spec is unresolved, and it is invisible
# unless it is named. `strand_layout.columns_in` is on the list knowing that
# the regex reader never fills it (see `designspec.build_spec`): that is
# exactly the sort of blank a reader should be able to see.
KEY_FIELDS = (
'concrete.fc_psi',
'concrete.fci_psi',
'concrete.unit_weight_pcf',
'thickness_in',
'envelope.length.max_ft',
'envelope.width.max_ft',
'reinforcing.wwf.mesh',
)
LIST_FIELDS = (
'reinforcing.strand_groups',
'reinforcing.strand_layout.columns_in',
)
def blank_fields(spec):
"""Dotted paths of the fields downstream reads that are still empty."""
out = [path for path in KEY_FIELDS
if specformat.get_path(spec, path) is None]
out.extend([path for path in LIST_FIELDS
if not (specformat.get_path(spec, path) or [])])
return out
def problem_sentence(entries, problems=None):
"""Why this design's section opens itself, or None when it need not.
UNRESOLVED NOTES AND VALIDATION FAILURES OPEN IT. Blank fields do NOT,
deliberately: the mechanical reader leaves the strand matrix empty on
every single spec it writes, so opening on a blank field would open every
section on every press and the fold's contract -- if nothing below is
open, nothing needs you -- would be worth nothing. Blanks are listed
inside the section, where a reader who opens it sees them.
"""
bits = []
if entries:
bits.append('{0} note{1} nothing could place'.format(
len(entries), '' if len(entries) == 1 else 's'))
if problems:
bits.append('{0} validation problem{1}'.format(
len(problems), '' if len(problems) == 1 else 's'))
if not bits:
return None
return ' and '.join(bits) + ' -- read them before this spec is trusted'
def summary_line(entries, blanks):
"""The one-line summary a folded section carries on its header."""
return '{0} unresolved note{1}, {2} field{3} blank'.format(
len(entries or []), '' if len(entries or []) == 1 else 's',
len(blanks or []), '' if len(blanks or []) == 1 else 's')