52d94303bc
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.
255 lines
9.7 KiB
Python
255 lines
9.7 KiB
Python
# -*- 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')
|