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
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""Shared library for the WillTools extension.
Anything in `<extension>/lib` is importable from every button script and from
`/vibe/exec` snippets:
from vibe import q, p, tx, out
"""
from vibe.helpers import ( # noqa: F401
doc, uidoc, uiapp,
q, all_of, types_of, by_name, selected, pick,
p, set_p, p_display,
tx, txg,
ft, mm, m, sqft_to_sqm,
out, table, log,
)
__all__ = [
'doc', 'uidoc', 'uiapp',
'q', 'all_of', 'types_of', 'by_name', 'selected', 'pick',
'p', 'set_p', 'p_display',
'tx', 'txg',
'ft', 'mm', 'm', 'sqft_to_sqm',
'out', 'table', 'log',
]
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""Run a batch of job files on a background thread without ever throwing.
WHY THIS MODULE IS THE ONLY PLACE THIS CODE MAY LIVE.
A pyRevit button command can return while a thread it started is still
running, and pyRevit then tears down the command script's module scope.
A name looked up from that scope -- a module-level import such as `io`,
`os` or `liftexport` -- is gone. The lookup misses, and the miss happens
on a NON-MAIN thread, and Revit has no way to catch an exception on a
non-main thread: an unhandled one ends the whole process (measured
2026-08-27: UnboundNameException in a Panel Lift Check batch thread,
~7 s after the command returned; the event log named IronPython
lookup-of-global as the killer).
A thread cannot be made safe by wrapping each call -- the miss happens at
name lookup, before any body runs. It is only safe with NO global name
lookup at all. Everything this worker needs arrives as a default
parameter, bound at definition time (when the scope it was defined in is
still alive), and the whole body is wrapped so nothing can escape. A lost
log line is acceptable; a dead Revit is not.
ONE PRINCIPLE, stated because it was learned the hard way: a function
whose contract is "never raises" must keep EVERY statement inside the
protection -- including the first one. `list(compute)` can raise before
any try; so can unpacking a row; so can `os.path.splitext`. The outer
try/except below exists for exactly those, and the tests feed the worker
malformed inputs to prove it stays silent.
The free functions below are what the tests exercise: build the same
callable, inject a job that raises and a status path that cannot be
written, and assert it still does not raise. IronPython 2.7 is assumed:
no f-strings, no pathlib.
MANY AT ONCE, AND WHY THAT IS SAFE HERE.
Each job is already an isolated CPython subprocess that reads one job
file and writes its own report, log and summary beside it. Nothing is
shared between two of them -- not a file, not a number, not a random
seed -- so running eight at once is a scheduling change and not a
numerical one: the reports come out identical, only sooner. Measured
2026-08-30 on the 184-piece reference sweep: 42 minutes serial, 8.6
minutes on eight workers.
What running many at once DOES touch is coordination, and coordination
is exactly the thing this module is paranoid about. So the extra
machinery obeys the same rule as everything else here: the queue, the
lock and the thread factory are all created and bound in `make_worker`,
on the UI thread, while the defining scope is alive; the functions the
threads run are defined there too, with every name they need bound as a
default parameter. A worker thread looks nothing up. `pump` carries its
own outer try, because an exception escaping THERE is the same dead
Revit as one escaping `worker` -- there are now N threads that can kill
the process instead of one.
"""
# Most jobs a batch will compute at the same time, whatever the machine has.
# Eight subprocesses is already more than the sweep needs to fit inside a
# coffee break, and the number exists to stop a 64-core workstation from
# starting 62 of them.
MAX_WORKERS = 8
# Cores left for everything else. Revit is open, the user is still working in
# it, and a batch that makes the model unusable is a batch nobody starts.
SPARE_CORES = 2
def worker_count(os=None, cap=None, spare=None):
"""How many jobs to compute at once: min(cores - 2, 8), and never 0.
Called on the UI thread, before any thread exists, and the answer is then
bound as a default parameter -- so a background thread never asks this
question and never touches `os.environ`.
NUMBER_OF_PROCESSORS is read before `os.cpu_count`, because it is the one
that exists on IronPython 2.7 (Windows sets it for every process) and
`cpu_count` does not arrive until CPython 3.4. A machine that answers
neither gets 1, which is exactly today's behaviour.
"""
import os as _os
os = os or _os
cap = MAX_WORKERS if cap is None else cap
spare = SPARE_CORES if spare is None else spare
cores = 0
try:
cores = int(os.environ.get('NUMBER_OF_PROCESSORS') or 0)
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
try:
cores = int(os.cpu_count() or 0) # CPython 3.4+ only
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
return 1
count = cores - spare
if count < 1:
count = 1
if count > cap:
count = cap
return count
def make_worker(compute, status_path, run_job, io=None, os=None,
workers=None, threading=None):
"""A zero-argument callable that runs a batch and never raises.
compute -- list of [name, job_path] rows, as the batch engine in
Panel Lift Check builds them. The list is copied, so the
caller may reuse its own after handing it over.
status_path -- where progress lines are appended.
run_job -- callable(name, job_path, out_path) that computes one
report. It may raise; the worker records an ERROR line
and continues with the next job.
workers -- how many jobs to compute at once. None asks the machine
(see `worker_count`). 1 is the old behaviour exactly:
one job after another, on the calling thread, no extra
thread created at all.
io, os,
threading -- injected for tests (a fake io whose open raises, say).
Defaults to the real modules, bound NOW -- at def time,
while this module's scope is alive, which is the whole
point: a background thread must never look anything up
from a scope that may have been torn down.
"""
import io as _io
import os as _os
import threading as _threading
io = io or _io
os = os or _os
threading = threading or _threading
if workers is None:
workers = worker_count(os)
# One lock for the queue AND the status file. They are never held at the
# same time -- take() releases before run_job starts, note() takes it
# after -- so one lock cannot deadlock against itself, and two locks
# would be two things to reason about for no gain.
try:
lock = threading.Lock()
except Exception: # pylint: disable=broad-except
lock = None
workers = 1 # no lock, no sharing: run it the old way
def _grab(lock=lock):
try:
if lock is not None:
lock.acquire()
except Exception: # pylint: disable=broad-except
pass
def _drop(lock=lock):
try:
if lock is not None:
lock.release()
except Exception: # pylint: disable=broad-except
pass
def note(line, io=io, status_path=status_path, mode='a',
grab=_grab, drop=_drop):
"""One line into the status file. Never raises, ever.
acquire, write and release are three separate guards on purpose: a
release() on a lock that was never acquired raises, so it cannot sit
in a `finally` that runs after a failed acquire.
"""
grab()
try:
with io.open(status_path, mode, encoding='utf-8') as fh:
fh.write(line + '\n')
except Exception: # pylint: disable=broad-except
pass # cannot write the log -> stay silent
drop()
def take(queue, grab=_grab, drop=_drop):
"""The next [name, job_path], or None when the batch is empty."""
grab()
row = None
try:
if queue:
row = queue.pop(0)
except Exception: # pylint: disable=broad-except
row = None
drop()
return row
def pump(queue, take=take, note=note, run_job=run_job, os=os):
"""Drain the shared queue. Runs ON a worker thread, so it never
raises: an exception here ends the process the same way one in
`worker` does, and there are now several of these."""
try:
while True:
row = take(queue)
if row is None:
return
try:
name, job_path = row
out_path = os.path.splitext(job_path)[0] + '.html'
run_job(name, job_path, out_path)
line = 'DONE {0}'.format(name)
except Exception as exc: # pylint: disable=broad-except
# A row that would not even unpack still gets a line.
# Dropping it -- which the serial version did, by letting
# the outer guard swallow the whole rest of the batch --
# is a piece that silently never ran.
try:
line = 'ERROR {0}: {1}'.format(row[0], exc)
except Exception: # pylint: disable=broad-except
line = 'ERROR (unreadable row): {0}'.format(exc)
note(line)
except Exception: # pylint: disable=broad-except
pass
def worker(compute=compute, note=note, pump=pump, threading=threading,
workers=workers):
try:
queue = list(compute)
# The count is on the header line because a batch that is running
# eight at a time and one that is running one look identical from
# outside, and they are 42 minutes apart.
note('BATCH RUNNING ({0} panels), {1} at a time'
.format(len(queue), workers), mode='w')
helpers = []
# A counted `while` rather than `range(...)`: one fewer name this
# thread has to resolve through a scope that may be gone. The
# calling thread is worker number one, so it starts workers - 1.
started = 1
while started < workers:
started += 1
try:
thread = threading.Thread(target=pump, args=(queue,))
try:
thread.daemon = False
except Exception: # pylint: disable=broad-except
pass
thread.start()
helpers.append(thread)
except Exception: # pylint: disable=broad-except
# Could not start another thread. The ones already going
# plus this one still finish the batch, just slower.
break
pump(queue) # this thread pulls its share too
for thread in helpers:
try:
thread.join()
except Exception: # pylint: disable=broad-except
pass
note('BATCH FINISHED')
except Exception: # pylint: disable=broad-except
# "Never raises" includes the first statement. list(compute) can
# raise before any of the inner guards run; this one catches it.
pass
return worker
+415
View File
@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
# sample_job. The iron rule is: read-only inside live, never write.
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
The rule this enforces is the owner's, and it was a memory until now: the
LIVE model is his, and nothing here may read it, run in it, or touch it. The
sandbox -- a timestamped detached copy, opened in a Revit session of its own
-- is the only session an agent is allowed to drive.
WHY IT HAS TO BE MECHANICAL
----------------------------
pyRevit's routes server picks its port by walking UP from the configured one
past every port a running Revit has already taken
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
session I meant"; it means "whichever Revit started first". Restart the
sandbox while the live session is up and the live session is now on 48884 and
answering. Every request an agent then sends -- `status`, `exec`, a whole
sweep -- lands in the model it must never touch, and every one of them looks
completely normal.
Nothing about that is detectable from the client side: a `status` that says
`ok` and names the right title is exactly what a correct sandbox says (the
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
note). So the refusal has to be made where the document actually is.
WHY A TITLE ALONE CANNOT DECIDE IT
-----------------------------------
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
leaves the model's own name -- and therefore `doc.Title` -- constant, because
the job folder is keyed on the title and a title that moves daily moves the
job folder with it. The consequence lands here: the sandbox copy's title is
IDENTICAL to the live model's. A denylist of titles would refuse both, which
is a bridge that refuses everything.
So the title is the ACCUSATION and the path is the ALIBI. A document whose
title is on the denylist is refused unless it can prove it is a copy:
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
plus anything in `bridge.allow_roots`), or
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
Revit process's own environment -- which only the pipeline that
launched the sandbox can have set.
The live cloud model has no local `PathName` at all (it is
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
so it can produce neither alibi and is refused. That is the intended outcome
and it is not an accident of string matching.
WHAT IS NOT HERE, DELIBERATELY
-------------------------------
No file reading, no `knowledge.load`, no environment lookups except the two
helpers at the bottom that are handed their own input. Everything is a pure
function of arguments so `startup.py` can be tested at the function level
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
around by parsing source text, which is a worse test than this one.
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
------------------------------------------------------------
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
cloud model outright. Hours later the owner reversed it, in his own words:
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
rule is: read-only inside live, never write." -- the
bridge may run on the live model now; the iron rule is it may never write to
it. `bridge.readonly_titles` is that rule. A title on it answers every read
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
transaction or arbitrary code, so they are reads by construction and never
consult this at all) and `/exec` -- the only route that can write -- refuses
when the request carries a transaction, or when the snippet text matches
`WRITE_PATTERNS` below.
`refuse_titles` and `readonly_titles` are NOT layered. A title on
`refuse_titles` is refused before anything here is reached; a title only on
`readonly_titles` is never refused outright, only stopped from writing.
THIS IS A GUARD RAIL, NOT A SANDBOX
-------------------------------------
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
not a permissions model. It catches the ordinary way `/exec` writes -- an
explicit transaction, or a snippet that calls a mutating Revit API method by
its literal name -- because that is what an ordinary agent request looks
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
setter (Revit exposes several that mutate without any method call at all),
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
code that reaches a write through a helper this list has not been told
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
A determined snippet gets through. This module only stops the request an
agent following instructions would send.
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
pathlib, `os.path` throughout.
"""
# pylint: disable=broad-except,invalid-name
import os
import re
from vibe.snapshotmeta import MODEL_EXT
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
# was launched on in its own environment. A session started from the taskbar
# does not, and cannot be made to look as though it did without somebody
# deciding to.
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
# The denylist, semicolon-separated, for the case where the knowledge layers
# cannot be read. It is a way to STATE the answer, not a way to switch the
# guard off -- there is deliberately no off switch, because an off switch is
# the hole this module exists to close.
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
# for a machine whose knowledge layers will not load. Stating the answer
# here does not widen what the bridge may do -- read-only is already the
# more permissive of the two lists.
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
def normalize_title(text):
"""A title as it is compared: no model extension, trimmed, case-folded.
`doc.Title` is sometimes reported with the extension and sometimes
without, depending on version and on whether the document was opened from
a path or from the cloud, so both have to compare equal or the denylist
works on one machine and not the next.
"""
try:
text = '{0}'.format(text or '').strip()
except Exception:
return ''
if text.lower().endswith(MODEL_EXT):
text = text[:-len(MODEL_EXT)]
return text.strip().lower()
def split_titles(raw):
"""A `;`-separated (or list) denylist into a clean list of strings.
Accepts what a YAML layer produces (a list), what an environment variable
produces (one string), and a single string in the YAML -- the last is a
predictable mis-edit and reading it as a one-item list beats reading it as
a list of characters.
"""
if raw is None:
return []
if isinstance(raw, (list, tuple)):
items = list(raw)
else:
items = '{0}'.format(raw).split(';')
out = []
for item in items:
try:
text = '{0}'.format(item).strip()
except Exception:
continue
if text:
out.append(text)
return out
def _norm_path(path):
try:
text = '{0}'.format(path or '').strip()
except Exception:
return ''
if not text:
return ''
text = text.replace('/', os.sep).replace('\\', os.sep)
try:
text = os.path.normcase(os.path.normpath(text))
except Exception:
pass
return text.rstrip(os.sep)
def is_under(path, root):
"""True when `path` sits inside `root`. Case- and separator-insensitive.
A prefix test on its own says `C:\\snapshots-old\\x` is under
`C:\\snapshots`, so the separator is required after the root.
"""
path = _norm_path(path)
root = _norm_path(root)
if not path or not root:
return False
return path == root or path.startswith(root + os.sep)
def same_model(path_a, path_b):
"""True when two paths name the same model file, as strings."""
a, b = _norm_path(path_a), _norm_path(path_b)
return bool(a) and a == b
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
"""True when `title` is on `listed` and this document cannot prove it is
a sandbox copy. Shared by `refusal()` (refuse_titles) and
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
No document open (`title` falsy after normalising) is never "on the
list": there is nothing to protect and nothing to touch.
"""
banned = [normalize_title(t) for t in split_titles(listed)]
banned = [t for t in banned if t]
if not banned:
return False
here = normalize_title(title)
if not here or here not in banned:
return False
# The title is on the list. Can this document prove it is a copy?
if sandbox_model and same_model(path, sandbox_model):
return False
for root in (sandbox_roots or ()):
if root and is_under(path, root):
return False
return True
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
config_error=None):
"""None when the bridge may serve this document; one sentence when not.
`config_error` is how a caller says "I could not read the denylist". That
is NOT the same answer as "the denylist is empty", and it is refused --
a bridge that cannot prove it is not in the live model has not proved
anything. The refusal names the two ways to answer it, so the fix is in
the message rather than in somebody's memory.
"""
if config_error:
return ('REFUSED: this bridge cannot tell whether the open document '
'is the live model -- the refuse-titles configuration could '
'not be read ({0}). Fix the job/plant knowledge layer, or '
'state the answer in {1} (semicolon separated), then restart '
'Revit. Nothing was read or run.'
.format(config_error, ENV_TITLES))
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
sandbox_model):
return None
return ('REFUSED: the open document is "{0}", a title on '
'bridge.refuse_titles -- the live model this bridge must never '
'touch. Nothing was read, run or changed. Its file is {1}, which '
'is under no configured snapshot root and is not {2}. If this '
'really is a detached snapshot, open it from under '
'snapshot.detached_dir, or launch its Revit with {2} set to its '
'path.'
.format(title, path or '(no local file -- a cloud model)',
ENV_SANDBOX))
# --------------------------------------------------------------- read-only
#
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
# Grounded in what this codebase's own write idioms actually look like:
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
# needs a Transaction" and "never reload" sections name the rest. Matched
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
WRITE_PATTERNS = [
('Transaction(', re.compile(r'\bTransaction\s*\(')),
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
('doc.Save(', re.compile(r'\.Save\s*\(')),
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
('Purge', re.compile(r'\bPurge\w*\s*\(')),
('.Rename(', re.compile(r'\.Rename\s*\(')),
('WorksharingUtils / SynchronizeWithCentral',
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
]
def write_signature(code):
"""The name of the first write-capable pattern found in `code`, or None.
A plain regex scan over the snippet text -- not a parser, and not a
defence against an obfuscated call. See the module docstring.
"""
text = '{0}'.format(code or '')
for name, pattern in WRITE_PATTERNS:
if pattern.search(text):
return name
return None
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
sandbox_model=None, tx_name=None, code=None):
"""None when `/exec` may run this request; one sentence when not.
Only reached once `refusal()` above has already let the request through
-- a title on `bridge.refuse_titles` is refused outright and this is
never consulted. `readonly_titles` is the softer rule: the document may
still be read, but nothing that could write to it may run.
"""
if not _on_list_and_not_exempt(title, path, readonly_titles,
sandbox_roots, sandbox_model):
return None
if tx_name:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This request asked to run '
'inside a transaction ("{1}"), so it was not run. Nothing '
'was changed.'.format(title, tx_name))
hit = write_signature(code)
if hit:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This snippet contains '
'{1}, which can write to the model, so it was not run. '
'Nothing was changed.'.format(title, hit))
return None
def payload(title, reason):
"""The refusal as a routes response body.
`ok: False` so every existing client already treats it as a failure, plus
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
grabbed the port" apart from "the sandbox is not up yet" -- two failures
that look identical from the outside and need opposite responses.
The title is reported even though the request is refused: "which model is
open" is the one question a refusal still has to answer, or nobody can
work out which session they reached.
"""
return {'ok': False, 'refused': True, 'doc_title': title,
'error': reason}
def config_from(know, getter, environ=None):
"""(titles, roots) out of the knowledge tree and the environment.
`getter` is `knowledge.get` passed in rather than imported, so this module
keeps its promise of importing nothing that reads a file. The environment
is a UNION with the configured values, never a replacement: a denylist you
can shrink by unsetting a variable is not a denylist.
"""
environ = os.environ if environ is None else environ
titles = []
roots = []
try:
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_TITLES)))
except Exception:
pass
try:
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
detached = getter(know, 'snapshot.detached_dir', None)
if detached:
roots.append(detached)
except Exception:
pass
try:
roots.extend(split_titles(environ.get(ENV_ROOTS)))
except Exception:
pass
seen = set()
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
seen = set()
roots = [r for r in roots if not (_norm_path(r) in seen or
seen.add(_norm_path(r)))]
return titles, roots
def readonly_titles_from(know, getter, environ=None):
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
as its own function, returning one list rather than folding a third value
into `config_from`'s tuple, so every existing caller of `config_from`
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
roots are shared with `config_from` -- a copy is a copy whichever list is
being checked against -- so this does not read `bridge.allow_roots` or
`snapshot.detached_dir` again; callers pass the roots `config_from`
already returned.
"""
environ = os.environ if environ is None else environ
titles = []
try:
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
except Exception:
pass
seen = set()
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
+31
View File
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""The small Python 2 / Python 3 bridge.
We do not choose the interpreter -- pyRevit does, from the engine configured
for the host. This extension was written on Revit 2025 with IronPython 2.7,
but the same folder may be dropped on a machine whose pyRevit is set to
IronPython 3 or CPython 3, where `unicode` and `basestring` are a NameError.
A NameError at module scope takes the whole button down with a traceback the
user cannot act on, so the two names are resolved once, here.
Everything else in this extension is already written in the intersection of
the two languages: no f-strings, no print statement, no implicit integer
division, no pathlib. Keep it that way and the engine stops mattering.
"""
try: # Python 2 (IronPython 2.7)
text_type = unicode # noqa: F821
string_types = (str, unicode) # noqa: F821
except NameError: # Python 3 (IronPython 3, CPython 3)
text_type = str
string_types = (str,)
def as_text(value, encoding='utf-8'):
"""Return value as the interpreter's unicode text type."""
if isinstance(value, text_type):
return value
if isinstance(value, bytes):
return value.decode(encoding)
return text_type(value)
+768
View File
@@ -0,0 +1,768 @@
# -*- coding: utf-8 -*-
"""The whole configuration of one project, written to one file.
Knowledge arrives in five layers -- built-in defaults, the catalogue, the
plant, the job, and the project config file -- and every one of them but the
defaults lives somewhere nobody browses to: the tool's own install, APPDATA,
a folder beside the model, and wherever the engineer chose to put the config
file. That is the right way to store it and the wrong way to hand it to
somebody. A reviewer asking "what was this checked against?" should get one
file, not a tour of three machines.
So this flattens the layers into a single document that says, for every
setting, what the value is and which layer it came from. `layer_values()` is
where the five are enumerated, and it is also where the one subtlety lives:
the project config layer is left out of the provenance by default, for the
reason set out in that function's own docstring. The knowledge section
is written in the same shape a knowledge.yaml has, which means the file is not
only a record: save it as the job's knowledge file and it reproduces exactly
the configuration it was taken from.
Written for IronPython 2.7: no f-strings, no pathlib, no OrderedDict literals.
"""
# pylint: disable=import-error,invalid-name,broad-except
import datetime
import os
import re
from vibe import knowledge
from vibe.compat import string_types, text_type
# The order sections come out in -- the order somebody setting a project up
# would work through them, which is not alphabetical. Anything not listed
# follows, sorted, so a new knowledge key never silently vanishes from a dump
# written by an older build.
SECTION_ORDER = (
'nomenclature', 'parameters', 'families', 'orientation', 'rails',
'legend', 'sheet', 'concrete', 'lifters', 'handling', 'interference',
'catalogue',
)
SECTION_NOTES = {
'nomenclature': 'How a design number is written, and what reduces to what',
'orientation': 'Which end of a piece is the top once it is erected',
'rails': 'The casting bed: which rail is fixed, how wide the bed is',
'parameters': "This office's Revit parameter name for each role the "
'tool reads',
'families': 'Which family names count as strand, mesh, or a lifting insert',
'lifters': 'Part number -> catalogue designation, and the checks on top',
'handling': ('Lifting and handling: stages, insert roles, where reports '
'go. rig_mode and dunnage are decided in the decisions '
'section at the top; a stage with no rig_mode here inherits '
'the default.'),
'interference': 'The clash check between members of one assembly',
'catalogue': 'Published hardware ratings. Not job data -- ships with the '
'tool',
'legend': 'The strand-pattern legend a ticket has to carry',
'sheet': 'What is compared against the printed ticket',
'concrete': 'Release strength rules',
}
def sections(know):
"""Knowledge section names, in reading order, nothing dropped.
Except the underscore keys. `_config_missing` and `_config_problems` are
notes load() leaves for config_problems ABOUT this run -- not settings --
and writing them into the file would make a transient complaint look like
a permanent setting, and hand it back to the next merge as one.
"""
listed = [key for key in SECTION_ORDER if key in know]
rest = sorted(key for key in know
if key not in SECTION_ORDER and not str(key).startswith('_'))
return listed + rest
# ------------------------------------------------------------ a YAML writer
_PLAIN = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_ ./+-]*$')
_RESERVED = ('true', 'false', 'null', 'yes', 'no', 'on', 'off', '~')
def _scalar(value):
if value is None:
return 'null'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, float):
text = repr(value)
return text[:-2] if text.endswith('.0') else text
if not isinstance(value, string_types):
return str(value)
text = text_type(value)
if not text:
return "''"
if text.lower() in _RESERVED or not _PLAIN.match(text) \
or text != text.strip() or text.endswith(':'):
return "'" + text.replace("'", "''") + "'"
# A bare number-looking string has to be quoted or it reads back as a
# number, and a part number like 4.10 would come back as 4.1.
if re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', text):
return "'" + text + "'"
return text
def _is_flat(node):
"""A list of plain scalars, short enough to sit on one line."""
if not isinstance(node, list) or not node:
return False
if any(isinstance(v, (dict, list)) for v in node):
return False
return sum(len(_scalar(v)) + 2 for v in node) <= 66
def emit(node, indent=0):
"""A dict/list/scalar tree as YAML lines. Deterministic key order."""
pad = ' ' * indent
out = []
if isinstance(node, dict):
if not node:
return [pad + '{}']
for key in sorted(node):
value = node[key]
label = pad + _scalar(key) + ':'
if isinstance(value, dict) and value:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, list) and value:
if _is_flat(value):
out.append('{0} [{1}]'.format(
label, ', '.join(_scalar(v) for v in value)))
else:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, (dict, list)):
out.append('{0} {1}'.format(label,
'{}' if isinstance(value, dict)
else '[]'))
else:
out.append('{0} {1}'.format(label, _scalar(value)))
return out
if isinstance(node, list):
for item in node:
if isinstance(item, dict) and item:
lines = emit(item, indent + 1)
# The first key rides on the dash, the rest line up under it.
out.append(pad + '- ' + lines[0].strip())
out.extend(lines[1:])
elif isinstance(item, list) and item:
lines = emit(item, indent + 1)
out.append(pad + '-')
out.extend(lines)
else:
out.append(pad + '- ' + _scalar(item))
return out
return [pad + _scalar(node)]
# --------------------------------------------------------------- provenance
def _layer_rows(doc, include_config=False):
"""[(label, path, exists, data, unreadable)] -- what layer_values() and
layer_problems() are both built out of.
`unreadable` is `None` for an ordinary layer (absent, or present and read
clean) and the parser's message (a non-empty string) for a layer whose
file IS there and would not parse.
This used to be `layer_values()` itself, calling
`knowledge._merge_file(holder, path)` with NO `layer` argument -- which
means `_note_layer_unreadable` returned immediately (`if not layer: ...`)
and a file that exists and will not parse came back as `data = {}`,
indistinguishable from a file that genuinely sets nothing. The five-layer
merge in `knowledge.load()` was never fooled by this -- it always passes
the token -- so a real run's `_plant_unreadable` marker was sitting right
there in `know`; only THIS dump's own, separate re-read of the files
(needed to say which layer a value came from) was blind to it. Threading
the token through here, and popping the marker back out of `data` before
handing it to a caller, fixes that without changing what a marker-free
`data` dict means to `origins()` / `overridden()` / every existing caller
of `layer_values()`.
"""
from vibe import designspec
layers = [('defaults', None, True,
knowledge._deep_copy(knowledge.DEFAULTS), None)]
# The plant layer is per plant, and which plant is a key in the JOB layer
# -- so it has to be resolved through the same pre-pass load() uses, or
# this dump attributes settings to a file the run never read.
# The token doubles as the label for these three -- 'catalogue', 'plant'
# and 'job' are both what `_FILE_LAYERS` calls the layer and what a
# human-readable line calls it.
for label, path in (('catalogue', knowledge.catalogue_path()),
('plant',
designspec.plant_knowledge_path(
knowledge.plant_id(doc))),
('job', designspec.knowledge_path(doc))):
layer = label
data = {}
unreadable = None
exists = bool(path and os.path.isfile(path))
if exists:
holder = {}
knowledge._merge_file(holder, path, layer)
marker = holder.pop(knowledge._unreadable_key(layer), None)
if marker:
unreadable = marker[1]
data = holder
layers.append((label, path, exists, data, unreadable))
if include_config:
path = designspec.active_config_path(doc)
if path:
data = {}
unreadable = None
exists = os.path.isfile(path)
if exists:
# Through _merge_config_file, not _merge_file: the file's
# `decisions:` block is mapped back onto the keys really read,
# and it beats the tree below it inside the same file. A
# provenance view that read only the tree would name the wrong
# value for the eight settings that have both spellings.
# _merge_config_file already threads 'config' through to
# _merge_file on its own (`notes=merged`), so the marker
# lands on `holder` here the same way it does for the other
# three layers -- just popped back out below instead.
holder = {}
knowledge._merge_config_file(holder, path)
marker = holder.pop(knowledge._unreadable_key('config'), None)
if marker:
unreadable = marker[1]
data = holder
layers.append(('config', path, exists, data, unreadable))
return layers
def layer_values(doc, include_config=False):
"""[(label, path, exists, data)] for every layer, in the order they merge.
Read separately from load() so the dump can say which layer a value came
from. Saying only what a setting ended up as leaves the reader unable to
tell a deliberate plant rule from a default nobody has looked at.
The project config layer is deliberately NOT included by default. This is
what writes it: folding the previous version of the file into the
provenance would stamp every setting as coming from the file about to be
overwritten, and the answer would be true and useless. What a reader wants
is which of the underlying layers each setting really came from.
`include_config=True` adds it, for the one caller that needs the opposite
answer: an editor showing what is in force has to show the layer that is
WINNING, and while a config file is live that is the config file. Leaving
it out there would show somebody a plant value that nothing is reading.
The flag is additive rather than a change of default so the dump's own
provenance keeps meaning what it has always meant.
`data` never carries an `_<layer>_unreadable` marker -- `_layer_rows()`
pops it back out before this wraps the row. A caller that wants to know
whether a layer was unreadable wants `layer_problems()`, not a key it has
to know to go looking for inside `data`.
"""
return [(label, path, exists, data)
for label, path, exists, data, _unreadable
in _layer_rows(doc, include_config)]
def layer_problems(doc, include_config=False):
"""[(label, path, message)] for every layer whose file is on this machine
and would not parse -- in merge order, empty in the ordinary case.
This is the dump's own version of `knowledge.unreadable_layers()`: same
question, asked against the fresh per-file read `layer_values()` does
rather than against an already-merged `know` tree, because the dump
reads the layers itself (see `layer_values()`'s docstring) and has to be
right on its own -- a caller here may not have a merged tree with the
markers already on it.
"""
return [(label, path, unreadable)
for label, path, _exists, _data, unreadable
in _layer_rows(doc, include_config)
if unreadable]
def _paths(node, prefix=''):
"""Every leaf in a tree as a dotted path. Lists count as one leaf."""
found = []
if isinstance(node, dict):
for key in node:
found.extend(_paths(node[key],
prefix + '.' + str(key) if prefix else str(key)))
elif prefix:
found.append(prefix)
return found
def origins(doc):
"""{dotted key: the label of the LAST layer that set it}."""
out = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
out[path] = label
return out
def overridden(doc):
"""[(key, [(layer, value), ...])] for settings a later layer CHANGES.
The interesting rows: where a plant rule beats a default, or a job beats
its plant. A layer that restates a value it agrees with is not an
override, and listing it would bury the handful of rows that matter under
a hundred that do not.
"""
seen = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
seen.setdefault(path, []).append(
(label, knowledge.get(data, path)))
rows = []
for path in sorted(seen):
chain = seen[path]
values = [value for _label, value in chain]
if len(chain) > 1 and any(v != values[0] for v in values[1:]):
rows.append((path, chain))
return rows
# ------------------------------------------------------------ the document
def project_info(doc, know):
"""What piece of work this configuration belongs to."""
from vibe import designspec, ticketqc
info = {
'model': doc.Title,
'model_path': doc.PathName or '(unsaved)',
'qc_data_dir': designspec.root_dir(doc),
'written': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
}
try:
grouped = ticketqc.assemblies_by_design(doc, know)
specs = designspec.list_specs(doc) if doc.PathName else {}
designs = []
for key in sorted(grouped):
designs.append({
'design': key,
'pieces': len(grouped[key]),
'extracted_spec': key in specs,
})
info['designs'] = designs
info['piece_count'] = sum(len(v) for v in grouped.values())
except Exception as exc:
info['designs'] = []
info['designs_error'] = str(exc)
return info
DECISION_BANNER = u'''# =========================================================================
# YOUR DECISIONS
#
# Everything in this section is yours. None of it can be worked out from the
# model, and the tool will not guess at any of it -- it uses what is written
# here. Edit a value and the next run uses it.
#
# Everything after this section is either a record of how this file was built
# or the merged knowledge it was built from. Worth reading, rarely worth
# editing: anything named down there and also named up here is overruled by
# the copy up here.
# ========================================================================='''
# The comment for each decision, written directly above the value it belongs
# to rather than gathered into one block at the top of the section. A note
# four lines away from the line it describes is a note that gets edited past.
DECISION_NOTES = (
('report_dir', u'''# Where Panel Lift Check writes its HTML. Used exactly as written, so an
# absolute path anywhere works -- a job folder on a share, say. Takes effect
# on the next run.'''),
('open_report_when_done', u'''# Open each report as it is finished. The gap between a report being written
# and being read is one double-click that is easy not to take. Turn it off
# when working through a lot of pieces in a row, or forty browser tabs is the
# result.'''),
('prestress_loss_pct', u'''# Everything lost between the jack and the stage being checked, as one
# percentage. It is NOT calculated: a loss model needs an age, a humidity and
# a curing schedule this tool does not have, and a plausible-looking number
# from guessed inputs is worse than a figure you can argue with.
#
# 8 is conservative. A commercial check of one real piece computed 1.6 at
# release. LOWER IT AND EVERY PIECE GETS MORE CREDIT.'''),
('transfer_length_in', u'''# Where a cut strand has handed its force back to the concrete. Blank uses
# ACI 318's f_se * db / 3, worked out from the pull stated on the design
# rather than assumed.'''),
('insert_position', u'''# How an insert's position is measured, per run mode.
#
# VALID VALUES -- write exactly one of these:
# fast the element's cached bounding box centre. No geometry solve.
# precise the solved solid centroid.
#
# They agree on a symmetric embed and disagree by a few inches on an L-shaped
# one -- and a face lifter's position IS the moment arm it hangs on. On-edge
# anchors are snapped to the edge either way. 'precise' is roughly an order of
# magnitude slower.
#
# The two modes are set apart on purpose. A batch runs unattended, so the time
# costs nobody anything and it should be right. One piece at a time is
# somebody waiting at the screen -- and a piece close enough for a few inches
# to decide it is a piece to put through Eriksson, not to squeeze a sharper
# number out of here.
#
# one_piece Panel Lift Check on a single selected assembly
# batch Panel Lift Check on several, the sweep, and the run Ticket QC
# starts for itself'''),
('rig_mode', u'''# How the piece hangs. This changes the mechanics, not just a coefficient.
#
# VALID VALUES -- write exactly one of these:
# rolling equalising blocks / roller sheaves. Every line in a cable group
# carries the same force; two groups, statically determinate.
# static fixed-length slings / static lift. A continuous beam on rigid
# supports; the reactions follow stiffness, so a blockout beside a
# support really does change them.
#
# NOT VALID: static_lift, static_lifting, fixed, 1, yes. A misspelled value is
# REFUSED, not silently inherited -- a typo that looked like "inherit" is
# exactly the kind of quiet wrongness this section exists to prevent.
#
# "default" applies to every stage. A stage named below overrides it for that
# stage only; put null / leave it blank there to inherit the default.
#
# Example -- strip with static slings, erect with equalising blocks:
# default: rolling
# stripping: static
# transportation: null
# erection: rolling
#
# Worth settling before anything else is tuned. A commercial check of one real
# piece stripped on fixed slings and erected on an equalising block, and its
# stripping moment came out 14.1 k-ft where one rolling mode everywhere gives
# 23.8. Which is right depends on how YOUR yard rigs -- nothing here guesses
# it.'''),
('group_tolerance_ft', u'''# How far apart ALONG THE PIECE two inserts may sit and still count as one
# pick row.
#
# The beam solve hangs each ROW from one rigid support line -- but the job
# names individual inserts and nothing names rows, so this tolerance is what
# decides who belongs together. Top view, x running along the piece's length:
#
# x=14.92 x=15.92
# | |
# edge o--+ | the edge anchor and face insert B are flush;
# B o--+ | C belongs to the SAME row but sits 1.00 ft
# | C o---+ further back along the piece.
#
# gap <= group_tolerance_ft -> A, B, C hang as ONE row, one support line.
# gap > group_tolerance_ft -> C becomes an extra "row" of its own. The
# solve then balances on a support that does
# not exist: one line goes NEGATIVE (pulled
# down) while its neighbour overloads -- on
# a real panel this read -936 lb on one
# insert and 116% of rating on the next.
#
# SET IT just above the largest stagger your yard actually leaves within a
# row. Too large merges two rows that were genuinely separate -- the two
# failure modes point opposite ways, so this number is yours to set, not a
# default to trust.'''),
('dunnage', u'''# Where the piece sits on the trailer during transportation.
#
# VALID VALUES for mode:
# null old lift approximation (transportation is still checked as a
# pick; the report labels it approximate)
# lifter_rows dunnage under the same lines as the stripping rows
# fraction two dunnage lines inset fraction*L from each end
#
# fraction is only used when mode is fraction, e.g. 0.2 means supports at
# 0.2L and 0.8L. This is plant knowledge; it is lifted up here so you can see
# and edit it without collapsing the whole knowledge tree.'''),
)
def report_dir(doc, know):
"""Where handling reports go: what was set, or beside the model."""
from vibe import designspec
return (knowledge.get(know, 'handling.report_dir') or
os.path.join(designspec.root_dir(doc), 'handling'))
def decisions(doc, know):
"""Everything in this file that is the engineer's to decide, in one place.
Every one of these lives three or four levels down the merged knowledge
tree, which is where it is read from and a hopeless place to find it.
Each is here because it was asked for by name: where the reports go,
whether they open, the prestress loss, the transfer length, how the
piece hangs at each stage, and how much stagger within a pick row still
counts as one row.
They come FIRST in the file on purpose. A config file is read from the top
and abandoned somewhere in the middle, so the part somebody opened it to
change has to sit above the part they did not.
"""
modes = {'default': knowledge.get(know, 'handling.rig_mode', 'rolling')}
for stage in knowledge.get(know, 'handling.stages', []) or []:
if stage.get('name'):
modes[stage['name']] = stage.get('rig_mode')
fast = knowledge.get(know, 'handling.fast_lifter_box', True)
fast_batch = knowledge.get(know, 'handling.fast_lifter_box_batch', False)
return {
'report_dir': report_dir(doc, know),
'open_report_when_done': knowledge.get(know, 'handling.open_report',
True),
'prestress_loss_pct': knowledge.get(know,
'handling.prestress_loss_pct',
8.0),
'transfer_length_in': knowledge.get(know,
'handling.transfer_length_in'),
'group_tolerance_ft': knowledge.get(know,
'handling.group_tolerance_ft',
1.0),
'dunnage': knowledge.get(know, 'handling.dunnage') or {},
'insert_position': {
'one_piece': 'fast' if fast else 'precise',
'batch': 'fast' if fast_batch else 'precise',
},
'rig_mode': modes,
}
def decisions_block(doc, know):
"""The decisions section as text: every value under its own comment."""
body = decisions(doc, know)
out = [DECISION_BANNER, u'', u'decisions:']
for key, note in DECISION_NOTES:
out.append(u'')
out.append(u'\n'.join(u' ' + line for line in note.split(u'\n')))
out.extend(emit({key: body[key]}, 1))
return out
def output_locations(doc, know):
"""Where each thing this tool produces is written.
Every path here is DERIVED from where the model sits. None of it is a
setting any more -- the one that was, `lifting_check_reports`, moved up
into `decisions` as `report_dir`. Two editable copies of one path is
exactly the trap this file exists not to be.
They are still listed, because "where did the specs go" is a question
people have and printing the answer beats hunting for it.
"""
from vibe import designspec
return {
'extracted_specs': designspec.sub_dir(doc, 'specs'),
'evidence': designspec.sub_dir(doc, 'evidence'),
'logs': designspec.sub_dir(doc, 'log'),
'job_knowledge_file': designspec.knowledge_path(doc),
# Resolved for THIS job's plant, not the machine-wide file -- see
# knowledge.plant_id() and the two-pass note in knowledge.load().
'plant_knowledge_file': designspec.plant_knowledge_path(
knowledge.plant_id(doc)),
}
LOCATION_NOTES = (
u'# All derived from where the model sits. Shown so you do not have to\n'
u'# hunt for them; editing them does nothing. The one path here that IS a\n'
u'# setting is report_dir, at the top of this file.'
)
HEADER = u"""# WillTools project configuration
#
# model {model}
# written {when}
#
# THIS FILE IS LIVE. It is the configuration {model} runs on, and it is the
# last word -- every setting in here beats the same setting in the layered
# files it was built from. Edit it and the next run uses what you wrote.
#
# It got that way because somebody pressed Project Config. A one-line pointer
# beside the model records where this file is:
#
# {pointer}
#
# Delete that pointer, or press Project Config again, and the model goes back
# to the layered files. Nothing else reads this path.
#
# The layers underneath, in the order they override each other. This file sits
# after all of them:
#
{layers}
#
# One consequence worth knowing. The `knowledge:` section below is the MERGED
# result, so it contains the plant's settings as values of its own. While this
# file is live, editing the plant file no longer reaches this model -- the
# copy in here wins. That is the point of pinning a configuration to a job,
# and it is also how a plant-wide fix quietly fails to arrive. When you want
# the plant layer back, clear the pointer.
"""
# Only ever appended when `layer_problems()` is non-empty. An unreadable
# layer and an empty one are OPPOSITE conditions -- one is a 255KB plant file
# silently voided (the 2026-09-01 incident that `knowledge._merge_file`'s
# marker exists for), the other is genuinely nothing -- and this file is
# where a human looks first, so the two must never render the same way.
# `# set by:` in the KNOWLEDGE section below only ever names layers that DID
# contribute a value; a layer that could not be read contributes none, which
# is exactly why it needs a banner of its own instead of relying on that line
# to mention it.
UNREADABLE_LAYER_BANNER = u'''
# =========================================================================
# ONE OR MORE KNOWLEDGE LAYERS COULD NOT BE READ
#
# Each layer named below is a file that IS on this machine, and the YAML
# reader refused it. A half-parsed layer is never merged, so NONE of that
# file was used -- every value it would have set has fallen back to
# whatever the layer underneath it says. A layer that fails to load looks
# EXACTLY like a layer that agrees with the defaults; nothing below this
# banner can tell you that happened, only this can.
#
{rows}
# Fix the file and dump again -- a key stated twice inside one mapping is
# the usual cause, and `python tools/knowledgelint.py` names it with a line
# number. The tool will not guess at what the file said.
# ========================================================================='''
def unreadable_layer_banner(problems):
"""The banner text for `layer_problems()`'s rows, or '' when there are none.
Split out from `build()` so a caller assembling its own view of the same
facts (see `layer_problems()`) does not have to reconstruct this from
scratch -- and so a test can check the wording without going through a
live `doc`.
"""
if not problems:
return u''
rows = u'#\n'.join(
u'# {0} layer -- {1}\n# {2}\n'.format(label, path, message)
for label, path, message in problems)
return UNREADABLE_LAYER_BANNER.format(rows=rows)
def _layer_status_suffix(exists, unreadable):
"""The trailing note on one layer's line in the header's layer listing.
Three facts, three different suffixes -- collapsing any two of them is
exactly the bug this exists to prevent: 'read clean' (''), 'not present'
(the file is not there, ordinary and quiet) and 'unreadable' (the file
IS there and the YAML reader refused it, which is the loud one and must
never look like either of the other two, and least of all like 'read
clean and simply set nothing').
"""
if unreadable:
return u' !! UNREADABLE !!'
if not exists:
return u' -- not present'
return u''
def build(doc, know):
"""The whole document, as text."""
from vibe import designspec
rows = _layer_rows(doc)
layer_lines = [
u'# {0:<10} {1}{2}'.format(
label, path or '(built into the tool)',
_layer_status_suffix(exists, unreadable))
for label, path, exists, _data, unreadable in rows]
body = {
'decisions': decisions(doc, know),
'project': project_info(doc, know),
'output_locations': output_locations(doc, know),
'knowledge': know,
}
text = [HEADER.format(when=datetime.datetime.now()
.strftime('%Y-%m-%d %H:%M'),
model=doc.Title,
pointer=designspec.config_pointer_path(doc),
layers=u'\n'.join(layer_lines))]
problems = [(label, path, message)
for label, path, _exists, _data, message in rows
if message]
banner = unreadable_layer_banner(problems)
if banner:
text.append(banner)
# Above everything else, because a config file is read from the top and
# abandoned in the middle.
text.append(u'')
text.append(u'\n'.join(decisions_block(doc, know)))
text.append(u'\n# What this configuration belongs to. Read for the record,'
u' not by the tool.')
text.append(u'project:')
text.append(u'\n'.join(emit(body['project'], 1)))
text.append(u'\n# Where output goes.\n#\n' + LOCATION_NOTES)
text.append(u'output_locations:')
text.append(u'\n'.join(emit(body['output_locations'], 1)))
rows = overridden(doc)
if rows:
text.append(u'\n# ---- settings more than one layer touches ----------'
u'--------------------\n#\n'
u'# Where a plant rule beats a default, or a job beats its '
u'plant. The last\n# entry on each line is the one in '
u'force.\n#')
for path, chain in rows:
text.append(u'# {0}\n# {1}'.format(
path, u' -> '.join(
u'{0}: {1}'.format(label, _scalar(value))
for label, value in chain)))
text.append(u'\n# =================================================================')
text.append(u'# KNOWLEDGE -- everything below this line is the merged configuration.')
text.append(u'#')
text.append(u'# This whole section is one `knowledge:` block, so most editors let you')
text.append(u'# collapse it (click the triangle / fold marker next to `knowledge:`).')
text.append(u'# You almost never need to edit anything here. The values the engineer')
text.append(u'# actually decides are in the `decisions:` section at the top of this')
text.append(u'# file; anything also named there is overruled by the copy up there.')
text.append(u'#')
text.append(u'# The "set by" comments say which underlying layer each value came from,')
text.append(u'# so you can tell a deliberate plant rule from a default nobody has')
text.append(u'# looked at.')
text.append(u'\nknowledge:')
text.append(_knowledge_body(doc, know, indent=1))
return u'\n'.join(text) + u'\n'
def _knowledge_body(doc, know, indent):
"""The knowledge tree, section by section, each one labelled and sourced.
Emitting the whole tree in one go would be shorter and much harder to
read: a merged configuration is several hundred lines and every one of
them looks equally important until something says otherwise.
"""
where = origins(doc)
pad = ' ' * indent
out = []
for name in sections(know):
note = SECTION_NOTES.get(name)
layers = sorted(set(
where[path] for path in _paths({name: know[name]})
if path in where))
out.append(u'')
if note:
out.append(u'{0}# {1}'.format(pad, note))
if layers:
out.append(u'{0}# set by: {1}'.format(pad, ', '.join(layers)))
out.extend(emit({name: know[name]}, indent))
return u'\n'.join(out)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
# -*- coding: utf-8 -*-
"""The FULL VIEW of the configuration: everything, and where each thing is set.
The Project Config button is a window of four boxes of controls
(`vibe.configform`): the settings a person actually changes between one press
and the next, and nothing else. This is the other half -- the page behind its
"Full view" button. Every setting the job layer may carry, every layer file,
every read-only value with the sentence saying why it is read-only, and the
provenance chain of anything more than one file had an opinion about.
The two are deliberately not two answers to the same question. The window says
WHAT IS SET AND LETS YOU CHANGE IT; this says WHAT EXISTS AND WHERE IT IS
CHANGED, and the `change it in` column is the join: it names the box that
carries each setting, or says the file. That column comes from
`configform.placement()`, so the mapping is written once.
WHAT THIS FIXED, AND WHY IT IS STILL A PAGE
--------------------------------------------
The button used to be a `CommandSwitchWindow` menu, and it had three defects
in one press:
1. THE SENTENCE THAT EXPLAINS THE SCREEN WAS INSIDE THE SCREEN'S CHROME.
`CommandSwitchWindow`'s `message` is a WPF `Label` docked left in a
36-pixel row it shares with the search box, in a window fixed at 600 wide
with `ResizeMode="NoResize"`. A Label does not wrap, so the explanation
was cut off. Prose belongs where there is room for it: on this page, or in
the real window's wrapping header.
2. THE PAGE WAS PRINTED IN THE MIDDLE OF A DIALOG LOOP, so a modal popup came
straight back over it and dismissing that popup ended the run. Nothing is
printed now until every window is shut.
3. THE OPTIONS WERE NAMED BY THEIR DOTTED KEYS. Every row in
`configmodel.SETTINGS` already carries a `label` ("Report folder") and a
`section` ("Output"); the screen showed `handling.report_dir` instead.
Everything user-facing here is label first. The dotted key still appears
-- it is what somebody grepping the YAML needs -- but as reference small
print in its own column, never as the name of the thing.
THE SHAPE, WHICH IS THE TAB'S SHAPE
------------------------------------
`vibe.foldreport`: the answer is the headline and is never folded; everything
else starts closed; a section carrying a problem opens itself and says why in
a sentence. Here the answer is *every setting the job layer may carry, what it
reads now, who decided it, and where it is changed*, so that is the headline.
The three things that open a section by themselves are the three that mean
somebody is about to be surprised:
* a project config file is live (`!`), so the layers on screen are not what
the model reads;
* a setting no layer sets at all (`?`), which is not a value of nothing --
it means nothing here could judge it;
* a layer file that is not present, which is how a plant rule silently
stops applying.
NO REVIT AND NO `doc`. Everything Revit-shaped -- the layer list, the
locations, the run options -- is read by the button and passed in as data, so
the whole page can be built and asserted on host CPython
(`WillTools.extension/tests/test_configpage.py`).
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
from vibe import configmodel
from vibe import foldreport as fold
NOT_SET = '(not set in any layer)'
# Comma-separated rather than dot-separated on purpose: `foldreport.md_text`
# restores `&mdash;`, `&nbsp;`, `&dagger;` and `&amp;` and nothing else, so a
# `&middot;` here would reach the plain-text copy of the page as its own
# source. Entities the text renderer does not know are not decoration that
# degrades, they are visible breakage.
LEGEND = ('> `D` defaults, `C` catalogue, `P` plant, `J` job, '
'`!` project config, `?` no layer sets it, `+` several layers each '
'set part of it (the layers MERGE that key, entry by entry). A `?` '
'is **not** a value of nothing: it means nothing here could judge '
'the setting.')
# ------------------------------------------------------------------ the rows
def value_text(key, res):
"""What one setting reads as, and never as a blank.
An unknown value gets a sentence rather than an empty cell, for the reason
`configmodel.resolve` gives: "judged and found nothing" and "could not
judge" are different answers and an empty cell says the first one.
"""
if not res or not res.get('known'):
return NOT_SET
return configmodel.display_value(key, res['value'])
def editable_rows(layers, settings=None):
"""Every setting the job layer may carry, in SETTINGS order.
`n` is a stable position, not an interface: the editing is done in the
four-module window (`vibe.configform`), and what this page adds is the
column saying WHICH BOX each setting is in -- or that it is in none of
them and is changed in the file.
`settings` overrides the source rows for tests. It defaults to
`configmodel.editable()`, which is whatever the loaded build of
`configmodel` knows about -- so a build that has not yet learned a setting
simply does not number one, rather than numbering a row it cannot edit.
"""
rows = configmodel.editable() if settings is None else list(settings)
out = []
for index, row in enumerate(rows):
res = configmodel.resolve(layers, row['key'])
chip = configmodel.chip(res)
value = value_text(row['key'], res)
if configmodel.merges(row['key']):
# A key the layers MERGE has no single winning layer, and
# `resolve` -- which answers for a key that replaces -- would name
# the last one that set an entry as though it owned all of them.
# `J` on a map the plant also writes is the same lie as a silent
# override, one level down.
merged = configmodel.merged_map(layers, row['key'])
chip = configmodel.map_chip(merged)
value = configmodel.format_merged_map(merged) if merged \
else NOT_SET
res = dict(res)
res['layer'] = ' + '.join(configmodel.map_layers(merged)) or None
out.append({'n': index + 1,
'key': row['key'],
'label': row['label'],
'section': row['section'],
'row': row,
'res': res,
'chip': chip,
'value': value})
return out
def group_rows(rows):
"""[(section, [row])] in the order the sections first appear."""
order = []
holder = {}
for row in rows or []:
if row['section'] not in holder:
holder[row['section']] = []
order.append(row['section'])
holder[row['section']].append(row)
return [(name, holder[name]) for name in order]
def unknown_rows(rows):
"""The settings nothing in any layer decides."""
return [row for row in rows or [] if not row['res']['known']]
def config_rows(rows):
"""The settings a live project config file is deciding, over the layers."""
return [row for row in rows or [] if row['chip'] == '!']
def contested_rows(rows):
"""The settings more than one layer had an opinion about.
The full chain of a setting exactly one file mentions is one line long and
it is already on the page. These are the ones where reading the chain
tells the reader something the headline could not.
"""
return [row for row in rows or []
if len(row['res']['chain']) > 1 or row['res']['overrides']]
# -------------------------------------------------------------- the whole page
def render(title=None, layers=None, rows=None, problems=None,
job_path=None, config_path=None, config_missing=False,
env=None, run_options=None, run_options_rows=None,
run_options_path=None, locations=None, report_dir=None,
subtrees=None, muted=None, muted_note=None, muted_rows=None,
notes=None, full_path=None, placement=None):
"""The page as `foldreport.Section`s: headline first, the rest folded.
Everything is data. `layers` is `configdump.layer_values(doc,
include_config=True)`; `subtrees` is [(name, note, [(key, value, layer)])];
`locations` is `configdump.output_locations()`; `notes` are extra sentences
for the headline (a build that could not show something says so there).
`placement` is {key: where a person changes it} -- the four-module
window's boxes. A key it does not name is changed in the file, and the
page says so rather than leaving the column blank: a blank there would
read as "nowhere", and everything in the job layer is changeable
somewhere.
"""
layers = list(layers or [])
rows = editable_rows(layers) if rows is None else rows
sections = [_headline(title, rows, problems, job_path, config_path,
config_missing, notes, full_path, placement)]
missing_layers = [label for label, _p, exists, _d in layers if not exists]
unknown = unknown_rows(rows)
if unknown:
sections.append(_unknown(unknown))
if config_path:
sections.append(_config(config_path, config_missing,
config_rows(rows)))
sections.append(_read_only(layers))
if muted is not None or muted_note or muted_rows:
sections.append(_muted(muted, muted_note, muted_rows))
sections.append(_run_options(run_options, run_options_rows,
run_options_path))
sections.extend(_subtrees(subtrees))
sections.append(_layers(layers, missing_layers, env, job_path))
sections.append(_output(locations, report_dir))
sections.append(_chains(contested_rows(rows)))
sections.append(_job_file())
return sections
def _headline(title, rows, problems, job_path, config_path, config_missing,
notes, full_path, placement=None):
section = fold.Section('now', 'What this job is set to', headline=True)
section.md('# Configuration of {0}'.format(title or 'this model'))
section.md('Every setting the job layer may carry, what it reads now, '
'and which layer decided it. The **change it in** column says '
'where: one of the four boxes in the Project Config window, or '
'the file itself. Everything is written to the job layer and '
'only the job layer: `{0}`'
.format(job_path or '(not created yet)'))
section.md('Everything else &mdash; what is read-only and why, where each '
'file lives, the whole provenance chain, the run-time options '
'&mdash; is folded below. **If nothing below is open, nothing '
'below needs you.**')
for line in notes or []:
section.md('> ' + line)
for line in problems or []:
section.md('**{0}**'.format(line))
if config_path:
section.md(
'> ## This model runs on a project config file\n>\n> `{0}`{1}\n>\n'
'> It is the last word. **An edit made here does not reach this '
'model** until that file is refreshed from the layers or cleared.'
.format(config_path, ' **(MISSING)**' if config_missing else ''))
for name, group in group_rows(rows):
section.md('### ' + name)
section.table(['setting', 'in force', 'from', 'change it in',
'key'],
[[_cell(row['label']), _cell(row['value']),
'[{0}] {1}'.format(row['chip'],
row['res']['layer'] or 'nothing'),
_where(placement, row['key']),
'`{0}`'.format(row['key'])]
for row in group])
section.md(LEGEND)
if full_path:
section.md('The whole page, nothing folded, is also written to `{0}` '
'on every press.'.format(full_path))
return section
def _unknown(unknown):
section = fold.Section(
'unknown', 'Settings no layer sets',
summary='{0} setting(s)'.format(len(unknown)),
problem='{0} setting(s) that nothing in any layer decides. That is '
'not a value of nothing -- whatever reads them falls back to '
'its own idea, and nothing here can tell you what that is.'
.format(len(unknown)))
section.table(['setting', 'key'],
[[_cell(row['label']), '`{0}`'.format(row['key'])]
for row in unknown])
section.md('Each one is still editable: in its box in the window, or in '
'the job file.')
return section
def _config(config_path, config_missing, overridden):
problem = ('A project config file is live, so the layers below are NOT '
'what this model reads.')
if config_missing:
problem = ('The project config file this model points at is MISSING. '
'Nothing is falling back quietly -- put it back, refresh '
'it, or go back to the layered files.')
section = fold.Section(
'config', 'The project config file, which is the last word',
summary='{0} setting(s) decided by it'.format(len(overridden)),
problem=problem)
section.md('`{0}`'.format(config_path))
if overridden:
section.table(['setting', 'in force', 'key'],
[[_cell(row['label']), _cell(row['value']),
'`{0}`'.format(row['key'])] for row in overridden])
else:
section.md('It sets none of the settings on this page, so every value '
'above still comes from the layer named beside it.')
section.md('Until it is refreshed from the layers or cleared, a '
'job-layer edit does not reach this model.')
return section
def _read_only(layers):
rows = []
whys = []
for row in configmodel.read_only():
res = configmodel.resolve(layers, row['key'])
rows.append([_cell(row['label']),
_cell(value_text(row['key'], res)),
'[{0}] {1}'.format(configmodel.chip(res),
res['layer'] or 'nothing'),
'`{0}`'.format(row['key'])])
whys.append('**{0}** &mdash; {1}'.format(row['label'],
row['why'] or ''))
section = fold.Section('readonly', 'Shown here, not editable here',
summary='{0} setting(s)'.format(len(rows)))
section.md('This button writes the job layer and nothing else. What a '
'plant\'s hardware is, and which part number is a face lifter, '
'is not a job fact: one plant edit reaches every job, so '
'editing that file stays a deliberate act somewhere else. '
'Every one of these is still shown, with the sentence saying '
'why it is not yours to change here.')
section.table(['setting', 'in force', 'from', 'key'], rows)
for line in whys:
section.md('> ' + line)
return section
WHERE_MUTES_ARE_SET = (
'Muting is configured in the **Ticket QC** box of the Project Config '
'window: it lists every row the checklist asks, with a speaker beside '
'each. Clicking one asks for the reason -- required, dated for you, '
'written to the job layer.')
WHAT_A_MUTE_DOES = (
'**Muting does not stop a check.** A muted row runs on every ticket '
'exactly as before and keeps its place on the table; what changes is how '
'it reports, quietly instead of as a red row, when the result is not a '
'failure. **A failure is never quietened** &mdash; it stays on the table, '
'in the tally, and in the machine-readable JSON, whatever this list says.')
def _muted(muted, muted_note, muted_rows=None):
"""What is muted, whose mute it is, and WHERE THE CONTROL IS.
That last sentence is on the page because its absence was the defect: the
owner went looking for the mute control twice and did not find it, and
this page -- the one surface that lists what is muted -- said nothing at
all about where muting is done. A page that shows a state and not the
thing that sets it sends the reader hunting.
`muted_rows` is `configform.mute_table(...)`: the same rows the window
draws, so the layer column here and the buttons there cannot disagree. It
is passed in rather than computed because it needs the ticket checklist
table, which this module has no other business loading. Without it the
section falls back to the flat map, which is what every older caller
hands over.
"""
muted = muted if isinstance(muted, dict) else {}
listed = [row for row in (muted_rows or []) if row.get('muted')]
theirs = [row for row in listed if not row.get('editable')]
stale = [row for row in listed if not row.get('known')]
problem = None
if muted:
problem = ('{0} checklist item(s) are muted on this job. Each one '
'still RUNS on every ticket and keeps its row -- muting '
'only makes a result that is not a failure report quietly '
'rather than red. It never quietens a FAIL, and its '
'findings still reach the JSON.'.format(len(muted)))
if stale:
problem = ('{0} muted number(s) are not checklist items at all, so '
'they mute NOTHING. Somebody believes a row is off while '
'it is on.'.format(len(stale)))
section = fold.Section('muted', 'Muted checklist items',
summary='{0} muted'.format(len(muted)),
problem=problem)
if listed:
section.table(['item', 'checklist row', 'why this job never has one',
'set by'],
[[_cell(row['number']), _cell(row['name']),
_cell(row['reason'] or '(no reason given)'),
_cell(row['layer'] or 'nothing')] for row in listed])
elif muted:
section.table(['item', 'why this job never has one'],
[[_cell(key), _cell(muted[key])]
for key in sorted(muted)])
else:
section.md('Nothing is muted on this job. Every checklist row reports '
'at full volume.')
section.md('> ' + WHAT_A_MUTE_DOES)
section.md('> ' + WHERE_MUTES_ARE_SET)
if theirs:
# Not editable there, and saying so HERE as well: this page is where
# somebody lands after failing to change one in the window, and a
# second silence would send them to the wrong file.
section.md('> **{0} of them are not the job layer\'s.** The knowledge '
'layers MERGE this map, so a job entry cannot take a mute '
'set higher up away -- {1}. Edit that layer\'s file to '
'unmute.'.format(
len(theirs),
', '.join('{0} ({1})'.format(row['number'], row['layer'])
for row in theirs)))
if muted_note:
section.md('> ' + muted_note)
return section
def _run_options(options, option_rows, path):
options = options or {}
option_rows = list(option_rows if option_rows is not None
else configmodel.RUN_OPTIONS)
section = fold.Section(
'runopts', 'Run-time options &mdash; not knowledge',
summary='{0} option(s)'.format(len(option_rows)))
section.md('> No layers, no provenance, nothing overrides them. They are '
'choices about what a run is willing to spend, which is a '
'property of the run and not of the plant. `{0}`'
.format(path or '(beside the model)'))
section.table(
['what it is', 'now', 'default', 'key'],
[[_cell(row['label']),
_cell('{0}'.format(options.get(row['key'], row['default']))),
_cell('{0}'.format(row['default'])), '`{0}`'.format(row['key'])]
for row in option_rows])
return section
def _subtrees(subtrees):
"""One section for the parameter map, one for everything else shown raw."""
out = []
rest = []
for name, note, leaves in subtrees or []:
if name == 'parameters':
section = fold.Section(
'params', 'Revit parameter names this tool reads',
summary='{0} role(s)'.format(len(leaves)),
problem=None if leaves else
'No parameter names are mapped, so the checks that read them '
'do not run and say nothing about it.')
section.md('> ' + note)
section.table(['role', 'parameter name read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
else:
rest.append((name, note, leaves))
if rest:
section = fold.Section(
'published', 'Published data and plant knowledge, read-only',
summary='{0} group(s)'.format(len(rest)))
for name, note, leaves in rest:
section.md('### ' + name)
section.md('> ' + note)
section.table(['key', 'value read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
return out
def _layers(layers, missing, env, job_path):
problem = None
if missing:
problem = ('{0} layer file(s) are not present: {1}. Whatever they say '
'is not being applied, and nothing else reports that.'
.format(len(missing), ', '.join(missing)))
section = fold.Section(
'layers', 'Where every layer lives',
summary='{0} layer(s)'.format(len(layers)), problem=problem)
section.md('Each one overrides the one above it.')
section.table(['layer', 'file', 'present'],
[[_cell(label), _cell(path or '(built into the tool)'),
'yes' if exists else 'MISSING']
for label, path, exists, _data in layers])
section.md('The job layer, which is the one this button writes: `{0}`'
.format(job_path or '(not created yet)'))
section.table(['environment variable', 'value'],
[[_cell(name), _cell(value)] for name, value in env or []])
return section
def _output(locations, report_dir):
locations = locations or {}
section = fold.Section('output', 'Where output goes',
summary='{0} place(s)'.format(len(locations)))
section.table(['what', 'where'],
[[_cell(key.replace('_', ' ')), _cell(locations[key])]
for key in sorted(locations)])
section.md('`{0}` is the report folder in force, and it is the one path '
'here that is a setting.'.format(report_dir))
return section
def _chains(rows):
section = fold.Section(
'chains', 'The full chain, where more than one layer had an opinion',
summary='{0} setting(s)'.format(len(rows)))
section.md('A setting exactly one file mentions is already answered on '
'the page above. These are the ones where a layer was beaten, '
'which is the case worth reading.')
for row in rows:
# No key line of its own: `chain_lines` leads with the dotted key and
# the chip, so printing one here would say it twice.
section.md('### ' + row['label'])
for line in configmodel.chain_lines(row['res']):
section.md(' ' + line)
if not rows:
section.md('Every setting comes from exactly one place.')
return section
def _job_file():
section = fold.Section('jobfile', 'What panellift is handed',
summary='8 field(s)')
section.md('> The job file (`*.job.json`) written beside every report is '
'not a second place to configure anything: it is generated '
'from the settings above on every run. These are the fields it '
'carries and where each one comes from, so the two halves of '
'"the job layer" read as one thing.')
section.table(['job file field', 'comes from'],
[['rigging.mode', 'handling.rig_mode'],
['rigging.sling_angle_deg', 'handling.sling_angle_deg'],
['rigging.group_tolerance_ft',
'handling.group_tolerance_ft'],
['stages[].rig_mode', 'handling.stages[].rig_mode'],
['stages[].multiplier / pci_case / strength',
'handling.stages[]'],
['stages[].lifters',
'clicked on the piece in Panel Lift Check'],
['rigging.tension_fos / shear_fos',
'catalogue.safety_factors.*'],
['panel.*', 'measured from the model']])
return section
def _where(placement, key):
"""Which of the window's boxes changes this setting, or the file.
Never blank. A blank cell in a column headed "change it in" reads as
"nowhere", and every setting on this page is changeable somewhere.
"""
if placement is None:
return ''
return placement.get(key) or 'the file'
def _cell(value):
"""One table cell. A pipe in a value would otherwise split the row.
`output.print_table` builds a markdown pipe table and hands it to the
markdown renderer, so a muting reason containing a `|` -- a sentence
somebody typed -- would silently become two columns and shift every cell
after it.
"""
text = '' if value is None else '{0}'.format(value)
return text.replace('|', '&#124;')
+739
View File
@@ -0,0 +1,739 @@
# -*- coding: utf-8 -*-
"""Design summaries as reviewable data.
A design summary is a drafting view full of free text that a human wrote. The
checking scripts want numbers. Turning one into the other is the only part of
the job that genuinely needs judgement, so it happens once, up front, and the
result is written to disk where you can read and correct it:
<model folder>/_ticketqc/
evidence/<DESIGN>.json everything we could scrape off the summary
specs/<DESIGN>.json the structured spec <-- the thing to review
knowledge.yaml hand-written rules that outlive one project
log/<DESIGN>.txt what the extractor printed
Evidence is gathered deterministically here. So is the spec now: the shape
of a spec, and how it reads and writes as text, live in `specformat.py` --
which touches no Revit API at all, on purpose, so that shape can be unit
tested without a licence or a model (see its docstring). This module is the
Revit side of that boundary: it reads the model, and it reads and writes
specformat's shapes to disk beside it. Everything downstream reads only the
spec, so a checking run is pure, repeatable arithmetic.
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
whole point of this file is that an engineer reads it and corrects it by hand.
An older spec written as plain JSON is still read -- see load_spec.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
import re
import json
import datetime
from Autodesk.Revit.DB import (
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
BrowserOrganization, Element, StorageType,
)
from vibe import specformat
from vibe import specsave
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
normalize_spec, new_spec, strand_positions, strand_row_marks,
strand_diagram, spec_text, parse_spec_text, design_key,
)
from vibe.compat import as_text
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
# path arithmetic and lives in a module with no Revit import, so it can be
# tested on host CPython -- but every caller has always asked designspec for
# it, and a second spelling of the same question is how two answers start.
from vibe.plantpath import ( # noqa: F401 -- re-exported
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
)
# Control characters and format characters that ride along in Revit text and
# crash downstream printers / parsers. Measured: a model parameter whose
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
# way into evidence/WP1.json and broke a tool that printed it. Everything
# that scrapes model text goes through clean_text at the boundary; the
# function itself lives in ticketpage (the Revit-free module, unit-tested).
from vibe.ticketpage import clean_text
FOLDER = '_ticketqc'
JOBS_ENV = 'WILLTOOLS_JOBS'
# ------------------------------------------------------------------- layout
def root_dir(doc):
"""Where this project's QC data lives.
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
underneath it. Everything a project knows then sits in one place the
engineer chose, rather than in two that the tool chose for him.
Without it, beside the model, falling back to APPDATA when the model has
no path yet or its folder cannot be written. That fallback is the reason
the override exists. Saving a detached copy gives it a path, so the
lookup silently moves from APPDATA to the model folder, and a job
knowledge file written before the save stops being read -- measured
2026-08-30, and from the outside it looks exactly like edits that did not
take.
"""
override = os.environ.get(JOBS_ENV)
if override:
return _ensure(os.path.join(override, doc.Title or 'untitled'))
model = doc.PathName
if model:
base = os.path.join(os.path.dirname(model), FOLDER)
try:
_ensure(base)
return base
except Exception:
pass
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
'pyrevit-ticketqc', doc.Title or 'untitled')
_ensure(fallback)
return fallback
def _ensure(path):
if not os.path.isdir(path):
os.makedirs(path)
return path
def sub_dir(doc, name):
return _ensure(os.path.join(root_dir(doc), name))
def evidence_path(doc, design):
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
def spec_path(doc, design, ext='yaml'):
"""Where one design's spec lives. New specs are written as '.yaml'.
A spec written before this file carried comments is still on disk as
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
whichever one is actually there.
"""
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
def find_spec_path(doc, design):
"""(path, ext) of the spec actually on disk for this design.
'.yaml' wins when both exist -- it is the one a human could have
corrected, and load_spec must not silently prefer the one they didn't
touch. Neither existing, this is where a new spec would be written.
"""
yaml_path = spec_path(doc, design, 'yaml')
if os.path.isfile(yaml_path):
return yaml_path, 'yaml'
json_path = spec_path(doc, design, 'json')
if os.path.isfile(json_path):
return json_path, 'json'
return yaml_path, 'yaml'
def log_path(doc, design):
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
def knowledge_path(doc):
return os.path.join(root_dir(doc), 'knowledge.yaml')
# ------------------------------------------------------- the project config
#
# A project's configuration file is written wherever the engineer wants it --
# the job folder on a share, usually, where the rest of the job's paperwork
# lives and where a reviewer will look for it. That is the whole point of
# letting them choose, and it means the tool cannot go and find it by
# convention afterwards.
#
# So a one-line pointer stays behind, beside the model with the rest of the QC
# data. It holds the path and nothing else. Losing it costs one button press;
# putting the config itself here would defeat the choice.
CONFIG_POINTER = 'active-config.txt'
def config_pointer_path(doc):
return os.path.join(root_dir(doc), CONFIG_POINTER)
def active_config_path(doc):
"""The project config file in force for this model, or None.
Returns the path even when the file has gone missing. A pointer to
nothing is a fact the caller has to be told about -- resolving it to None
would silently fall back to the layered files, and the run would use a
different configuration than the one the engineer set, without saying so.
"""
pointer = config_pointer_path(doc)
if not os.path.isfile(pointer):
return None
try:
with io.open(pointer, 'r', encoding='utf-8') as fh:
path = fh.read().strip()
except Exception:
return None
return path or None
def set_active_config(doc, path):
"""Make this file the configuration this model runs on."""
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
fh.write(as_text(path))
return path
def clear_active_config(doc):
"""Go back to the layered files. Returns what was in force, or None."""
pointer = config_pointer_path(doc)
previous = active_config_path(doc)
if os.path.isfile(pointer):
os.remove(pointer)
return previous
def _safe(name):
"""A design number as a filename.
One rule, and it lives in `specsave` -- the Extract Design button names
the COPY it saves elsewhere with the same function, so a copy can never
end up called something the canonical file is not.
"""
return specsave.safe_name(name)
# ---------------------------------------------------------- design numbers
#
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
# file, alongside the rest of the spec shape. It is the same function under
# the same name; nothing that calls `designspec.design_key` has to change.
#
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
# asking "are these two design numbers the same design?" needed a licence.
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
# out of a suggestion -- and it must answer both inside Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
# the reduction there would have been a second spelling of one question, which
# is how two answers start; so the function went to the Revit-free module and
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
# `specformat.FALLBACK_DESIGN_RE`.
def _nomen(know, key, default):
"""One nomenclature setting, tolerating a knowledge dict that is not there.
Imported late: knowledge imports this module, so a module-level import
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
and resolves `nomenclature.<key>` at each call site, so do not rename it
without teaching the scanner.
"""
if know is None:
return default
from vibe import knowledge as K
return K.get(know, 'nomenclature.' + key, default)
def list_specs(doc):
"""Design numbers that already have a spec on disk.
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
wins, same rule as find_spec_path, so a spec a human has never touched
(still '.json') does not shadow one they have.
"""
folder = sub_dir(doc, 'specs')
by_design = {}
for fn in sorted(os.listdir(folder)):
low = fn.lower()
if low.endswith('.yaml'):
rank = 0
elif low.endswith('.json'):
rank = 1
else:
continue
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
if not spec or not spec.get('design_number'):
continue
design = spec['design_number']
prior = by_design.get(design)
if prior is None or rank < prior[0]:
by_design[design] = (rank, spec)
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
# --------------------------------------------------------------------- io
def read_json(path):
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
except Exception:
return None
def write_json(path, data):
_ensure(os.path.dirname(path))
text = as_text(json.dumps(data, indent=2, sort_keys=True,
ensure_ascii=False))
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(text)
return path
def read_yaml(path):
"""A spec file written by save_spec, or None. Comments are just dropped."""
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
text = fh.read()
except Exception:
return None
try:
return specformat.parse_spec_text(text)
except Exception:
return None
def read_spec_file(path):
"""A spec, whichever of the two formats it was written in."""
if path.lower().endswith('.json'):
return read_json(path)
return read_yaml(path)
def load_spec(doc, design):
path, _ext = find_spec_path(doc, design)
return normalize_spec(read_spec_file(path))
def save_spec(doc, design, spec):
"""Write a spec as hand-editable YAML.
Always '.yaml', regardless of what was there before -- a spec that gets
re-extracted goes back to being machine-written, and a stale '.json'
left beside it would otherwise look like an untouched original. Nothing
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
here on, same as list_specs.
"""
path = spec_path(doc, design, 'yaml')
_ensure(os.path.dirname(path))
text = specformat.spec_text(spec)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(as_text(text))
return path
# ------------------------------------------------------------ element utils
def _name(el):
try:
return Element.Name.GetValue(el)
except Exception:
return ''
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _params(el, wanted=None):
out = {}
for p in el.Parameters:
pname = p.Definition.Name
if wanted is not None and pname not in wanted:
continue
try:
if p.StorageType == StorageType.String:
val = p.AsString()
elif p.StorageType == StorageType.Integer:
val = p.AsInteger()
elif p.StorageType == StorageType.Double:
val = p.AsValueString()
else:
val = p.AsValueString()
except Exception:
val = None
if val not in (None, ''):
out[pname] = val
return out
def _dimension(d):
"""One dimension, with enough geometry to say what it measures.
A bare string is not enough for a strand chain. Where the strands sit is
dimensioned as a multi-segment chain -- rail to first strand, then strand
to strand, then last strand to the far edge -- so the segments in order,
their exact lengths, and which way the chain runs are all part of the
reading. N segments locate N-1 strands and the total is the piece width,
which is what makes the reading checkable.
Two traps, both met on real summaries:
* Dimension.Curve throws on these drafting views, so the direction is
inferred from how the segment origins are spread instead.
* segment.Value is the drawn geometry and segment.ValueString is what is
printed. On an overridden segment they disagree, and only the printed
text is the instruction -- so both are recorded and neither is
silently preferred.
"""
entry = {}
try:
entry['value'] = clean_text(d.ValueString)
except Exception:
entry['value'] = None
for key, attr in (('above', 'Above'), ('below', 'Below'),
('prefix', 'Prefix'), ('suffix', 'Suffix')):
try:
val = getattr(d, attr, None)
except Exception:
val = None
if val:
entry[key] = val
try:
raw = d.Value
if raw is not None:
entry['value_ft'] = round(float(raw), 6)
except Exception:
pass
try:
entry['references'] = d.References.Size
except Exception:
pass
try:
origin = d.Origin
entry['x'] = round(origin.X, 4)
entry['y'] = round(origin.Y, 4)
except Exception:
pass
segments = []
try:
if d.NumberOfSegments > 1:
for seg in d.Segments:
item = {}
try:
item['value'] = clean_text(seg.ValueString)
except Exception:
item['value'] = None
try:
if seg.Value is not None:
item['value_ft'] = round(float(seg.Value), 6)
except Exception:
pass
try:
point = seg.Origin
item['x'] = round(point.X, 4)
item['y'] = round(point.Y, 4)
except Exception:
pass
segments.append(item)
except Exception:
pass
if segments:
entry['segments'] = segments
entry['segment_count'] = len(segments)
xs = [s['x'] for s in segments if 'x' in s]
ys = [s['y'] for s in segments if 'y' in s]
if xs and ys:
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
else 'vertical')
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
if len(lengths) == len(segments):
entry['total_ft'] = round(sum(lengths), 6)
if entry.get('value') or segments:
return entry
return None
def _view_content(doc, view):
"""Text, dimensions and drawn components of one view, positions included.
Positions matter: the min/max table in a summary is a label note and a
value note sitting side by side, and only the geometry links them.
"""
notes = []
for t in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
try:
c = t.Coord
notes.append({'text': t.Text.replace('\r', '').strip(),
'x': round(c.X, 4), 'y': round(c.Y, 4)})
except Exception:
notes.append({'text': t.Text.replace('\r', '').strip()})
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_Dimensions)
.WhereElementIsNotElementType().ToElements())]
dims = [d for d in dims if d]
components = {}
for e in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
try:
key = e.Symbol.Family.Name
except Exception:
key = type(e).__name__
components[key] = components.get(key, 0) + 1
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
'text_notes': notes, 'dimensions': dims, 'components': components}
# ------------------------------------------------------------ the gathering
def _sheet_folder(org, sheet):
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
if org is None:
return ''
try:
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
return ''
def find_summary_views(doc, design, know=None):
"""The views and sheets that make up one design's packet.
Driven by SHEETS, not by view names. A view belongs to the design of the
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
would file it under the wrong design without anything looking wrong.
Sheets are reduced through design_key, so the whole packet -- cover sheet
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
order, which puts the cover sheet (the design summary itself) first.
"""
key = design_key(design, know)
if not key:
return [], []
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
ignore = [str(p).upper()
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
stale = _nomen(know, 'stale_view_re', None)
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
sheets = []
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if s.IsTemplate:
continue
number = (s.SheetNumber or '').strip()
if any(number.upper().startswith(p) for p in ignore):
continue
if design_key(number, know) != key:
continue
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
continue
sheets.append(s)
sheets.sort(key=lambda s: (s.SheetNumber or ''))
views, seen = [], set()
for sheet in sheets:
for vid in sheet.GetAllPlacedViews():
if vid in seen:
continue
seen.add(vid)
view = doc.GetElement(vid)
if view is None:
continue
if stale:
try:
if re.search(stale, view.Name):
continue
except Exception:
pass
views.append(view)
return views, sheets
def gather_evidence(doc, design, know=None):
"""Scrape everything the extractor could possibly need. No interpretation."""
views, sheets = find_summary_views(doc, design, know)
evidence = {
'schema': SCHEMA_VERSION,
'design_number': design,
'model': doc.Title,
'model_path': doc.PathName,
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'summary_views': [_view_content(doc, v) for v in views],
'sheets': [],
}
for sheet in sheets:
entry = {
'number': sheet.SheetNumber,
'name': sheet.Name,
'id': _eid(sheet),
'parameters': _params(sheet),
'views': [],
}
for vid in sheet.GetAllPlacedViews():
view = doc.GetElement(vid)
if any(v.Id == view.Id for v in views):
continue
entry['views'].append(_view_content(doc, view))
evidence['sheets'].append(entry)
# A worked example of a real piece helps the extractor ground its units.
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
return evidence
def _sample_piece(doc, design, know=None):
"""One assembly on this design, so the extractor can see real magnitudes."""
from Autodesk.Revit.DB import AssemblyInstance
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
from vibe.ticketqc import rstr
# Compared as reduced keys: the piece may be stamped PNL1 while
# the summary sheet is PNL1.0, and they are the same design.
if design_key(rstr(member, know, 'panel.design_number'),
know) != design_key(design, know):
break
bom = {}
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
try:
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
except Exception:
pass
return {
'mark': asm.AssemblyTypeName,
'family': member.Symbol.Family.Name,
'panel_parameters': _params(member),
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
'bill_of_material': bom,
}
else:
continue
return None
# ------------------------------------------------------------- spec shape
#
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
# in specformat.py now -- see the imports at the top of this file -- because
# none of them touch Revit and every one of them is worth testing without it.
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
# code written against this module.
def build_spec(doc, design, know=None, evidence=None):
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
Everything below either matched one of ticketqc's rules or it did not --
there is no third option, and nothing here guesses. What matched is
filled in; every text note on the design summary that nothing matched is
recorded in 'unresolved' rather than silently dropped. What the chain of
dimensions means -- the strand matrix -- is not attempted here at all: it
is not text, matching it well enough to trust would take real judgement,
and a wrong grid is worse than an honest blank one a human fills in by
looking at the drawing. datum comes from knowledge (rails.strand_datum)
because that much genuinely is known already; columns_in and rows start
empty.
Returns None when this design has no summary view to read.
"""
from vibe import knowledge as K
from vibe import ticketqc
if know is None:
know = K.load(doc)
views, sheets = find_summary_views(doc, design, know)
if not views:
return None
if evidence is None:
evidence = gather_evidence(doc, design, know=know)
view = views[0] # the design's own summary view --
# the rest of the packet's views
# are sub-details, not this design
reading = ticketqc.parse_design_summary(doc, view)
spec = new_spec(design, evidence)
spec['source']['extractor'] = 'regex'
spec['piece_description'] = sheets[0].Name if sheets else None
spec['concrete']['fc_psi'] = reading.get('fc')
spec['concrete']['fci_psi'] = reading.get('fci')
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
spec['reinforcing']['strand_groups'] = [
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
'it_lb': g.get('it'), 'role': None, 'note': None}
for g in (reading.get('strands') or [])
]
spec['reinforcing']['strand_layout']['datum'] = K.get(
know, 'rails.strand_datum', 'left_rail')
envelope = reading.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
if entry:
val, raw = entry
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
# which does not land on a clean decimal even when the
# drawing dimension is exact -- 34'-11 1/2" is really
# 34.958333333333336 in binary float. Round to the nearest
# 1/16" (6 decimal places comfortably clears that) so the
# spec shows the number a person would actually write, not
# float noise nobody typed.
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
spec['envelope'][dim][bound + '_text'] = raw
# Every note the generic rules could not place. The plant's declared rule
# packs then get one look at that list and may move a note into `notes`
# (prose a person reads) or leave it where it is with a better sentence
# beside it -- never remove one. `apply_to` returns both halves and their
# lengths always sum back to what went in, which is the property that
# keeps a house-style rule from becoming a silent exclusion.
#
# No packs declared, or a declaration this build refuses, means this is
# exactly the single assignment it used to be. A refusal is carried on the
# spec rather than raised: a spec that lost its extraction because a
# knowledge file named an unknown pack would be a worse outcome than one
# that says so on its face and leaves every note visible.
from vibe import extractrules
entries = ticketqc.unresolved_in_view(doc, view)
packs, problems = extractrules.load(know)
notes, unresolved = extractrules.apply_to(entries, packs)
spec['notes'] = notes
spec['unresolved'] = unresolved
if problems:
spec['source']['rule_pack_problems'] = problems
return spec
@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
"""PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of
the generic reader and loaded only where the plant knowledge says so.
WHY THIS EXISTS
---------------
`ticketqc.parse_design_summary` reads a design summary with rules that are
true of precast drawings generally -- an f'c line, a strand callout, a mesh
callout. Every text note it cannot place lands in the spec's `unresolved`
list verbatim, which is what stops a narrow rule set from being a lossy one.
But a real sheet carries a second population of notes that are not general at
all. They are one producer's house style: the title of a standard detail, the
label above a legend, a standing instruction that appears on every sheet that
producer draws. The generic reader cannot place them and should not try --
they are not facts about precast, they are facts about an office.
Left alone they are not harmless. Seven of them on one sheet turn `unresolved`
into a list nobody reads, and the day a genuinely unread value lands there it
is the eighth line of a list the reader has already learned to skim. A signal
that always fires is not a signal -- the same argument `.setup-opts` makes for
a check that always FAILs, and the same one `checks.muted` makes for a row
that is permanently NOT RUN.
WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO
--------------------------------------------
A pack classifies a note the generic rules did not match. It may say:
* `note` -- prose that belongs in the spec's `notes` list. An
instruction, a condition, a standing rule. It is CARRIED,
verbatim, into a field a person reads.
* `furniture` -- drawing furniture that carries no data at all: the bare
title of a legend or a detail. It STAYS in `unresolved`,
with the pack's reason in place of the blanket one.
A pack may NEVER remove a note from the spec, and there is no third verdict
that makes one disappear. `furniture` is not an ignore list: the line is still
written out, still readable, still countable -- only the sentence beside it
changes, from "no rule matched this" to "this is a legend title, it carries no
data". An exclusion the code chose for itself is the one failure this project
has already paid for (see `vibe/scope.py`), and nothing here may repeat it.
WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture`
--------------------------------------------------
The two errors are not symmetric, in the same way `vibe/scope.py`'s two are
not. A note wrongly called `note` is copied into a field a person reads and
costs them one line of attention. A note wrongly called `furniture` has been
declared meaningless by a regex, and the reader is told there is nothing
there. So a rule that is not certain writes `note`, and a title that carries
any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a
note, not furniture -- the condition is the part that could matter.
WHICH PACKS LOAD IS DECLARED, NEVER INFERRED
--------------------------------------------
From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though
that key exists and would be enough to guess with: which house style a job is
drawn in is a thing somebody knows and can write down, and a guess that is
right today is a guess that goes wrong silently the first time a job is drawn
to another producer's standard on this plant's template.
extract:
rule_packs: [mc]
Undeclared means NO PACK LOADS and every note behaves exactly as it did
before this module existed -- unmatched notes land in `unresolved` under the
generic reason. That is the honest default: a machine that has been told
nothing about an office's habits should claim to recognise nothing.
A declared name this module does not know is REFUSED, loudly, and no pack
loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts`
check: an opt file that has gone quiet about a line nobody honours is worse
than no opt file, because the reader believes something is in force that is
not. `problems` comes back beside the packs and the caller prints it.
Nothing here imports a module named in knowledge. `PACKS` is a whitelist of
names this package ships; a name outside it is an error, never an import.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
# Declared name -> module basename inside this package. A WHITELIST: the
# knowledge layer chooses among these, it never names a module to import.
PACKS = {
'mc': 'mc',
}
KEY = 'extract.rule_packs'
# What a pack may return from `classify`. `note` is carried into the spec's
# notes; `furniture` stays in unresolved with a better reason. There is no
# verdict that drops a line -- see the module docstring.
NOTE = 'note'
FURNITURE = 'furniture'
KINDS = (NOTE, FURNITURE)
class Placement(object):
"""One pack's verdict on one note.
`rule` is the pack-qualified id of the rule that fired (`mc/legend-title`)
so a reader of the spec can find the line of code that made the claim, and
argue with it. A placement that cannot say which rule made it is not
accepted -- an unattributable classification is exactly the kind nobody
can correct.
"""
__slots__ = ('kind', 'rule', 'reason')
def __init__(self, kind, rule, reason):
if kind not in KINDS:
raise ValueError('unknown placement kind: %r' % (kind,))
if not rule:
raise ValueError('a placement must name the rule that made it')
self.kind = kind
self.rule = rule
self.reason = reason
def __repr__(self):
return 'Placement(%r, %r)' % (self.kind, self.rule)
def load(know, get=None):
"""(packs, problems) for the rule packs this knowledge declares.
`packs` is a list of imported pack modules, in declared order. `problems`
is a list of one-line strings naming anything refused; when it is
non-empty `packs` is EMPTY -- a half-loaded rule set would classify some
notes and not others, and the reader could not tell which sentences to
trust. All or nothing, and say why.
"""
if get is None:
from vibe import knowledge as K
get = K.get
declared = get(know, KEY, None) or []
if isinstance(declared, str):
declared = [declared]
problems = []
names = []
for raw in declared:
name = (raw or '').strip().lower()
if not name:
problems.append('an empty name in %s -- remove the entry or '
'name a pack' % (KEY,))
elif name not in PACKS:
problems.append("%s names '%s', which this build does not ship. "
'Known packs: %s'
% (KEY, name, ', '.join(sorted(PACKS)) or '(none)'))
elif name in names:
problems.append("%s names '%s' twice" % (KEY, name))
else:
names.append(name)
if problems:
return [], problems
packs = []
for name in names:
packs.append(__import__('vibe.extractrules.' + PACKS[name],
globals(), locals(), ['classify'], 0))
return packs, []
def classify(text, packs):
"""The first pack that recognises `text`, or None.
First, not best: packs are tried in the order they were declared, and the
reader can see that order in the job file. Nothing here merges two packs'
opinions -- a note that two house styles both claim is a fact about the
declaration, not something this function should quietly average.
"""
for pack in (packs or []):
placed = pack.classify(text)
if placed is not None:
return placed
return None
def apply_to(entries, packs):
"""Split `unresolved` entries into (notes, unresolved) using the packs.
`entries` are the dicts `ticketqc.unresolved_in_view` returns -- each
carries at least 'text' and 'reason'. Every entry comes back in exactly
one of the two lists and none is dropped: `len(notes) + len(unresolved)`
always equals `len(entries)`, which `test_extractrules` asserts directly
because it is the property the whole module rests on.
A `note` gains 'rule' so the spec says which rule claimed it. A
`furniture` keeps its place in unresolved and only its 'reason' changes.
"""
notes = []
unresolved = []
for entry in (entries or []):
placed = classify(entry.get('text') or '', packs)
if placed is None:
unresolved.append(entry)
continue
out = dict(entry)
out['rule'] = placed.rule
if placed.reason:
out['reason'] = placed.reason
if placed.kind == NOTE:
notes.append(out)
else:
unresolved.append(out)
return notes, unresolved
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""The `mc` pack: one producer's design-summary habits.
Loaded only when the plant layer declares it (`extract.rule_packs: [mc]`) --
see `vibe/extractrules/__init__.py` for why that is declared and not inferred
from `plant.id`.
WHERE THESE RULES COME FROM
---------------------------
Every rule below was written against text observed on a real sheet, and each
one says which. They are not a model of how this producer draws; they are a
record of lines that actually landed in `unresolved` and what they turned out
to be. A rule with no observation behind it does not belong here -- this file
is evidence, and a guess mixed into evidence is indistinguishable from it
afterwards.
Observed on `WPA1.0` (sample_job, 8" horizontal wall, read 2026-09-01):
all seven notes the generic reader could not place, listed in the order they
sit on the sheet.
WHAT IS DELIBERATELY NOT HERE
------------------------------
`STRIPPING & BLOCKING DETAIL < 25'` is NOT treated as the same thing as the
bare `STRIPPING & BLOCKING DETAIL` beside it. The bare one is the title of a
detail drawn on the sheet and carries no data. The other one carries a
CONDITION -- which detail applies below twenty-five feet -- and a condition is
the part that could change what gets built. So the bare title is furniture and
the conditional one is a note, and the rule that separates them tests for
anything trailing the title rather than matching both with one loose pattern.
That asymmetry is the module's standing rule (`__init__`: when unsure, note).
Nothing here encodes a NUMBER. `*USE MC STANDARD REINFORCEMENT ...` names a
standard this office keeps; what that standard says is plant knowledge and
belongs in the plant knowledge file, looked up by name, never frozen into a
regex in this file. The rule's whole job is to carry the sentence to a field a
person reads.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.extractrules import Placement, NOTE, FURNITURE
NAME = 'mc'
def _rule(rule_id, pattern, kind, reason):
return (rule_id, re.compile(pattern, re.IGNORECASE), kind, reason)
# Order matters and is not alphabetical: a more specific rule must come before
# the looser one it would otherwise be swallowed by. The conditional detail
# title is tested before the bare one for exactly that reason.
RULES = (
# ---------------------------------------------------------- instructions
_rule('mc/standard-reinforcement',
r"^\*?\s*USE\s+MC\s+STANDARD\s+REINFORCEMENT\b",
NOTE,
'a standing instruction naming this plant\'s standard reinforcement '
'for cut-down panels and openings -- prose, not a number; what the '
'standard says is plant knowledge, looked up by name'),
_rule('mc/conditional-bar',
r"^#\s*\d+\s+BAR\b.*\bONLY\s+NEEDED\s+WHEN\b",
NOTE,
'a conditional reinforcing instruction -- the bar is required only '
'when the condition in the sentence holds, so the condition is the '
'value and it stays as prose'),
_rule('mc/pick-instruction',
r"^USE\s+FOR\b.*\bPICK\b",
NOTE,
'a handling instruction saying which pick this detail is for -- '
'prose, and it qualifies a detail rather than stating a number'),
_rule('mc/detail-title-conditional',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\b\s*\S+",
NOTE,
'a standard-detail title carrying a CONDITION (which detail applies, '
'and when) -- the title alone would be furniture, the condition is '
'not'),
# ------------------------------------------------------------- furniture
_rule('mc/detail-title',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\s*$",
FURNITURE,
'the bare title of a standard detail drawn on the sheet -- names '
'the detail, states nothing about this design'),
_rule('mc/legend-title',
r"^STRAND\s+PATTERN\s*$",
FURNITURE,
'the title above the strand-pattern legend -- the legend\'s DATA is '
'the dimension chain under it, which is read as geometry, not as '
'this text'),
_rule('mc/legend-label',
r"^STRAND\s*$",
FURNITURE,
'a bare column label in the strand legend -- a heading over values '
'read elsewhere, with no value of its own'),
)
def classify(text):
"""A Placement for `text`, or None when this pack does not recognise it.
`text` arrives already whitespace-normalised by the caller (one space
between words, no leading or trailing run), so every pattern here can
assume single spaces and anchor at both ends where it means to.
"""
probe = (text or '').strip()
if not probe:
return None
for rule_id, pattern, kind, reason in RULES:
if pattern.search(probe):
return Placement(kind, rule_id, reason)
return None
+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
+279
View File
@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""Small, boring helpers that remove the usual Revit API boilerplate.
Written for IronPython 2.7 (pyRevit's default engine) but 3.x-safe:
no f-strings, no walrus.
"""
# pylint: disable=import-error,invalid-name,broad-except
from contextlib import contextmanager
from Autodesk.Revit import DB, UI # noqa: F401
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, BuiltInParameter,
ElementId, StorageType, Transaction, TransactionGroup,
UnitUtils,
)
# UnitTypeId arrived in Revit 2021 and DisplayUnitType was removed in 2022, so
# the two never overlap for long. Resolving the unit handles once here keeps a
# host outside that window from failing the import of the whole library --
# which would take every button down, not just the four conversions below.
try:
from Autodesk.Revit.DB import UnitTypeId
_MILLIMETERS = UnitTypeId.Millimeters
_METERS = UnitTypeId.Meters
_SQUARE_METERS = UnitTypeId.SquareMeters
except ImportError: # Revit 2020 and older
from Autodesk.Revit.DB import DisplayUnitType
_MILLIMETERS = DisplayUnitType.DUT_MILLIMETERS
_METERS = DisplayUnitType.DUT_METERS
_SQUARE_METERS = DisplayUnitType.DUT_SQUARE_METERS
from pyrevit import revit, script, HOST_APP
# ------------------------------------------------------------ session handles
#
# revit.doc / revit.uidoc are LIVE @property accessors -- they always return
# whichever document is active RIGHT NOW. Assigning one to a module global
# snapshots its value ONCE, at the first import of `vibe`, and freezes it to
# whatever document happened to be active then. With two models open and the
# user switching between them that snapshot goes stale, and any helper that
# read it would silently act on the wrong model -- a transaction opened on the
# stale doc, a collector enumerating the stale doc's elements with no error.
#
# So the helpers below NEVER read these globals; they call _doc()/_uidoc()/
# _uiapp(), which re-resolve on every call. The globals are kept only for
# backward compatibility: `vibe/__init__.py` re-exports them, and some callers
# do `from vibe.helpers import doc`. IronPython 2.7 has no module-level
# __getattr__ (that is Python 3.7+), so these names cannot be made to
# re-resolve on plain attribute access -- a caller that reads helpers.doc /
# vibe.doc still gets the frozen snapshot. Any such caller in a
# model-reading or transaction path must be repointed to _doc() (or read
# revit.doc directly) instead of these names.
uiapp = HOST_APP.uiapp
uidoc = revit.uidoc
doc = revit.doc
def _uiapp():
"""The live UIApplication, re-resolved on each call (never a snapshot)."""
return HOST_APP.uiapp
def _uidoc():
"""The active UIDocument, re-resolved on each call (never a snapshot)."""
return revit.uidoc
def _doc():
"""The active Document, re-resolved on each call (never a snapshot)."""
return revit.doc
# ----------------------------------------------------------------- collecting
def _as_bic(category):
"""Accept BuiltInCategory, 'OST_Walls', or 'Walls'."""
if isinstance(category, BuiltInCategory):
return category
name = str(category)
if not name.startswith('OST_'):
name = 'OST_' + name.replace(' ', '')
bic = getattr(BuiltInCategory, name, None)
if bic is None:
raise ValueError('unknown BuiltInCategory: ' + str(category))
return bic
def q(category=None, cls=None, view=None, types=False, document=None):
"""One-liner element query. Returns a list.
q('OST_Walls') -> all wall instances
q(cls=DB.Wall) -> same, by class
q('OST_Doors', view=doc.ActiveView)-> only what's visible in the view
q('OST_Walls', types=True) -> wall types
"""
d = document or _doc()
col = FilteredElementCollector(d, view.Id) if view is not None \
else FilteredElementCollector(d)
if category is not None:
col = col.OfCategory(_as_bic(category))
if cls is not None:
col = col.OfClass(cls)
col = col.WhereElementIsElementType() if types \
else col.WhereElementIsNotElementType()
return list(col.ToElements())
def all_of(category, document=None):
"""All instances of a category."""
return q(category, document=document)
def types_of(category, document=None):
"""All element types of a category."""
return q(category, types=True, document=document)
def by_name(elements, name):
"""First element whose .Name matches, else None."""
for el in elements:
try:
if el.Name == name:
return el
except Exception:
continue
return None
def selected(document=None):
"""Currently selected elements as a list."""
d = document or _doc()
return [d.GetElement(i) for i in _uidoc().Selection.GetElementIds()]
def pick(message='Pick elements', multiple=True):
"""Interactive pick. Returns a list of elements (empty if cancelled)."""
from Autodesk.Revit.UI.Selection import ObjectType
_uidoc_ = _uidoc()
_doc_ = _doc()
try:
if multiple:
refs = _uidoc_.Selection.PickObjects(ObjectType.Element, message)
return [_doc_.GetElement(r) for r in refs]
ref = _uidoc_.Selection.PickObject(ObjectType.Element, message)
return [_doc_.GetElement(ref)]
except Exception:
return []
# ---------------------------------------------------------------- parameters
def _find_param(el, name):
"""Resolve a parameter by BuiltInParameter, exact name, then case-insensitive."""
if isinstance(name, BuiltInParameter):
return el.get_Parameter(name)
prm = el.LookupParameter(str(name))
if prm is not None:
return prm
lowered = str(name).lower()
for candidate in el.Parameters:
if candidate.Definition.Name.lower() == lowered:
return candidate
return None
def p(el, name, default=None):
"""Read a parameter value, typed. Falls back to `default` if missing."""
prm = _find_param(el, name)
if prm is None:
return default
st = prm.StorageType
if st == StorageType.String:
return prm.AsString()
if st == StorageType.Integer:
return prm.AsInteger()
if st == StorageType.Double:
return prm.AsDouble()
if st == StorageType.ElementId:
return prm.AsElementId()
return default
def p_display(el, name, default=None):
"""Read a parameter as the string Revit shows in the UI (with units)."""
prm = _find_param(el, name)
if prm is None:
return default
val = prm.AsValueString()
return val if val is not None else prm.AsString()
def set_p(el, name, value):
"""Write a parameter. Must be called inside a transaction.
Returns True on success, False if the parameter is missing or read-only.
"""
prm = _find_param(el, name)
if prm is None or prm.IsReadOnly:
return False
return bool(prm.Set(value))
# -------------------------------------------------------------- transactions
@contextmanager
def tx(name='vibe'):
"""Transaction context manager - commits on success, rolls back on error.
with tx('Rename walls'):
set_p(wall, 'Comments', 'checked')
"""
t = Transaction(_doc(), name)
t.Start()
try:
yield t
t.Commit()
except Exception:
if t.HasStarted() and not t.HasEnded():
t.RollBack()
raise
@contextmanager
def txg(name='vibe group'):
"""Transaction group - lets you undo several transactions as one step."""
g = TransactionGroup(_doc(), name)
g.Start()
try:
yield g
g.Assimilate()
except Exception:
if g.HasStarted() and not g.HasEnded():
g.RollBack()
raise
# --------------------------------------------------------------------- units
# Revit stores lengths in decimal FEET internally, always.
def ft(millimeters):
"""mm -> internal feet."""
return UnitUtils.ConvertToInternalUnits(millimeters, _MILLIMETERS)
def mm(internal_feet):
"""internal feet -> mm."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _MILLIMETERS)
def m(internal_feet):
"""internal feet -> meters."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _METERS)
def sqft_to_sqm(internal_area):
"""internal square feet -> square meters."""
return UnitUtils.ConvertFromInternalUnits(internal_area, _SQUARE_METERS)
# -------------------------------------------------------------------- output
def out():
"""The pyRevit output window for the running script."""
return script.get_output()
def log(*args):
"""print() that always lands in the pyRevit output window."""
print(' '.join(str(a) for a in args))
def table(rows, columns=None, title=None):
"""Render a list of lists as a table in the pyRevit output window."""
output = script.get_output()
if title:
output.print_md('### ' + title)
output.print_table(table_data=[[str(c) for c in r] for r in rows],
columns=columns or [])
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""Which job's knowledge layer a HOST-SIDE tool should read, with no doc open.
Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by
`doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for
a model with no path yet. That lookup needs a live document and cannot be
ported here: a terminal running `tools/snapshot_cycle.py` or
`tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and
guessing one from several candidates is a worse failure than asking, because a
wrong guess runs silently on the wrong job's settings and looks completely
normal -- the exact failure mode this whole codebase is built against
(see CLAUDE.md's "could not judge" discipline).
THE RESOLUTION ORDER, deliberately, and in this order only:
1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a
FILE) wins outright. The caller already knows which file; nothing
here second-guesses it, and a bad path is reported the ordinary way a
missing file is reported (by whoever tries to read it), not specially
here.
2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one
that has a `knowledge.yaml` in it) -- unambiguous, so it is used with
no further input.
3. a NAMED job (`--job "sample_job"`) picks one out of several by
name.
4. otherwise: REFUSE, and name every candidate folder found. This is the
correct behaviour when several real job folders exist and none was
named -- not a hole to fill in later. Silently picking "the newest
one" or "the one that happens to set the key being asked for" is a
guess wearing the clothes of a rule, and the day it guesses wrong the
run looks completely normal while reading another job's tolerances.
This is intentionally the only place this order is written down. Two tools
reading job knowledge off disk had two different half-answers to "where is
it" before this module existed -- `tools/snapshot_cycle.py` required an
explicit `--knowledge` and reported the resulting absence as if the setting
itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded
to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now.
`plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen
by an env var that may name either a file or its folder. This is a different
shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is
no `doc.Title` here to break the tie) so it earns its own module rather than
being bent to fit plantpath's shape, but the discipline is the same one:
never fall back to a plausible-looking wrong answer in silence, and say
which of "not configured", "not found" and "found but ambiguous" happened,
because they are different facts and read as different sentences.
IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at
module scope -- or at all -- so it is also safe to load from host CPython the
same way `plantpath.py` is: `tools/snapshot_cycle.py` and
`tools/knowledge_drift.py` both do, via the `vibe` package stub documented in
`WillTools.extension/tests/conftest.py`.)
"""
# pylint: disable=invalid-name
import os
JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV
KNOWLEDGE_FILENAME = 'knowledge.yaml'
class JobLocateResult(object):
"""What `resolve_job_knowledge()` found, or precisely why it did not.
`path` is set only on success -- callers should treat `bool(result)` (or
`result.path`) as the found/not-found test. `blocking` marks the
not-found cases that are a genuine misconfiguration worth stopping a run
over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a
WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary
"nothing configured yet" case, which a caller may reasonably continue
past on DEFAULTS alone -- exactly as it always could when no `--knowledge`
was given.
"""
__slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking')
def __init__(self, path=None, how=None, reason=None, candidates=None,
root=None, blocking=False):
self.path = path
self.how = how
self.reason = reason
self.candidates = list(candidates or [])
self.root = root
self.blocking = bool(blocking)
def __bool__(self):
return self.path is not None
__nonzero__ = __bool__ # IronPython 2.7
def __repr__(self):
if self.path:
return 'JobLocateResult(path={0!r}, how={1!r})'.format(
self.path, self.how)
return 'JobLocateResult(reason={0!r})'.format(self.reason)
def job_candidates(jobs_root):
"""Every subfolder of `jobs_root` that carries a `knowledge.yaml`.
Sorted, so a refusal that lists them is stable across runs and a "single
unambiguous" check does not depend on directory-walk order. Nothing here
tries to tell a "real" job folder from a stray one (a leftover detach
copy, an old bootstrap) -- that would be exactly the judgement call this
module exists to refuse to make silently. Every folder with a
`knowledge.yaml` in it is a candidate; a human names the right one.
"""
out = []
if not jobs_root or not os.path.isdir(jobs_root):
return out
for name in sorted(os.listdir(jobs_root)):
folder = os.path.join(jobs_root, name)
if not os.path.isdir(folder):
continue
candidate = os.path.join(folder, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
out.append(candidate)
return out
def _job_name_of(knowledge_path):
return os.path.basename(os.path.dirname(knowledge_path))
def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None,
env=None):
"""Which job knowledge.yaml a host-side tool should read. A JobLocateResult.
explicit a path stated outright -- a `--knowledge` flag, or an env var
that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as
`tools/snapshot_cycle.py`'s argparse default already reads).
Wins over everything below. Not checked for existence here:
whoever reads the file reports a missing one the ordinary
way, and duplicating that check here would be a second,
possibly-drifted answer to "does this file exist".
jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None`
to have it read from `env` here instead.
job_name a folder name under `jobs_root`, stated explicitly (a `--job`
flag) to pick one out of several.
env `os.environ` by default; overridable so tests never touch the
real environment.
"""
env = os.environ if env is None else env
if explicit:
return JobLocateResult(path=explicit, how='explicit')
root = jobs_root if jobs_root is not None else env.get(JOBS_ENV)
if not root:
return JobLocateResult(
reason='no job knowledge file was named (--knowledge) and {0} '
'is not set, so there is no folder to look for a job '
'under.'.format(JOBS_ENV))
if not os.path.isdir(root):
return JobLocateResult(
root=root, blocking=True,
reason='{0} is set to {1}, but that is not a folder.'
.format(JOBS_ENV, root))
if job_name:
candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
return JobLocateResult(path=candidate, how='named', root=root)
candidates = job_candidates(root)
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='no job named {0!r} under {1} (looked for {2}). Job '
'folders found there: {3}'
.format(job_name, root, candidate,
', '.join(names) or '(none)'))
candidates = job_candidates(root)
if len(candidates) == 1:
return JobLocateResult(path=candidates[0], how='single', root=root)
if not candidates:
return JobLocateResult(
root=root,
reason='{0} is set to {1}, but no job folder under it has a '
'{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME))
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='{0} is {1}, which holds {2} job folders ({3}) -- there is '
'no doc.Title here to pick one by, so nothing here guesses. '
'Say which with --job, or point --knowledge at the file '
'directly.'.format(JOBS_ENV, root, len(candidates),
', '.join(names)))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""The Revit half of the job-level reconciliation: gather, then hand over.
`vibe.jobrecon` holds the reconciliation itself and knows nothing about
Revit, so it can be tested on host CPython. This module is the thin gatherer
that turns a live document into the plain dicts that module eats -- the same
split `ticketpage.py` / `ticketchecks.py` uses, for the same reason: the
arithmetic is where a wrong answer hides, and arithmetic must be testable
without a licence.
NO OFFICE PARAMETER NAME APPEARS IN THIS FILE. Everything is read by ROLE
through `vibe.knowledge`, and a role nobody has mapped reads as a REFUSAL,
not as an empty job. That distinction is the whole point of this tool: a
reconciliation that cannot see the control numbers must say so, because
"found nothing" and "looked at nothing" are the same output otherwise, and
this project has already shipped that bug once.
Roles this module needs, none of which the older checks used:
member.control_number the piece's control number -- ONE PHYSICAL
PIECE. The unit of the whole reconciliation.
sheet.control_numbers the title-block slots holding the control
numbers a ticket covers. A list of parameter
names, or leave it unset and give
`nomenclature.control_slot_re` instead, a
regex matched against sheet parameter names --
which is what a plant with numbered slots
(..._01 .. _30) wants.
sheet.total_required the quantity the title block states.
sheet.sheet_count how many pages the ticket says it has.
member.ticket_created the model's own claim that a ticket exists.
member.ticket_detailed ... and that it was detailed. Both optional.
Already-mapped roles it reuses: `member.mark`, `panel.design_number`,
`sheet.design_number`.
Cost, measured on a 2480-piece job: the piece sweep is about 6 s across six
categories and the sheet sweep about 1 s across 1105 sheets -- no geometry is
built anywhere, which is what keeps it in seconds rather than the 10 s a
690-piece geometry pass costs.
IronPython 2.7: no f-strings, no pathlib, no typing.
"""
# pylint: disable=import-error,invalid-name,broad-except
import re
import time
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, AssemblyInstance, ViewSheet,
)
from vibe import jobrecon
# Where precast pieces can live. Not a filter on what counts as a piece --
# a piece is anything carrying a control number -- but the set of collectors
# the sweep opens. Every category here is reported with its tally, INCLUDING
# the zeros, so a reader can see what was looked at; categories outside this
# list are named in the result's blind spots rather than silently omitted.
#
# Never widen this to an unfiltered collector. A model-wide
# WhereElementIsNotElementType() walk killed Revit here on 2026-08-18.
PIECE_CATEGORIES = (
'OST_StructuralFraming',
'OST_StructuralColumns',
'OST_Walls',
'OST_Floors',
'OST_StructuralFoundation',
'OST_GenericModel',
)
# WHAT WAS LOOKED AT IS NOT A BLIND SPOT. `gather` returns ONE list of notes
# -- everything it wants said about the run -- and two different kinds live in
# it: 'I could not see X', which is a blind spot, and 'I opened these six
# category collectors and here is the tally', which is an inventory. The
# report folds the second and opens the first, so `run` has to tell them
# apart, and these two constants are how: the inventory notes are written
# with them three lines below and recognised by them in `is_inventory_note`.
#
# `gather`'s signature is deliberately NOT changed to return them separately.
# `vibe.takeoffgather` calls `gather` too and collects the same notes; a
# different tuple there would be a change to another button's path for the
# sake of this one's formatting.
INVENTORY_LEAD = 'categories searched:'
INVENTORY_INDENT = ' '
def is_inventory_note(note):
"""True for a note that is an INVENTORY of what was swept, not a blind
spot. See the constants above."""
text = str(note or '')
return text.startswith(INVENTORY_LEAD) or text.startswith(INVENTORY_INDENT)
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _param_text(param):
"""A parameter as text, whatever it is stored as."""
if param is None:
return None
try:
value = param.AsString()
except Exception:
value = None
if value in (None, ''):
try:
value = param.AsValueString()
except Exception:
value = None
return value
def _read(el, names):
"""First non-empty value across candidate parameter names."""
for name in names or ():
value = _param_text(el.LookupParameter(name))
if value not in (None, ''):
return value
return None
def _roles(know, overrides=None, _doc_for_knowledge=None):
"""Resolve every role this module reads, once.
`overrides` -- {role: name or [names]}, for a caller that knows the
template but whose knowledge file has not been taught these roles yet.
Explicit and visible beats a hardcoded office name in the repo.
"""
from vibe import knowledge as K
from vibe.compat import string_types
overrides = overrides or {}
# `know=None` used to mean "nothing is mapped", which made a caller who
# simply forgot the argument indistinguishable from an unconfigured
# plant: every role resolved empty and the whole reconciliation refused.
# It refused LOUDLY, which is the only reason this was survivable. Every
# other module in vibe loads knowledge itself when it is handed none, and
# so does this one now. A genuine refusal still happens -- it just has to
# be because the file really does not map the role.
if know is None and _doc_for_knowledge is not None:
try:
know = K.load(_doc_for_knowledge)
except Exception:
know = None
out = {}
for role in ('member.control_number', 'member.mark', 'member.ticket_created',
'member.ticket_detailed', 'panel.design_number',
'sheet.design_number', 'sheet.control_numbers',
'sheet.total_required', 'sheet.sheet_count'):
if role in overrides:
value = overrides[role]
out[role] = ([value] if isinstance(value, string_types)
else list(value))
elif know is not None:
out[role] = K.param_names(know, role)
else:
out[role] = []
slot_re = overrides.get('control_slot_re')
if slot_re is None and know is not None:
slot_re = K.get(know, 'nomenclature.control_slot_re', None)
out['control_slot_re'] = slot_re
return out
def gather(doc, know=None, overrides=None, categories=None):
"""Read the document. Returns (pieces, sheets, notes, timing).
`notes` collects everything the gather itself could not do -- an unmapped
role, a category that could not be collected. It becomes part of the
reconciliation's blind spots; it is never swallowed.
"""
roles = _roles(know, overrides, _doc_for_knowledge=doc)
notes = []
t0 = time.time()
ctrl_names = roles['member.control_number']
if not ctrl_names:
notes.append('the role member.control_number is not mapped -- no '
'piece could be identified, so NOTHING was reconciled. '
'Map it in the plant knowledge file.')
slot_names = roles['sheet.control_numbers']
slot_re = roles['control_slot_re']
if not slot_names and not slot_re:
notes.append('neither the role sheet.control_numbers nor '
'nomenclature.control_slot_re is set -- no ticket could '
'be read, so every piece will read as unticketed. This '
'is a refusal, not a finding.')
# ---- which elements are already inside an assembly ------------------
in_assembly = {}
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
name = asm.AssemblyTypeName
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if member is not None:
in_assembly[_eid(member)] = name
# ---- the pieces -----------------------------------------------------
pieces = []
per_category = {}
for cat_name in (categories or PIECE_CATEGORIES):
bic = getattr(BuiltInCategory, cat_name, None)
if bic is None:
notes.append('this Revit build has no category {0}; it was not '
'searched'.format(cat_name))
continue
try:
elements = (FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception as exc:
notes.append('could not collect {0}: {1}'.format(cat_name, exc))
continue
found = 0
for el in elements:
control = _read(el, ctrl_names)
if control in (None, ''):
continue
found += 1
family = None
try:
family = el.Symbol.Family.Name
except Exception:
try:
family = el.Name
except Exception:
family = None
pieces.append({
'id': _eid(el),
'category': cat_name,
'control': control,
'mark': _read(el, roles['member.mark']),
'design': _read(el, roles['panel.design_number']),
'family': family,
'in_assembly': in_assembly.get(_eid(el)),
'ticket_created': _read(el, roles['member.ticket_created']),
'ticket_detailed': _read(el, roles['member.ticket_detailed']),
})
per_category[cat_name] = {'elements': len(elements), 'pieces': found}
t1 = time.time()
# ---- the sheets -----------------------------------------------------
#
# One pass over each sheet's own Parameters, not thirty LookupParameter
# calls per sheet: with numbered slots that is the difference between
# about a second and about ten across a 1105-sheet job, and the slots are
# discovered rather than assumed to be contiguous.
slot_pattern = re.compile(slot_re, re.IGNORECASE) if slot_re else None
sheets = []
for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements():
slots = {}
if slot_pattern is not None:
for param in sheet.Parameters:
try:
name = param.Definition.Name
except Exception:
continue
if slot_pattern.match(name):
slots[name] = _param_text(param)
controls = [slots[k] for k in sorted(slots)]
for name in slot_names:
controls.append(_param_text(sheet.LookupParameter(name)))
sheets.append({
'id': _eid(sheet),
'number': sheet.SheetNumber,
'name': sheet.Name,
'design': _read(sheet, roles['sheet.design_number']),
'controls': controls,
'total_required': _read(sheet, roles['sheet.total_required']),
'sheet_count': _read(sheet, roles['sheet.sheet_count']),
})
t2 = time.time()
searched = set(categories or PIECE_CATEGORIES)
notes.append(INVENTORY_LEAD + ' {0}. A precast piece modelled in any '
'other category was not seen by this run.'
.format(', '.join(sorted(searched))))
for cat_name in sorted(per_category):
row = per_category[cat_name]
notes.append(INVENTORY_INDENT +
'{0}: {1} element(s), {2} carrying a control number'
.format(cat_name, row['elements'], row['pieces']))
timing = {'pieces_s': round(t1 - t0, 2), 'sheets_s': round(t2 - t1, 2),
'total_s': round(t2 - t0, 2)}
return pieces, sheets, notes, timing
def _numeric_sort(value):
"""Sort control numbers the way a person reads them: 9 before 10."""
text = str(value)
parts = re.split(r'(\d+)', text)
key = []
for part in parts:
if part.isdigit():
key.append((1, int(part), ''))
elif part:
key.append((0, 0, part))
return key
def run(doc, know=None, overrides=None, categories=None, scope=None):
"""Gather and reconcile. Returns the jobrecon result, plus timings.
The DECLARED SCOPE is read from the job knowledge here and handed to
`reconcile`, which sets aside what is outside it and reports that. The
gather itself is deliberately unscoped: it reads the whole job, so the
count of what was left alone is a measured number rather than an
estimate of what was never looked at.
"""
pieces, sheets, notes, timing = gather(doc, know=know,
overrides=overrides,
categories=categories)
if scope is None:
from vibe import scope as scopelib
if know is None:
from vibe import knowledge as K
know = K.load(doc)
scope = scopelib.of(know)
recon = jobrecon.reconcile(pieces, sheets, sort_key=_numeric_sort,
scope=scope)
# Split, not merged: what the sweep opened folds away as routine, what it
# could not see opens itself. Both are printed either way.
recon['blind_spots'] = ([n for n in notes if not is_inventory_note(n)]
+ recon['blind_spots'])
recon['searched'] = [n for n in notes if is_inventory_note(n)]
recon['timing'] = timing
return recon
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
"""The Revit half of lateral extraction. READ-ONLY. No Transaction, ever.
This is the `ticketchecks.py` of the pair: it imports the Revit API at module
scope and therefore cannot be unit tested, which is exactly why it does as
little thinking as possible. It reads parameters and geometry, hands plain
dicts to `vibe.latmodel`, and makes no judgement of its own beyond "the
model did not tell me".
WHAT WAS MEASURED IN THE LIVE MODEL, 2026-08-30
-----------------------------------------------
Two findings shaped this file, and both are the reason it refuses things
rather than inventing them.
1. ZERO elements carry the lateral marker today. Every string parameter of
every structural framing, wall, foundation and floor element, plus 1200
generic models, was read: nothing anywhere contains 'lateral' or
'resist'. The marker exists as a convention and has not been applied yet.
So the first honest run of this tool reports NOTHING MARKED, and that
must not look like an answer.
PARAMETER NAMES ARE NOT IN THIS FILE
------------------------------------
Not one Revit parameter is named here by its literal name. Every one is
reached through a ROLE resolved against the plant's knowledge file --
`member.identity_description` for the field the marker is typed into,
`member.mark` for the piece mark. That is not tidiness: a parameter name IS
this office's template, and a name written into shareable code means the
next plant cannot be served without editing code.
An unmapped role therefore makes this stand DOWN, with a sentence naming
the role. It never falls back to a name that happens to work here -- a
fallback would work on this model and silently find nothing on the next
one, which is precisely the failure this codebase is built against. The
only exception is Revit's own built-in Mark, used purely to label a row in
the report; it is a Revit concept, not a plant's, and nothing depends on it.
2. Every structural framing element in that model has a LocationPoint, not
a LocationCurve. They are point-placed precast families. A member axis
therefore cannot be read the usual way, and the bounding box is not a
substitute: a column whose COLUMN_LENGTH parameter reads 90'-5" has a
bounding box 140 ft tall, because the family carries geometry beyond the
member. Deriving an axis from that box would produce coordinates that
look completely reasonable and are wrong by 50 feet.
So point-placed elements are SKIPPED with that reason, unless a plant
explicitly opts in via `lateral.point_member_axis`. The default is None
-- refuse -- for the same reason `rails.left_rail_is_fixed` defaults to
None: a wrong default here is invisible.
IronPython 2.7.
"""
# pylint: disable=import-error,invalid-name,broad-except
from Autodesk.Revit.DB import (FilteredElementCollector, BuiltInCategory,
BuiltInParameter, ElementId, StorageType,
LocationCurve, LocationPoint, Line)
from vibe import knowledge as K
from vibe import latmodel
# The role the marker field is reached through. The NAME behind it lives in
# the plant's knowledge file and deliberately not here.
DESC_ROLE = 'member.identity_description'
MARK_ROLE = 'member.mark'
# Scanned when the user has selected nothing. Never unscoped -- an
# unfiltered collector took Revit down on 2026-08-18.
DEFAULT_CATEGORIES = [
'OST_StructuralFraming', 'OST_StructuralColumns', 'OST_Walls',
'OST_StructuralFoundation', 'OST_GenericModel',
]
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _sparam(el, name):
try:
pr = el.LookupParameter(name)
if pr is None or pr.StorageType != StorageType.String:
return None, False
v = pr.AsString()
return (v.strip() if v else None), True
except Exception:
return None, False
def read_description(el, param_names):
"""(text, source_param, any_such_param_exists).
`param_names` comes from the `member.identity_description` role. This
function never invents a name of its own.
The third value is the one that matters. If NO mapped parameter exists
on any element, the marker cannot have been written anywhere, and that
is 'could not judge' -- not 'nothing is marked'.
"""
exists = False
for name in param_names:
v, present = _sparam(el, name)
exists = exists or present
if v:
return v, name, True
return None, None, exists
def _mark(el, know):
"""The piece mark, for labelling report rows only.
The plant's own mark parameter first, through its role. Revit's built-in
Mark second -- that is a Revit concept rather than an office one, it is
the sanctioned general fallback (see knowledge.OPTIONAL_ROLES), and
nothing but a table cell depends on it.
"""
for name in K.param_names(know, MARK_ROLE):
v, _ = _sparam(el, name)
if v:
return v
try:
pr = el.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)
if pr is not None:
v = pr.AsString()
if v and v.strip():
return v.strip()
except Exception:
pass
return None
def _category(el):
try:
return el.Category.Name if el.Category else None
except Exception:
return None
def _type_name(el):
"""The Revit type name -- a section LABEL, never section properties.
`.Name` on a Revit type is ambiguous under IronPython on some bindings
(it came back empty for every element in the 2026-08-30 measurement
pass), so the built-in type parameter is tried first and reflection
second. Both are Revit's own, not this office's.
"""
try:
pr = el.get_Parameter(BuiltInParameter.ELEM_TYPE_PARAM)
if pr is not None:
v = pr.AsValueString()
if v and v.strip():
return v.strip()
except Exception:
pass
for getter in (lambda: el.Symbol.Name,
lambda: el.WallType.Name,
lambda: el.Name):
try:
v = getter()
if v and v.strip():
return v.strip()
except Exception:
continue
return None
def _material(el):
try:
pr = el.get_Parameter(BuiltInParameter.STRUCTURAL_MATERIAL_PARAM)
if pr is None:
return None
mid = pr.AsElementId()
if mid is None or mid == ElementId.InvalidElementId:
return None
me = el.Document.GetElement(mid)
if me is None:
return None
return me.Name
except Exception:
return None
def _pt(xyz):
return (xyz.X, xyz.Y, xyz.Z)
def axis_of(el, know):
"""((i, j), source, None) or (None, None, why_not). Feet. Read-only.
Feet because that is Revit's internal unit and nothing here converts.
The tuple carries no unit of its own; `latmodel.LENGTH_UNIT` is the one
place the unit is stated, and it is stated once.
"""
try:
loc = el.Location
except Exception:
loc = None
if isinstance(loc, LocationCurve):
try:
crv = loc.Curve
except Exception:
return None, None, 'the location curve could not be read'
if not isinstance(crv, Line):
return (None, None,
'the location curve is a {0}, not a straight line. A '
'curved member is not a single analysis member and is '
'not split into one here.'.format(type(crv).__name__))
try:
return ((_pt(crv.GetEndPoint(0)), _pt(crv.GetEndPoint(1))),
'location_curve', None)
except Exception as ex:
return None, None, 'end points unreadable: {0}'.format(ex)
if isinstance(loc, LocationPoint):
mode = K.get(know, 'lateral.point_member_axis', None)
if not mode:
return (None, None,
'placed as a point-based family, so it has no location '
'curve and no member axis. Its bounding box is not used: '
'measured on this model, a 90 ft column has a 140 ft '
'bounding box because the family carries geometry beyond '
'the member, so an axis taken from it would be wrong by '
'tens of feet while looking entirely plausible. Set '
'lateral.point_member_axis in knowledge.yaml to opt in.')
if mode != 'bounding_box_longest':
return (None, None,
'lateral.point_member_axis is set to "{0}", which this '
'version does not implement.'.format(mode))
try:
bb = el.get_BoundingBox(None)
except Exception:
bb = None
if bb is None:
return None, None, 'point-based, and it has no bounding box either'
lo, hi = _pt(bb.Min), _pt(bb.Max)
spans = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
k = spans.index(max(spans))
mid = [(lo[n] + hi[n]) / 2.0 for n in range(3)]
a, b = list(mid), list(mid)
a[k], b[k] = lo[k], hi[k]
return (tuple(a), tuple(b)), 'bounding_box', None
return (None, None,
'no Location of a kind that carries an axis (got {0})'
.format(type(loc).__name__ if loc is not None else 'None'))
def _wall_skip_reason(el):
"""Why a marked wall is not turned into a line member.
A shear wall is a panel. RISA models it as a wall panel or a mesh of
plates, and this tool emits no plates at all -- so turning the wall's
base curve into a horizontal line member would put a beam where the
engineer put a wall. The geometry is recorded in the skip row so that
nothing is lost, but no member is invented.
"""
bits = []
try:
loc = el.Location
if isinstance(loc, LocationCurve):
c = loc.Curve
a, b = _pt(c.GetEndPoint(0)), _pt(c.GetEndPoint(1))
bits.append('base line ({0:.3f},{1:.3f},{2:.3f}) to '
'({3:.3f},{4:.3f},{5:.3f}) ft'
.format(a[0], a[1], a[2], b[0], b[1], b[2]))
except Exception:
pass
try:
pr = el.get_Parameter(BuiltInParameter.WALL_USER_HEIGHT_PARAM)
if pr is not None:
bits.append('height {0:.3f} ft'.format(pr.AsDouble()))
except Exception:
pass
geom = '; '.join(bits) or 'geometry not readable'
return ('a wall is a shear PANEL, not a line member. This tool emits no '
'plates or wall panels, and will not put a beam where the '
'engineer put a wall. Recorded geometry: ' + geom)
def candidates(doc, selection=None, know=None):
"""The elements to inspect. Always scoped -- never a bare collector."""
if selection:
return list(selection)
cats = K.get(know, 'lateral.categories', None) or DEFAULT_CATEGORIES
out = []
for cn in cats:
bic = getattr(BuiltInCategory, cn, None)
if bic is None:
continue
try:
out.extend(FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception:
continue
return out
NOT_RUN_UNMAPPED = 'role_unmapped'
NOT_RUN_ABSENT = 'parameter_absent'
def scan(doc, selection=None, know=None):
"""Inspect candidates for the marker. Returns a dict, or (None, reason).
A tuple `(None, reason)` means COULD NOT JUDGE, and there are exactly
two ways to get there:
NOT_RUN_UNMAPPED the `member.identity_description` role has no
parameter name behind it, so this does not know
where the marker would be written.
NOT_RUN_ABSENT the role is mapped, but no candidate element
actually carries that parameter.
In both, the absence of marked members says nothing at all. A returned
dict with empty `marked` means the opposite: the question was asked
properly and the answer is that nothing is marked. The button reports
the two differently, which is the whole reason this is not just an
empty result.
"""
know = know or {}
params = K.param_names(know, DESC_ROLE)
if not params:
return None, NOT_RUN_UNMAPPED
tokens = K.get(know, 'lateral.marker_tokens', None) \
or latmodel.DEFAULT_MARKER_TOKENS
els = candidates(doc, selection=selection, know=know)
any_field = False
marked, ambiguous, unmarked = [], [], 0
for el in els:
text, source, present = read_description(el, params)
any_field = any_field or present
verdict, token = latmodel.match_marker(text, tokens)
if verdict == latmodel.MATCH_NO:
unmarked += 1
continue
row = {'element': el, 'element_id': _eid(el), 'desc': text,
'desc_param': source, 'token': token,
'category': _category(el), 'mark': _mark(el, know)}
(marked if verdict == latmodel.MATCH_YES else ambiguous).append(row)
if not any_field:
return None, NOT_RUN_ABSENT
return {'params_tried': params, 'tokens': tokens,
'candidates': len(els), 'marked': marked,
'ambiguous': ambiguous, 'unmarked': unmarked}, None
def gather(doc, selection=None, know=None):
"""(model, scan, None) -- or (None, None, reason) when it could not judge.
Read-only from start to finish. No Transaction is opened anywhere in
this module and none should ever be: the production model this runs on
is somebody's live work.
"""
found, why = scan(doc, selection=selection, know=know)
if found is None:
return None, None, why
raws = []
for row in found['marked']:
el = row['element']
cat = row['category'] or ''
if 'Wall' in cat:
raws.append({'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'],
'token': row['token'], 'i': None, 'j': None,
'axis_source': None, 'section': None,
'material': _material(el),
'note': _wall_skip_reason(el)})
continue
ends, source, why = axis_of(el, know)
tname = _type_name(el)
raws.append({
'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'], 'token': row['token'],
'i': ends[0] if ends else None,
'j': ends[1] if ends else None,
'axis_source': source,
# A type NAME, flagged unverified. See latmodel.build for why no
# dimension from GetStructuralSection() is trusted on this model.
'section': ({'name': tname, 'source': 'revit_type_name',
'verified': False} if tname else None),
'material': _material(el),
'note': why,
})
tol = K.get(know, 'lateral.node_tolerance_ft', None)
model = latmodel.build(raws, node_tol_ft=tol, model_title=doc.Title)
# latmodel.build only knows "no axis". The REASON lives out here, where
# the Revit object was, so put it back on the skip row -- a skip without
# a reason is the thing this codebase keeps refusing to ship.
by_id = {}
for r in raws:
if r.get('note'):
by_id[r['element_id']] = r['note']
for s in model['skipped']:
reason = by_id.get(s.get('element_id'))
if reason:
s['reason'] = reason
return model, found, None
+414
View File
@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""The Revit-free half of lateral extraction: marker, model, node graph.
This file is the `ticketpage.py` of the lateral tool -- no Revit import at
module scope, no I/O, no `doc`. `vibe.latextract` is the `ticketchecks.py`
side: it walks the model and hands raw dicts in here. `vibe.latrisa` turns
what comes out into files.
WHY THE SHAPE IS THIS CAREFUL
-----------------------------
Every other tool in this repository produces something a human reads and
judges. This one produces an input file for structural analysis software.
The failure mode is therefore new: a file that OPENS and is subtly wrong is
much worse than no file at all, because the wrongness arrives wearing the
authority of a finished model.
So the rule throughout: **nothing this cannot determine is ever defaulted.**
A member whose section could not be read does not get a section -- it gets
`section = None`, its label goes in `member['unresolved']`, and the report
names it. The RISA draft writes an obviously-bogus shape label for it so
that RISA refuses the member rather than analysing a guess.
And the standing distinction this codebase already draws (see the NOT RUN
discussion in `ticketchecks.py`): "could not judge" is not "judged and found
nothing". `collect()` returns `None` for a run that could not happen at all
-- no marker configured, nothing selectable -- and an empty model for a run
that happened and found nothing marked. The button reports those two
differently, on purpose.
IronPython 2.7: no f-strings, no dataclasses, no typing syntax.
"""
# pylint: disable=invalid-name,broad-except
import math
import re
# --------------------------------------------------------------- the marker
#
# The user marks lateral-resisting members BY HAND, by typing into a
# free-text identity field. The owner's own spelling is "lateral-resistent";
# the conventional English is "lateral-resistant". Both are accepted, because
# the field is typed by a person and a tool that rejects the spelling its own
# owner uses is a tool nobody marks anything for.
#
# These are the DEFAULTS. The live values come from the plant layer under
# `lateral.marker_tokens` (declared in knowledge.DEFAULTS) so a plant that
# writes something else -- "LFRS", "seismic" -- changes a YAML line rather
# than this file.
DEFAULT_MARKER_TOKENS = ['lateral-resistent', 'lateral-resistant']
# Words that, immediately in front of the marker, invert it. "NOT
# LATERAL-RESISTENT" is a phrase a detailer plausibly types, and a plain
# substring match would read it as a mark. We neither include it nor drop it
# silently: it comes back as AMBIGUOUS and the report names the element and
# quotes the text, for a human to settle. Guessing either way here is the
# kind of quiet error this whole file exists to avoid.
NEGATORS = ('not', 'non', 'no', 'never', 'excl', 'exclude', 'excluded')
MATCH_NO = 'no'
MATCH_YES = 'yes'
MATCH_AMBIGUOUS = 'ambiguous'
_NONWORD = re.compile(r'[^a-z0-9]+')
def normalise(text):
"""Lowercase; every run of non-alphanumerics becomes one space.
This is what makes the match tolerant of how the token was typed:
'Lateral-Resistent', 'LATERAL RESISTANT', 'lateral_resistent' and
'lateral resistent' all normalise to the same thing.
It deliberately does NOT remove the separator entirely, so the run-together
'lateralresistent' does not match. That is a judgement call: accepting it
would mean 'lateral' followed by anything starting with 'resistent' also
matches, and the tool cannot tell a typo from a different phrase. An
unmatched element is visible in the report as "not marked"; a wrongly
matched one is invisible.
"""
if text is None:
return ''
try:
low = text.lower()
except Exception:
low = str(text).lower()
return _NONWORD.sub(' ', low).strip()
def match_marker(text, tokens=None):
"""(verdict, matched_token_or_None) for one free-text field.
verdict is MATCH_YES / MATCH_NO / MATCH_AMBIGUOUS.
ACCEPTS (verdict YES):
'lateral-resistent' exactly, any case
'lateral-resistant' the conventional spelling
'LATERAL RESISTENT' any separator run: - _ space . /
'shear wall, lateral-resistent' surrounded by other text
'lateral-resistent (see S2.1)' trailing text
REJECTS (verdict NO):
'lateral' on its own -- half a token
'resistant' on its own
'lateralresistent' no separator at all (see normalise)
'laterally resistant' 'laterally' is not 'lateral'
'' / None an empty field is not a mark
FLAGS (verdict AMBIGUOUS -- never counted either way, always reported):
'NOT lateral-resistent'
'non lateral resistant'
"""
hay = normalise(text)
if not hay:
return MATCH_NO, None
padded = ' ' + hay + ' '
for raw in (tokens or DEFAULT_MARKER_TOKENS):
needle = normalise(raw)
if not needle:
continue
# Whole-token match: the marker must sit on word boundaries, so
# 'laterally resistant' does not match 'lateral resistant'.
at = padded.find(' ' + needle + ' ')
if at < 0:
continue
before = padded[:at].strip().split(' ')
if before and before[-1] in NEGATORS:
return MATCH_AMBIGUOUS, raw
return MATCH_YES, raw
return MATCH_NO, None
# ------------------------------------------------------------------- units
#
# Revit's internal length unit is decimal FEET. Always. Handing inches to
# XYZ() is a 12x error that has already cost this project real time, so the
# unit travels WITH the number everywhere in this structure rather than
# being remembered. Nothing in this file converts anything: feet in, feet
# out. RISA's own default length unit is feet too, which is why the emitter
# has no conversion either -- but it states the unit in the file instead of
# relying on that.
LENGTH_UNIT = 'ft'
# How close two member endpoints must be to count as the same node.
# 1/8 inch. Not a modelling tolerance -- a "did the detailer snap these
# together" tolerance. Anything looser starts welding members that a
# structural engineer means to be separate.
DEFAULT_NODE_TOL_FT = 0.0104167 # 1/8"
# ----------------------------------------------------------- raw member in
#
# What `latextract` hands over per element. Every field may be None, and
# None means "the model did not tell us", never "zero" or "default":
#
# element_id int
# category 'OST_Walls'
# mark piece mark / control mark, or None
# desc the identity-description text the marker was found in
# token which marker spelling matched
# i, j (x, y, z) tuples in FEET, or None if no axis could be found
# axis_source 'location_curve' | 'bounding_box' | None
# section {'name':..,'source':..} or None
# material str or None
# note free text from the gatherer
def _key(pt, tol):
"""Grid key for endpoint welding, at the tolerance."""
return (int(round(pt[0] / tol)), int(round(pt[1] / tol)),
int(round(pt[2] / tol)))
def _dist(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
+ (a[2] - b[2]) ** 2)
def build(raw_members, node_tol_ft=None, model_title=None):
"""Raw gathered members -> the intermediate structure. Pure.
Returns a dict. Never raises on a bad member: a member that cannot be
turned into two nodes is moved into `skipped` with a reason, because a
silently dropped member is exactly the failure this tool must not have.
"""
tol = node_tol_ft or DEFAULT_NODE_TOL_FT
nodes = []
index = {}
members = []
skipped = []
def node_for(pt):
"""Weld to an existing node within tol, else make a new one.
Snapping is to a GRID at the tolerance, then confirmed by true
distance against the candidates in the 27 neighbouring cells -- a
plain grid alone would fail to weld two points a hair either side of
a cell boundary.
"""
k = _key(pt, tol)
best = None
best_d = None
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for dz in (-1, 0, 1):
for idx in index.get((k[0] + dx, k[1] + dy, k[2] + dz), ()):
d = _dist(pt, (nodes[idx]['x'], nodes[idx]['y'],
nodes[idx]['z']))
if d <= tol and (best_d is None or d < best_d):
best, best_d = idx, d
if best is not None:
return best
idx = len(nodes)
nodes.append({'label': 'N{0}'.format(idx + 1),
'x': pt[0], 'y': pt[1], 'z': pt[2],
'members': []})
index.setdefault(k, []).append(idx)
return idx
for n, raw in enumerate(raw_members):
i_pt, j_pt = raw.get('i'), raw.get('j')
label = 'M{0}'.format(n + 1)
if not i_pt or not j_pt:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'no member axis could be established '
'(no location curve, and no usable bounding box)'})
continue
if _dist(i_pt, j_pt) <= tol:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'the two ends are the same point to within the node '
'tolerance -- a zero-length member is not analysable'})
continue
ii, jj = node_for(i_pt), node_for(j_pt)
if ii == jj:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'both ends welded to the same node at the '
'{0} ft tolerance'.format(tol)})
continue
unresolved = []
sec = raw.get('section')
if not sec:
unresolved.append('section')
elif not sec.get('verified'):
# A Revit type NAME is not a section property. It is the thing an
# engineer maps to a RISA shape by hand, and it is worth carrying
# -- but calling it a section would be the exact silent default
# this file exists to refuse. Measured on this model 2026-08-30:
# a type named 28" x 48" reports a parameterised rectangle 28"
# wide and 20 FEET deep. The API's own section object is wrong
# here, so no dimension read from it is trusted.
unresolved.append('verified section properties '
'(only a type name was read)')
if not raw.get('material'):
unresolved.append('material')
# End releases are NEVER extracted -- see `notes` below. Listing it
# per member keeps it in front of whoever reads one member's row,
# not only in the preamble.
unresolved.append('end releases')
mem = {
'label': label,
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'desc': raw.get('desc'),
'token': raw.get('token'),
'i_node': nodes[ii]['label'],
'j_node': nodes[jj]['label'],
'length_ft': _dist(i_pt, j_pt),
'axis_source': raw.get('axis_source'),
'section': raw.get('section'),
'material': raw.get('material'),
'releases': None,
'unresolved': unresolved,
'note': raw.get('note'),
}
members.append(mem)
nodes[ii]['members'].append(label)
nodes[jj]['members'].append(label)
model = {
'schema': 'willtools.lateral/1',
'model_title': model_title,
'length_unit': LENGTH_UNIT,
'coordinate_note': 'Revit project internal coordinates, decimal feet. '
'Not shared coordinates, not a survey datum.',
'node_tol_ft': tol,
'nodes': nodes,
'members': members,
'skipped': skipped,
}
model['connectivity'] = connectivity(model)
model['notes'] = notes(model)
return model
def connectivity(model):
"""What the node graph does and does not say. Facts, not a verdict.
Two things a structural model needs that a Revit precast model does not
hand over, both reported here rather than fixed:
free_ends a node only one member reaches. Fine at a support, a
disconnected member anywhere else -- and this cannot
tell which, because supports are not extracted.
near_misses two endpoints close enough to be suspicious but further
apart than the weld tolerance. Almost always a detailer
gap the analysis model should close; NEVER closed here.
"""
free = [n['label'] for n in model['nodes'] if len(n['members']) == 1]
tol = model['node_tol_ft']
# "Suspicious" = up to 6 inches. Wide enough to catch a real gap, narrow
# enough that it is not just listing the whole model.
near = []
ns = model['nodes']
# The near-miss search is O(n^2). Above this it would freeze Revit's UI
# thread, which is a hard rule here. Skipping it is reported as skipped
# (near_miss_checked False) rather than as "no near misses found" --
# the same could-not-judge / judged-and-found-nothing distinction the
# rest of this file keeps.
checked = len(ns) <= 1200
if not checked:
return {'node_count': len(ns), 'member_count': len(model['members']),
'free_ends': free, 'near_misses': [], 'near_miss_total': None,
'near_miss_checked': False,
'interior_intersections_checked': False}
for a in range(len(ns)):
for b in range(a + 1, len(ns)):
d = _dist((ns[a]['x'], ns[a]['y'], ns[a]['z']),
(ns[b]['x'], ns[b]['y'], ns[b]['z']))
if tol < d <= 0.5:
near.append({'a': ns[a]['label'], 'b': ns[b]['label'],
'gap_ft': d})
near.sort(key=lambda r: r['gap_ft'])
return {
'node_count': len(ns),
'member_count': len(model['members']),
'free_ends': free,
'near_misses': near[:200],
'near_miss_total': len(near),
'near_miss_checked': True,
# A member end landing part-way along another member is NOT a
# connection here and is not searched for. Saying so is the point.
'interior_intersections_checked': False,
}
def notes(model):
"""The standing caveats, as sentences, carried inside the structure.
They travel with the data rather than living only in the button's report,
so that the JSON handed to somebody three months from now still says what
it does not contain.
"""
out = [
'Coordinates are Revit internal decimal FEET, in project coordinates.',
'END RELEASES ARE NOT EXTRACTED. Revit carries no reliable release '
'information for these precast families, so every member here is '
'released nowhere. Loaded as-is, an analysis program will treat every '
'joint as fully fixed, which for a precast lateral system is very '
'likely wrong. Set releases by hand.',
'SUPPORTS ARE NOT EXTRACTED. No boundary conditions are emitted at '
'all. The model as written is unrestrained and will not solve until '
'supports are added.',
'LOADS ARE NOT EXTRACTED. No load cases, combinations, diaphragms, '
'masses or seismic parameters are present.',
'Members are connected only where their END POINTS coincide within '
'{0} ft. A member ending part-way along another member is NOT '
'connected to it, and no node is inserted to make it so.'
.format(model['node_tol_ft']),
]
unres_sec = [m['label'] for m in model['members']
if any(u.startswith('section') or u.startswith('verified section')
for u in m['unresolved'])]
if unres_sec:
out.append('{0} member(s) have no verified section property. They are '
'not given one. See the unresolved list.'.format(len(unres_sec)))
unres_mat = [m['label'] for m in model['members'] if 'material' in m['unresolved']]
if unres_mat:
out.append('{0} member(s) have no material. They are not given one.'
.format(len(unres_mat)))
return out
def unresolved_rows(model):
"""One row per member per missing thing -- element by element, not counts."""
rows = []
for m in model['members']:
for what in m['unresolved']:
rows.append({'label': m['label'], 'element_id': m['element_id'],
'mark': m['mark'], 'missing': what})
return rows
def is_analysable(model):
"""False whenever loading this straight into analysis software would lie.
Never used to suppress the export -- the export is always written and
always labelled. Used to decide how loudly the report shouts.
"""
if not model or not model['members']:
return False
if any(m['unresolved'] for m in model['members']):
return False
if model['connectivity']['free_ends']:
return False
return True
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""Intermediate lateral model -> files. Revit-free, no I/O at module scope.
THREE OUTPUTS, AND WHY THE RISA ONE IS A DRAFT
----------------------------------------------
`emit_json` the intermediate structure verbatim. This is the ground
truth and the only output that claims to be complete.
`emit_csv` nodes and members as two plain tables. Any engineer can open
these, and any script can read them. No format risk at all.
`emit_risa_draft`
RISA-3D .r3d text. **DRAFT. NOT VERIFIED.**
What was established about the RISA-3D format, and from where:
RISA's own Appendix D - File Format
https://help.risa.com/risahelp/risa3d/Content/Appendices/Appendix%20D%20(3D).htm
* .r3d is plain ASCII, keyword driven, editable in Notepad.
* Data is divided into segments delimited by bracketed keyword labels:
[NODES], [MEMBERS], [PLATES], [BOUNDARY_CONDITIONS], [BASIC_LOAD_CASES],
[UNITS], [VERSION_NO], [PROGRAM_INFO], and the file opens with
[RISA-3D_INPUT_DATA].
* The record count follows the keyword label in angle brackets: `<n>`.
* Every record ends with a semicolon, except the project-description
records between [.PROJECT_DESCRIPTION] and [.END_PROJECT_DESCRIPTION].
* Labels are quoted strings. A member label may be up to 20 characters.
* A node record is "LABEL" x y z ... ;
* A member record is "LABEL" "DESIGN_LIST" "SHAPE_LABEL" iNode jNode ... ;
with later fields covering K node, rotation, section-set offset,
tension/compression, design rules, material, end releases, end offsets.
What was NOT established, and this is the reason for the word DRAFT:
The documentation designates the fields of each record by letter (a, b, c,
...) and the table that maps those letters to meanings could not be
retrieved -- the mirror that carries it returns 403, and no complete
worked example of a [NODES] or [MEMBERS] record was found in public
sources. So the field COUNT, the field ORDER, and the encoding of the
[UNITS] line are not known with confidence. Nor is which RISA version's
schema a given install expects.
Guessing them would produce a file that very plausibly opens and is
quietly wrong about which column is which -- the single worst outcome
available to this tool. So the RISA output is written with a
`.r3d.DRAFT.txt` extension. That is deliberate and is not a naming
accident: it cannot be double-clicked into RISA, and anybody who wants to
try it has to rename it, which is the moment they read the header.
To finish this properly: open any existing .r3d from this office in
Notepad, paste its [UNITS], [NODES] and [MEMBERS] segments into the repo
issue, and the emitter becomes a real one in about an hour. Until then the
CSV and the JSON are the outputs to use.
IronPython 2.7: no f-strings.
"""
# pylint: disable=invalid-name,broad-except
import json
from vibe import latmodel
# A shape label RISA cannot possibly resolve. Used wherever a section is
# unknown. The point is that RISA fails the member rather than analysing it
# with whatever the current default shape happens to be -- an unanalysable
# file beats a wrong answer.
UNRESOLVED_SHAPE = 'WILLTOOLS_UNRESOLVED_SECTION'
UNRESOLVED_MATERIAL = 'WILLTOOLS_UNRESOLVED_MATERIAL'
def emit_json(model):
"""The intermediate structure. Complete, and the only output that is."""
return json.dumps(model, indent=2, sort_keys=True)
def _csv_cell(v):
if v is None:
return ''
s = v if isinstance(v, str) else str(v)
if any(c in s for c in ',"\n'):
return '"' + s.replace('"', '""') + '"'
return s
def _csv(header, rows):
lines = [','.join(header)]
for r in rows:
lines.append(','.join([_csv_cell(c) for c in r]))
return '\n'.join(lines) + '\n'
def emit_nodes_csv(model):
"""label, x, y, z, unit, how many members meet here."""
rows = [[n['label'], '{0:.6f}'.format(n['x']), '{0:.6f}'.format(n['y']),
'{0:.6f}'.format(n['z']), model['length_unit'],
len(n['members']), ' '.join(n['members'])]
for n in model['nodes']]
return _csv(['label', 'x', 'y', 'z', 'unit', 'member_count', 'members'],
rows)
def emit_members_csv(model):
"""One row per member, with the blanks LEFT BLANK.
An empty `section` cell is the honest representation of a section this
could not read. It is never filled with a plausible-looking default, and
`unresolved` on the same row says the same thing in words so that a
reader skimming for problems finds them without comparing columns.
"""
rows = []
for m in model['members']:
sec = m.get('section') or {}
rows.append([
m['label'], m['i_node'], m['j_node'],
'{0:.6f}'.format(m['length_ft']), model['length_unit'],
sec.get('name'), sec.get('source'),
m.get('material'),
'', # end releases: never extracted
m.get('axis_source'),
m.get('category'), m.get('mark'), m.get('element_id'),
m.get('desc'), m.get('token'),
'; '.join(m.get('unresolved') or []),
])
return _csv(['label', 'i_node', 'j_node', 'length', 'unit',
'section', 'section_source', 'material', 'end_releases',
'axis_source', 'revit_category', 'mark', 'revit_element_id',
'identity_description', 'marker_matched', 'unresolved'],
rows)
DRAFT_HEADER = """\
! =====================================================================
! DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE. DO NOT LOAD AND TRUST.
! =====================================================================
!
! Written by WillTools lateral extraction from a Revit model.
!
! The section keywords, the <n> record counts, the semicolon terminators
! and the quoted labels below follow RISA's published Appendix D. The
! FIELD ORDER inside each record does NOT: Appendix D names those fields
! only by letter and the table mapping letters to meanings could not be
! obtained. The numbers below are in the right sections and are very
! likely in the wrong columns.
!
! This file is named .r3d.DRAFT.txt so that it cannot be opened into RISA
! by accident. Renaming it to .r3d is a decision, not a convenience.
!
! WHAT IS NOT IN THIS FILE, AT ALL:
! * end releases -- not extractable from these Revit families
! * boundary conditions / supports -- none. This model is unrestrained.
! * loads, load combinations, diaphragms, mass, seismic parameters
! * plates, walls as shells (walls appear as line members only)
! * member offsets, K-node orientation, unbraced lengths, design rules
!
! Use the .json and the two .csv files beside this one. They are complete
! statements of what was extracted; this file is not.
!
! Length unit: {unit} (Revit internal decimal feet, project coordinates)
! Node weld tolerance: {tol} ft
! ---------------------------------------------------------------------
"""
def emit_risa_draft(model):
"""RISA-3D-shaped text, clearly and repeatedly labelled as a draft."""
L = []
L.append(DRAFT_HEADER.format(unit=model['length_unit'],
tol=model['node_tol_ft']))
L.append('[RISA-3D_INPUT_DATA]')
L.append('')
L.append('[.PROJECT_DESCRIPTION]')
L.append('DRAFT lateral extraction -- field order unverified')
L.append('Model: {0}'.format(model.get('model_title') or '(unnamed)'))
L.append('[.END_PROJECT_DESCRIPTION]')
L.append('')
# [UNITS] is deliberately NOT emitted as data. Its field encoding is the
# part of the format least established, and a units line in the wrong
# order is the most dangerous single line the file could contain -- it
# rescales everything silently. A comment says what the numbers are in.
L.append('! [UNITS] omitted on purpose -- the encoding of this line is')
L.append('! not established, and a wrong units line rescales the whole')
L.append('! model without any visible sign. All lengths below are FEET.')
L.append('')
L.append('[NODES] <{0}>'.format(len(model['nodes'])))
for n in model['nodes']:
L.append(' "{0}" {1:.6f} {2:.6f} {3:.6f} ;'
.format(n['label'], n['x'], n['y'], n['z']))
L.append('[END_NODES]')
L.append('')
L.append('[MEMBERS] <{0}>'.format(len(model['members'])))
for m in model['members']:
sec = m.get('section') or {}
shape = sec.get('name') or UNRESOLVED_SHAPE
mat = m.get('material') or UNRESOLVED_MATERIAL
L.append(' "{0}" "" "{1}" "{2}" "{3}" "{4}" ; ! {5}'
.format(m['label'][:20], shape, mat,
m['i_node'], m['j_node'],
'unresolved: ' + ', '.join(m['unresolved'])
if m['unresolved'] else 'complete'))
L.append('[END_MEMBERS]')
L.append('')
L.append('! [BOUNDARY_CONDITIONS] intentionally absent -- no supports')
L.append('! were extracted. Nothing here is restrained.')
L.append('')
L.append('! ---- what was skipped, and why -----------------------------')
if model['skipped']:
for s in model['skipped']:
L.append('! skipped element {0} ({1}, mark {2}): {3}'
.format(s.get('element_id'), s.get('category'),
s.get('mark'), s.get('reason')))
else:
L.append('! nothing was skipped.')
L.append('')
for note in model['notes']:
L.append('! NOTE: ' + note)
return '\n'.join(L) + '\n'
def file_set(model, stem):
"""[(filename, text)] -- everything this tool writes, in one place.
The RISA file is last and its name carries the warning, so that a
directory listing sorted any way still shows the complete outputs first.
"""
return [
(stem + '-lateral.json', emit_json(model)),
(stem + '-lateral-nodes.csv', emit_nodes_csv(model)),
(stem + '-lateral-members.csv', emit_members_csv(model)),
(stem + '-lateral.r3d.DRAFT.txt', emit_risa_draft(model)),
]
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
# -*- coding: utf-8 -*-
u"""The Design Criteria section of the Panel Analysis input form.
WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three
times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like
this, where would a user even understand it), "现在这个panel analysis做的也太丑
了" (even now it is still too ugly) -- and then gave the fix in one sentence:
"全部给我做成普通人能看懂的样子", make every part of it something an ordinary
person can understand. The form is being rebuilt to look like Eriksson Wall's
own printed pages, split one Eriksson section per file so eight agents can
build it at once without stepping on each other. This file owns exactly one
of those sections: **Design Criteria**, which on Eriksson's own printout is
four blocks stacked on one page -- Project Information, Material Properties,
Load Combinations, Settings -- not four separate sections. `page()` below
returns all four, in that order, as one flat block list.
`page()` NEVER RAISES. A form button that throws mid-render leaves the owner
looking at a stack trace instead of a form, which is worse than an ugly form
-- so every lookup here is defensive, and anything this module could not lay
out is named in the `problems` list it returns instead.
THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF
renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`,
`table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as
nothing, so a typo here is a silently missing block, not an error. There is
no test for "the renderer draws this correctly" (there is no renderer to run
against on host CPython, and Revit is not running for this module to check
against) -- only for "every block this function returns is a well-formed
member of that vocabulary."
WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol`
/ `hint` for every field, rather than importing them from
`vibe.panelanalysisform`. Two reasons, both load-bearing:
1. Another agent is restructuring `panelanalysisform.FIELDS` while this file
is being written, so importing text from it ties this module's stability
to a moving target -- exactly what "the block list is stable" (tested
below) is supposed to rule out.
2. Roughly half of what Eriksson's own Design Criteria page prints has NO
key in `panelanalysisform.FIELDS` at all yet (project identity, concrete
elastic modulus, deflection-limit checkboxes, stress-limit checkboxes,
resistance-factor splits, development multipliers by strand type, ...).
Those need a label and a hint regardless of whether the backing field
exists, so authoring locally is the only option that covers both cases the
same way.
`panelanalysisform` IS still consulted, but only for one yes/no fact per
field: does this key already exist in `FIELDS`. That answer decides EXISTS
vs. MISSING for the `problems` list, per the owner's instruction that a field
whose key does not exist yet degrades to an empty editor plus a named
problem, never to a vanished block. The import is wrapped in `try/except
Exception` (not just `ImportError`) because a sibling agent editing that file
right now can leave it syntactically broken for a moment, and this module
must keep rendering through that -- "could not judge whether the key exists"
degrades to "treat it as missing", the same discipline CLAUDE.md's
`same_face()` example applies to an unrecognised abbreviation.
WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH
`columns`, NOT `table`. Eriksson prints both as grids, and the block
vocabulary's `table` kind was the first thing tried -- but a `table` cell is
only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no
way to put a live numeric/text editor inside one. Every value in those two
grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is
something the user can actually type, so they are laid out as `columns` of
`field` blocks that mirror Eriksson's rows and column groupings instead. The
Load Combinations tables, by contrast, use `table` for real: every row is a
fixed ACI 318-14 coefficient (public code text, not this job's data -- see
"psi vs. ksi" below for the same reasoning applied to a different table) and
the only per-row editable thing is the Include tick, which the `table`
vocabulary already supports natively.
PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength
keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in
ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not
perfectly consistent about it, and this file does not try to fix that, only
to read it correctly field by field). Eriksson prints everything in **ksi**.
Per the assignment: keep the psi keys as the source of truth and put the ksi
reading in the field's `symbol` or `hint` instead of introducing a second,
parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and
`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this
divided by 1000, in ksi" so the two reconcile by eye against a printed sheet;
the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`,
`strand_ep_ksi`) need no such note.
WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS.
On Eriksson's own page these are not something the user types on THIS page at
all -- they are a computed caption ("Design contains no exterior soil loads")
that reads back whatever soil-load rows exist elsewhere in the model. Nothing
in this codebase collects soil-load rows anywhere yet, so there is no field
for this section to degrade to an "empty editor" for -- there is no editor to
have. Rendering it as a static `sentence` instead of inventing a text field
that would silently claim to collect something it cannot is the same
discipline as `knowledge.same_face()` returning `None` rather than `False`:
"cannot judge, and does not pretend to." One `problems` entry names this once
for both columns, so the gap is visible without repeating the same sentence
twice.
THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a
checkbox that turns a calculation on, printed beside the sentence that
applies when it is off ("R-Value not being calculated"). Both checkbox keys
are new (`design_criteria.rvalue_calc_enabled`,
`design_criteria.min_reinf_checked` -- see the naming note below) and both
degrade the same way every other missing key does: an empty `check` editor
plus a named `problems` entry, with the off-state sentence always shown
underneath since "unchecked" is this page's honest default until the field
exists to say otherwise.
NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted
namespacing convention for one composite input (`stage.<stage_slug>.<opt>`,
see `stage_field_key`). New keys here that do not fit a single flat name
follow the same shape: `design_criteria.<thing>` for one-off Settings/Project
fields (e.g. `design_criteria.analysis_beta_d`), and
`load_combo.<strength|service>.<n>.include` /
`load_combo.<strength|service>.min_earth_factor` for the Load Combinations
grid, mirroring `stage_field_key`'s `<group>.<slug>.<opt>` shape exactly.
IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting
is `'{0}'.format(...)`; every literal that carries the prime character or a
non-ASCII letter is a `u'...'` literal with the coding cookie above declaring
the file UTF-8, the same convention `panelanalysisform.py` uses throughout.
Imports NOTHING beyond the standard library and, defensively,
`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit,
no `panelsketch`, so this loads and runs on plain CPython the way
`tests/conftest.py` expects.
"""
# ------------------------------------------------------------- key existence
def _existing_keys():
"""The set of keys `panelanalysisform.FIELDS` already declares, or None.
None means "could not judge" -- the import failed, or the module's shape
changed out from under us mid-refactor -- and every caller below treats
None the same as "key not found": every field on this page degrades to
an empty editor plus a `problems` entry rather than silently assuming
the other agent's in-flight edit added the key. Broad `except Exception`
on purpose: a sibling agent saving a half-written `panelanalysisform.py`
can raise a SyntaxError, not just ImportError, and this page must keep
rendering through that.
"""
try:
from vibe import panelanalysisform as pf
return set(f['key'] for f in pf.FIELDS)
except Exception:
return None
# ------------------------------------------------------------------- fields
#
# One entry per field this page can lay out: key -> (label, symbol, hint,
# required, editor, choices). Authored locally -- see the module docstring's
# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the
# two keys `panelanalysisform` itself already gates the Run button on
# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching
# the rest of Design Criteria in that module.
_F = {
# --------------------------------------------------- Project Information
'project_name': (
'Project name', '',
'The name of the project this panel belongs to, for the record on '
'this printout. Leave blank if the project has no name yet.',
False, 'text', None),
'client_name': (
'Client name', '',
"The client this panel's project is for. Leave blank if unknown.",
False, 'text', None),
'project_description': (
'Project description', '',
'A short free-text description of the project. Leave blank if '
'there is nothing to add.',
False, 'text', None),
'project_location': (
'Project location', '',
'Where the project is built -- city, state, or site name. Leave '
'blank if unknown.',
False, 'text', None),
'engineer_initials': (
'Engineer of record', '',
'Who is responsible for this design. Leave blank if not yet '
'assigned.',
False, 'text', None),
'project_date': (
'Design date', '',
'The date this design applies to. Leave blank to leave it unset.',
False, 'text', None),
'job_number': (
'Job number', '',
"This job's internal tracking number, if it has one. Leave blank "
'if none has been assigned yet.',
False, 'text', None),
'design_spec': (
'Governing design code', '',
'Which code edition this design is checked against, e.g. '
'"ACI 318-14". Leave blank to leave it unstated.',
False, 'text', None),
# ------------------------------------------- Material Properties/Concrete
'fci_psi': (
'Concrete strength at release', u"f′ci (psi)",
u"How strong the concrete must be when the strand is cut or the "
u"panel is stripped from the bed -- usually lower than the 28-day "
u"strength. Eriksson prints this divided by 1000, in ksi. Leave "
u"blank if this panel is not prestressed.",
False, 'num', None),
'fc_psi': (
'Concrete strength at 28 days', u"f′c (psi)",
u"The concrete's design strength once fully cured; every stress "
u"limit on this page is a multiple of this number. Eriksson prints "
u"this divided by 1000, in ksi. Required -- the solve cannot run "
u"without it.",
True, 'num', None),
'conc_eci_psi': (
'Concrete elastic modulus at release', u"E′ci (psi)",
u"How stiff the concrete is at release strength. Not yet used by "
u"the solve. Leave blank if unknown -- it can be computed from "
u"f′ci once a rule for that is agreed.",
False, 'num', None),
'conc_ec_psi': (
'Concrete elastic modulus, in-place', u"E′c (psi)",
u"How stiff the concrete is once fully cured. Not yet used by the "
u"solve. Leave blank if unknown.",
False, 'num', None),
'unit_weight_pcf': (
'Concrete unit weight', 'pcf',
"How much the concrete weighs per cubic foot; the panel's own "
'weight is computed from this. Required.',
True, 'num', None),
'concrete_type': (
'Concrete type', '',
'What kind of concrete this panel is cast from (normal weight, '
'lightweight, ...). Reference only -- nothing computes from it '
'yet.',
False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')),
# ----------------------------------------------- Material Properties/Bar
'rebar_fy_ksi': (
'Rebar yield strength', 'fy (ksi)',
'The stress at which ordinary (non-prestressed) reinforcing bar '
'starts to yield. Shown for the record; not yet used by the '
'solve.',
False, 'num', None),
'rebar_es_ksi': (
'Rebar elastic modulus', 'Es (ksi)',
'How stiff ordinary reinforcing steel is. Shown for the record; '
'not yet used by the solve.',
False, 'num', None),
'ps_fpu_ksi': (
u'Strand ultimate strength', u'fₚᵤ (ksi)',
'The breaking strength of the prestressing strand. Leave blank if '
'this panel is not prestressed.',
False, 'num', None),
'strand_ep_ksi': (
'Strand elastic modulus', 'Ep (ksi)',
'How stiff the prestressing strand is. Shown for the record; not '
'yet used by the solve.',
False, 'num', None),
# -------------------------------------------------------- Load combos
'design_criteria.min_earth_factor_strength': (
'Minimum earth factor, strength combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'strength combinations below. Leave blank to use none.',
False, 'num', None),
'design_criteria.min_earth_factor_service': (
'Minimum earth factor, service combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'service combinations below. Leave blank to use none.',
False, 'num', None),
# --------------------------------------------------- Settings/Deflection
'deflection_limit': (
'Typical deflection limit', 'L / n',
'How much the panel is allowed to bow or sag, as a fraction of its '
'span, e.g. "150" for L/150. Not yet used by the solve.',
False, 'text', None),
'design_criteria.deflection_add_bow': (
'Add an initial bow to reported deflections?', '',
'Whether the panel is assumed to already be slightly bowed before '
'any load is applied, and that starting bow is added to every '
'reported deflection. Leave unchecked to assume none.',
False, 'check', None),
'design_criteria.deflection_additional_bow_in': (
'Additional bow assumed', 'in',
'How much starting bow to add, if the box above is checked. Leave '
'blank for none.',
False, 'num', None),
'design_criteria.deflection_check_cantilever': (
'Check deflections on cantilevers?', '',
'Whether cantilevered portions of the panel get their own '
'deflection check, separate from the typical limit above. Leave '
'unchecked to skip it.',
False, 'check', None),
'design_criteria.deflection_cantilever_limit': (
'Cantilever deflection limit', 'L / n',
'The separate deflection limit for cantilevered portions, if the '
'box above is checked, e.g. "75" for L/75.',
False, 'text', None),
# ---------------------------------------------- Settings/Structural Anal.
'second_order': (
'2nd-order effects considered?', '',
'Whether P-delta / second-order bending is considered for this '
'panel. Not yet used by the solve.',
False, 'text', None),
'design_criteria.analysis_beta_d': (
u'Sustained-load ratio for 2nd-order analysis', u'βd',
'The fraction of the axial load that is sustained (long-term), '
'used by the 2nd-order analysis above. Leave blank if not '
'applicable.',
False, 'num', None),
'design_criteria.cracking_method': (
'Cracking method', '',
'Which published method decides whether the section is treated as '
'cracked. Leave blank if not applicable.',
False, 'text', None),
'design_criteria.cracking_limit_sqrt_fc': (
u'Cracking stress limit', u"n·√f′c",
u"The stress, as a multiple of √f′c, at which the "
u"section is assumed to crack. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'design_criteria.rvalue_calc_enabled': (
'Calculate an R-value for this panel?', '',
'Whether an insulation R-value is calculated for this panel. Leave '
'unchecked if it is not being calculated -- that is the default.',
False, 'check', None),
'design_criteria.min_reinf_checked': (
'Check minimum reinforcement?', '',
'Whether the ACI minimum-steel requirement is checked for this '
'panel. Leave unchecked if it is not being checked -- that is the '
'default.',
False, 'check', None),
# -------------------------------------------------- Settings/Stress opts
'design_criteria.stress_failure_if_cracked': (
'Fail the panel if it cracks?', '',
'Whether a cracked section is treated as an outright failure '
'rather than just a flag. Leave unchecked to only flag it.',
False, 'check', None),
'design_criteria.stress_ignore_lateral_ecc': (
'Ignore lateral strand eccentricity?', '',
'Whether side-to-side strand placement is ignored when computing '
'stress. Leave unchecked to include it.',
False, 'check', None),
'design_criteria.stress_use_principal_axis': (
'Use the principal axis for stress?', '',
'Whether stress is computed about the section\'s principal axis '
'rather than its geometric one. Leave unchecked to use the '
'geometric axis.',
False, 'check', None),
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
'Modulus of rupture, in place', u"n·√f′c",
u"The concrete's tensile cracking stress once erected, as a "
u"multiple of √f′c. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'inplace_comp_ratio': (
'Compression allowed once erected', u"n·f′c",
'The compression limit for the panel once it is standing in '
'place, as a fraction of f′c. Not yet used by the solve.',
False, 'num', None),
'handling_tension_sqrt': (
'Tension allowed while handling', u"n·√f′ci",
u"How much the concrete may be pulled apart during stripping / "
u"handling, as a multiple of √f′ci. Blank uses the "
u"built-in default.",
False, 'num', None),
'handling_comp_ratio': (
'Compression allowed while handling', u"n·f′ci",
u"How much the concrete may be squeezed during handling, as a "
u"fraction of f′ci. Blank uses the built-in default.",
False, 'num', None),
'design_criteria.flexure_method': (
'Flexure calculation method', '',
'Which method computes bending capacity (e.g. a stress-block '
'method). Leave blank to use the built-in default.',
False, 'text', None),
'design_criteria.flexure_ignored_end_distance_in': (
'Ignored end distance', 'in',
'A length at each end of the panel excluded from the flexure '
'check (strand not yet developed there). Leave blank for none.',
False, 'num', None),
'design_criteria.flexure_cracking_check': (
'Flexural cracking check', '',
'Which cracking check applies to the flexure calculation, if any. '
'Leave blank for none.',
False, 'text', None),
# ------------------------------------------------ Settings/Resist. Fact.
'phi_flexure': (
'Bending strength reduction factor, developed', u'φ, tension',
"The ACI safety factor applied to the panel's bending strength "
'once the strand is fully developed. Blank uses the built-in '
'default (0.90).',
False, 'num', None),
'design_criteria.phi_compression': (
'Compression strength reduction factor, developed',
u'φ, compression',
"The ACI safety factor applied to the panel's compression "
'strength once the strand is fully developed. Leave blank to use '
'the built-in default.',
False, 'num', None),
'phi_shear': (
'Shear strength reduction factor, developed', u'φ, shear',
"The ACI safety factor applied to the panel's shear strength once "
'the strand is fully developed. Blank uses the built-in default '
'(0.75).',
False, 'num', None),
'design_criteria.phi_tension_transfer': (
'Bending strength reduction factor, transferring',
u'φ, tension',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'design_criteria.phi_shear_transfer': (
'Shear strength reduction factor, transferring', u'φ, shear',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'dev_mult': (
'Strand development multiplier, normal', '',
'A multiplier on the strand development length for ordinary '
'(non-debonded, uncut) strand. Blank uses the built-in default '
'(1.0).',
False, 'num', None),
'design_criteria.dev_mult_debonded': (
'Strand development multiplier, debonded', '',
'The same multiplier, for strand that is deliberately debonded '
'over part of its length. Leave blank to use the built-in '
'default.',
False, 'num', None),
'design_criteria.dev_mult_cut': (
'Strand development multiplier, cut', '',
'The same multiplier, for strand that is field-cut rather than '
'released normally. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.dev_mult_mild': (
'Development multiplier, mild reinforcement', '',
'The same idea applied to ordinary (non-prestressed) reinforcing '
'bar. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.shear_treat_as_column': (
'Treat this panel as a column for shear?', '',
'Whether the shear check uses column rules rather than wall '
'rules. Leave unchecked to use wall rules.',
False, 'check', None),
'design_criteria.axial_ignore_tensile_failures': (
'Ignore axial tensile-strength failures?', '',
'Whether the axial check is allowed to pass even when the '
"concrete's tensile strength alone is exceeded. Leave unchecked "
'to flag them.',
False, 'check', None),
'design_criteria.axial_ignore_buckling_failures': (
'Ignore buckling failures?', '',
'Whether the axial check is allowed to pass even when the panel '
'would buckle. Leave unchecked to flag them.',
False, 'check', None),
}
# Prestress-loss group: shown as a sentence when every one of these is blank
# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here"
# style for Exterior/Interior Soil, applied to the one other Design Criteria
# group that is entirely optional and entirely empty on a fresh panel.
_PRESTRESS_LOSS_FIELDS = (
'ps_loss_method', 'ps_loss_humidity_pct',
'ps_loss_release_days', 'ps_loss_erection_days',
)
_F['ps_loss_method'] = (
'Prestress loss method', '',
'Which published method (PCI, AASHTO, ...) the assumed loss below '
'comes from. Reference only.',
False, 'text', None)
_F['ps_loss_humidity_pct'] = (
'Ambient humidity assumed', '%',
'The relative humidity the loss method uses -- losses are larger in '
'drier air. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_release_days'] = (
'Days from casting to release', '',
'How many days after casting the strand is cut / the panel is '
'stripped. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_erection_days'] = (
'Days from casting to erection', '',
'How many days after casting the panel is erected in place. Not yet '
'used by the solve.',
False, 'num', None)
def _field(key, problems, seen, existing):
"""One `field` block for `key`, plus a `problems` entry if it is missing.
`seen` de-dupes: several groups below reuse a couple of keys (none do
today, but the guard costs nothing and keeps a future edit from quietly
filing the same problem twice). `existing` is `_existing_keys()`'s
result, threaded through rather than re-read per field so one broken
import during a render is reported once, not dozens of times.
"""
spec = _F.get(key)
if spec is None:
# Programming error inside this file, not a missing form key --
# still degrade rather than raise, and say so plainly.
problems.append(
"Design Criteria has no text authored for the key '{0}' -- "
'this is a bug in pa_criteria.py, not a missing form field.'
.format(key))
return {'kind': 'field', 'key': key, 'label': key, 'symbol': None,
'hint': None, 'required': False, 'editor': 'text',
'choices': None}
label, symbol, hint, required, editor, choices = spec
if existing is None or key not in existing:
if key not in seen:
seen.add(key)
problems.append(
"'{0}' ({1}) has no backing field in panelanalysisform.FIELDS "
'yet -- shown as an empty editor until it is added.'
.format(label, key))
return {'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint, 'required': required,
'editor': editor, 'choices': list(choices) if choices else None}
def _heading(text, level):
return {'kind': 'heading', 'text': text, 'level': level}
def _cols(columns):
return {'kind': 'columns', 'columns': columns}
def _sentence(text):
return {'kind': 'sentence', 'text': text}
def _rule():
return {'kind': 'rule'}
def _space(h=1):
return {'kind': 'space', 'h': h}
def _group_or_sentence(values, keys, empty_text, build_filled):
"""[blocks] -- a `sentence` when every key in `keys` is blank, else
whatever `build_filled()` returns.
This is the "nothing here vs. never asked" split applied at group
granularity: `keys` all being blank in `values` is a real, filled-in
answer ("nothing entered"), not the absence of a form -- so it is shown
as a plain sentence instead of a wall of empty boxes, the same way
Eriksson's own printout shows a caption instead of blank inputs when
there is nothing to report. `values` is read with `.get(...)`, never
indexed, and never mutated.
`build_filled` is a zero-argument CALLABLE, not a plain list -- the
filled-in branch typically calls `F(key)`, which files a `problems`
entry for any key still missing from `panelanalysisform.FIELDS`, and
that side effect must only happen for fields actually being shown. A
plain list argument would be built eagerly by the caller before this
function ever looks at `keys`, filing a phantom "missing field" problem
for a field the empty-sentence branch never renders.
"""
for k in keys:
v = values.get(k)
if v is not None and str(v).strip():
return build_filled()
return [_sentence(empty_text)]
# --------------------------------------------------------- load combinations
#
# Standard ACI 318-14 basic strength/service combinations -- public code
# text, the same eight/ten rows any ACI-compliant tool would print, not this
# job's data. Kept as a fixed reference table (see the module docstring's
# "WHY THE 3-COLUMN CONCRETE GRID ... `columns`, NOT `table`" note for why
# THIS table, unlike the material grids, is a real `table` block): the only
# thing a user edits per row is which combinations apply, via the Include
# tick, and `table` already carries a native check-editor cell for that.
_STRENGTH_COMBOS = (
('1', '1.4', '0', '0', '0', '0', '1', '1.6'),
('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'),
('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'),
('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'),
('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'),
('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'),
('7', '0.9', '0', '0', '1', '0', '1', '1.6'),
('8', '0.9', '0', '0', '0', '1', '1', '1.6'),
)
_SERVICE_COMBOS = (
('1', '1', '0', '0', '0', '0', '0.75', '1'),
('2', '1', '1', '0', '0', '0', '0.75', '1'),
('3', '1', '0', '1', '0', '0', '0.75', '1'),
('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'),
('5', '1', '0', '0', '0.6', '0', '0.75', '1'),
('6', '1', '0', '0', '0', '0.7', '0.75', '1'),
('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'),
('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'),
('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'),
('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'),
)
_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include']
def _combo_table(name, rows, problems):
table_rows = []
for row in rows:
n = row[0]
include_key = 'load_combo.{0}.{1}.include'.format(name, n)
table_rows.append(list(row) + [{'editor': 'check', 'key': include_key}])
problems.append(
"the Include tick boxes in the {0} load-combination table have no "
"backing fields yet (load_combo.{0}.<n>.include) -- shown as empty "
"check boxes.".format(name))
return {'kind': 'table', 'headers': list(_COMBO_HEADERS),
'header_groups': None, 'rows': table_rows, 'row_key': None}
# --------------------------------------------------------------------- page
def page(values, reads=None):
"""(blocks, problems) for the Design Criteria section.
`values` is the form's current string-valued dict, read defensively --
every lookup goes through `.get(...)` and a key this module expects but
does not find is treated as blank, never as an error, because another
agent is restructuring `panelanalysisform.FIELDS` at the same time this
is being written. `values` is never mutated. `reads` is optional and, on
this section, only used for the one-line "Panel <mark>" context caption
at the top -- everything else on Design Criteria is job-level or
material data, not something read off the framing element.
Never raises: every lookup that could fail (the `panelanalysisform`
import, a missing dict key) degrades to a `problems` entry instead.
"""
values = values if isinstance(values, dict) else {}
reads = reads if isinstance(reads, dict) else {}
problems = []
seen = set()
existing = _existing_keys()
if existing is None:
problems.append(
'could not read panelanalysisform.FIELDS to check which keys '
'already exist (the module may be mid-edit); every field on '
'this page is shown, and is reported as missing until this is '
'confirmed.')
def F(key):
return _field(key, problems, seen, existing)
blocks = []
blocks.append(_heading('Design Criteria', 1))
mark = (reads.get('mark') or '').strip()
design_number = (reads.get('design_number') or '').strip()
if mark or design_number:
blocks.append({'kind': 'kv', 'rows': [
('Panel', mark or '(mark not read)'),
('Design number', design_number or '(not read)'),
]})
# ----------------------------------------------------- Project Information
blocks.append(_heading('Project Information', 2))
blocks.append(_cols([
[F('project_name'), F('client_name'), F('project_description')],
[F('project_location'), F('engineer_initials')],
[F('project_date'), F('job_number'), F('design_spec')],
]))
blocks.append(_rule())
# ------------------------------------------------------ Material Properties
blocks.append(_heading('Material Properties', 2))
blocks.append(_heading('Concrete', 3))
blocks.append(_cols([
[F('fci_psi'), F('fc_psi')],
[F('conc_eci_psi'), F('conc_ec_psi')],
[F('unit_weight_pcf'), F('concrete_type')],
]))
blocks.append(_space(1))
blocks.append(_cols([
[_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')],
[_heading('Mesh', 3),
_sentence('No mesh material properties are collected on this '
'form yet.')],
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
]))
blocks.append(_rule())
# ---------------------------------------------------------- Prestress Loss
blocks.append(_cols([
[_heading('Prestress Losses', 2)] +
_group_or_sentence(
values, _PRESTRESS_LOSS_FIELDS,
'No prestress loss assumptions entered for this panel.',
lambda: [F(k) for k in _PRESTRESS_LOSS_FIELDS]),
]))
blocks.append(_rule())
# -------------------------------------------------------------- Soil loads
#
# `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR
# SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing
# to degrade to an editor for here.
problems.append(
'soil loads are not collected anywhere in this form yet; Exterior '
'Soil and Interior Soil always read as "no loads" until a later '
'stage adds that input.')
blocks.append(_cols([
[_heading('Exterior Soil', 2),
_sentence('Design contains no exterior soil loads.')],
[_heading('Interior Soil', 2),
_sentence('Design contains no interior soil loads.')],
]))
blocks.append(_rule())
# ----------------------------------------------------------- Load Combos
blocks.append(_heading('Load Combinations', 1))
blocks.append(_cols([
[_heading('Strength Combinations', 2),
_combo_table('strength', _STRENGTH_COMBOS, problems),
F('design_criteria.min_earth_factor_strength')],
[_heading('Service Combinations', 2),
_combo_table('service', _SERVICE_COMBOS, problems),
F('design_criteria.min_earth_factor_service')],
]))
blocks.append(_rule())
# --------------------------------------------------------------- Settings
blocks.append(_heading('Settings', 1))
col_a = [
_heading('Deflection Limits', 2),
F('design_criteria.deflection_add_bow'),
F('design_criteria.deflection_additional_bow_in'),
F('deflection_limit'),
F('design_criteria.deflection_check_cantilever'),
F('design_criteria.deflection_cantilever_limit'),
_heading('Structural Analysis', 2),
F('second_order'),
F('design_criteria.analysis_beta_d'),
F('design_criteria.cracking_method'),
F('design_criteria.cracking_limit_sqrt_fc'),
F('design_criteria.rvalue_calc_enabled'),
] + _group_or_sentence(
values, ('design_criteria.rvalue_calc_enabled',),
'R-value is not being calculated for this panel.', lambda: []) + [
F('design_criteria.min_reinf_checked'),
] + _group_or_sentence(
values, ('design_criteria.min_reinf_checked',),
'Minimum steel is not being checked for this panel.', lambda: [])
col_b = [
_heading('Stress Options', 2),
F('design_criteria.stress_failure_if_cracked'),
F('design_criteria.stress_ignore_lateral_ecc'),
F('design_criteria.stress_use_principal_axis'),
_heading('In-Place Stress Limits', 2),
F('design_criteria.inplace_modulus_rupture_sqrt_fc'),
F('inplace_comp_ratio'),
_heading('Handling Stress Limits', 2),
F('handling_tension_sqrt'),
F('handling_comp_ratio'),
_heading('Flexure Options', 2),
F('design_criteria.flexure_method'),
F('design_criteria.flexure_ignored_end_distance_in'),
F('design_criteria.flexure_cracking_check'),
]
col_c = [
_heading('Resistance Factors', 2),
_cols([
[_heading('Developed', 3),
F('phi_flexure'), F('design_criteria.phi_compression'),
F('phi_shear')],
[_heading('Transferring', 3),
F('design_criteria.phi_tension_transfer'),
_sentence('Not applicable for compression once the strand '
'has fully transferred.'),
F('design_criteria.phi_shear_transfer')],
]),
_heading('Development Multipliers', 2),
F('dev_mult'),
F('design_criteria.dev_mult_debonded'),
F('design_criteria.dev_mult_cut'),
F('design_criteria.dev_mult_mild'),
_heading('Shear Options', 2),
F('design_criteria.shear_treat_as_column'),
_heading('Axial Options', 2),
F('design_criteria.axial_ignore_tensile_failures'),
F('design_criteria.axial_ignore_buckling_failures'),
]
blocks.append(_cols([col_a, col_b, col_c]))
return blocks, problems
+570
View File
@@ -0,0 +1,570 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Concrete Extents" section, laid out.
The owner has said three times the Panel Analysis form is too ugly to read
("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成
普通人能看懂的样子" -- every part of it has to be something an ordinary
person, not just an engineer, can understand. The fix agreed on is to make
the form look like Eriksson Wall's own printed page for this section (a solid
wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals /
Solid Zones table, and a drawings row) but with a plain-English `label`
beside every engineering `symbol` and a one-sentence `hint` on every field --
never the symbol alone, standing in as if it were the explanation.
`page(values, reads=None)` returns `(blocks, problems)`: a flat list of block
dicts from a fixed vocabulary a generic WPF renderer (built separately, not
here) consumes, plus a list of plain-English sentences for anything that
could not be laid out. This module never raises -- a field it cannot place
becomes a `problems` entry, never a traceback the owner has to read a
Python stack to understand.
WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to
load and run on plain CPython under `tests/conftest.py`'s trick (see that
file's docstring) the same way the rest of Revit-free `vibe` does, and it has
to run under IronPython 2.7 inside the button. `panelsketch` is named by
STRING in a `drawing` block's `source` field -- the renderer calls it, not
this module -- because a sibling agent is extending `panelsketch` at the same
time this file is being written, and importing a module mid-rewrite is how
two agents' work collides.
WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns
`vibe.panelanalysisform`, the source of the form's key names, and is
restructuring it at the same time this file is being written. A key this
module expects that is not actually there is "not present", never treated as
a bug -- `_paf_field` returns `None` and the caller falls back rather than
raising. The same discipline applies to the structured opening rows a third
agent is meant to be adding at `values['openings_rows']` (shaped
`{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross',
'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's
own contract): when it is not there yet, or is not a list, this module falls
back to the raw `values['openings']` text field and SAYS SO in `problems`,
rather than guessing at a shape that has not landed.
WHY THE SECTION PROPERTIES BLOCK OMITS MOST OF ERIKSSON'S OWN ROWS: Eriksson
prints Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb and St, but the last
seven need either an axis convention this module has no authority to invent,
or a net-of-openings deduction it does not compute. Printing a plausible
number in a column headed "St, in³" when nobody has verified it is exactly
the fabricated section modulus the owner has ruled against elsewhere in this
codebase (`CLAUDE.md` "no invented default" discipline) -- so this module
computes ONLY Area / Width / Height, from the panel's own width and
thickness, and names the rest in `problems` as omitted, never fills them in
with a guess.
WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT:
`panelsketch.elevation()`'s own docstring says it draws the front elevation
BESIDE a side elevation as one combined picture, not two separate ones.
Eriksson's page prints them as two separately captioned drawings, and this
module is asked to lay out a `columns` row of exactly that shape -- so it
does, naming `source: 'elevation'` under both the "Side Elevation" and
"Front Elevation" captions, and says in `problems` that until `panelsketch`
is split in two, both captions currently draw the same combined image. That
is a real, current limitation, not something to paper over by inventing a
`source` name that does not exist.
Miters get the same "print a sentence, never leave a blank" discipline
Eriksson uses on its own page (an unchecked "Left Miter" box there is not
just left bare -- it prints "Left miter not included in design" underneath).
`_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is
silent, because blank means "not yet decided", never "no" (same rule
`panelanalysisform._parse_bool` documents for its own checkboxes); a miter
answered explicitly "no" prints the sentence, in words, the way Eriksson
does.
"""
# `panelanalysisform` carries no Revit import at module scope (see its own
# docstring), so this succeeds on plain CPython under `tests/conftest.py`'s
# trick and under IronPython 2.7 alike. If the sibling agent restructuring it
# has, at some moment, left it unimportable, this module still has to load --
# so the import itself is guarded, and every reader of `paf` below is guarded
# again on top of that (see `_paf_field`).
try:
from vibe import panelanalysisform as paf
except ImportError:
paf = None
# --------------------------------------------------------------- utilities
def _num(value):
"""`value` (a form string, or already a number) as a float, or None.
None covers "blank", "not a number", and "not present" alike -- the
caller only ever needs to know whether there is a usable number, and
`validate()`-style error text is not this module's job (the button's
completeness gate already owns that; this module only lays out what it
is given).
"""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A float as the plain string this page prints -- '8', not '8.0'."""
if value is None:
return ''
if float(value) == int(value):
return str(int(value))
return ('%.4f' % value).rstrip('0').rstrip('.')
# Eighths-of-an-inch -> the fraction word Eriksson prints. A precast
# dimension finer than 1/8" is not meaningful, so `_feet_str` rounds to it
# rather than pretending to more precision than the field carries.
_EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2',
5: '5/8', 6: '3/4', 7: '7/8'}
def _feet_str(value):
"""Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2".
Internal units are decimal feet throughout this codebase (`CLAUDE.md`
§3), so a bare number arriving in a position/length field is read as
feet and converted here to the feet-inches form Eriksson prints (`8"`,
`2'-11.5"`). A value that is already a string is left untouched by the
caller (`_dim_or_dash`) rather than reparsed here -- a sibling agent may
hand this module an already-formatted dimension, and reformatting a
string that already reads correctly is how a "2'-11.5\"" becomes a wrong
"2'-11 1/2\"" glued onto a stray decimal.
Returns None only when `value` cannot be read as a number at all.
"""
try:
feet = float(value)
except (TypeError, ValueError):
return None
negative = feet < 0.0
feet = abs(feet)
whole_ft = int(feet)
inches = (feet - whole_ft) * 12.0
eighths_total = int(round(inches * 8.0))
whole_in, eighths = divmod(eighths_total, 8)
if whole_in >= 12:
whole_ft += whole_in // 12
whole_in = whole_in % 12
frac = _EIGHTHS[eighths]
if frac and whole_in:
in_part = '{0} {1}'.format(whole_in, frac)
elif frac:
in_part = frac
else:
in_part = str(whole_in)
if whole_ft > 0:
text = u'{0}\'-{1}"'.format(whole_ft, in_part)
else:
text = u'{0}"'.format(in_part)
return u'-' + text if negative else text
def _dim_or_dash(value):
"""A table cell for one dimension: Eriksson's own '-' for blank."""
if value is None:
return '-'
if isinstance(value, str):
value = value.strip()
return value if value else '-'
text = _feet_str(value)
return text if text is not None else '-'
def _from_word(word):
"""'start' -> 'Start'; blank/None -> ''. Never invents a datum word."""
if not word:
return ''
word = str(word).strip()
return word[:1].upper() + word[1:].lower() if word else ''
def _pos_cell(pos, frm):
"""A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'."""
dim = _dim_or_dash(pos)
if dim == '-':
return '-'
frm_text = _from_word(frm)
return '{0} from {1}'.format(dim, frm_text) if frm_text else dim
_TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal',
'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'}
def _type_label(value):
"""The Type column's text -- a known word capitalised Eriksson's way,
or, for anything this table does not recognise, the entered text back
in title case rather than a guessed category -- echoing what was typed
is honest; picking the "closest" known type is not.
"""
if not value:
return '-'
key = str(value).strip().lower().replace('_', ' ')
if key in _TYPE_LABELS:
return _TYPE_LABELS[key]
return str(value).strip().title() if str(value).strip() else '-'
def _tri_bool(text):
"""(True/False/None) for a checkbox's raw text. None = "not yet said".
Mirrors `panelanalysisform._parse_bool`'s own rule (blank is
"unspecified", never "no") without importing that private helper across
a module boundary neither module owns the other's internals of.
"""
if text is None:
return None
t = str(text).strip().lower()
if not t:
return None
if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'):
return True
if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded',
'unchecked'):
return False
return None
# --------------------------------------------------------- panelanalysisform
def _paf_field(key):
"""The raw field dict `panelanalysisform.FIELDS` has for `key`, or None.
None covers every way `paf` can fail to have the answer: the module
failed to import, it has no `FIELDS` (mid-restructure), or `key` is
simply not one it declares yet. All three are "not present", never an
error -- see the module docstring.
"""
if paf is None:
return None
try:
fields = paf.FIELDS
except AttributeError:
return None
try:
for f in fields:
if f.get('key') == key:
return f
except (TypeError, AttributeError):
return None
return None
def _field(key, editor, fallback_label, fallback_symbol, fallback_hint,
fallback_required, problems, choices=None):
"""One `field` block, sourced from `panelanalysisform` when it has the
key and from the fallback text when it does not.
A missing key still gets a field on the page -- the Eriksson layout this
section is copying has a slot there -- but `problems` names it, because
a value typed into a field `panelanalysisform` does not declare cannot
be saved into the deck yet. That is the honest state of a form being
restructured by another agent at the same time, not a bug in this file.
"""
spec = _paf_field(key)
if spec is not None and paf is not None:
try:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or fallback_hint
required = bool(spec.get('required'))
except AttributeError:
spec = None
if spec is None:
label = fallback_label
symbol = fallback_symbol or None
hint = fallback_hint
required = fallback_required
problems.append(
"{0!r} is not a field vibe.panelanalysisform declares yet; "
"shown here to match Eriksson's own Concrete Extents page, but "
"a value typed into it will not be saved until that module "
"adds this key.".format(key))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
def _miter_sentence(values, key, side):
"""Eriksson's own discipline: an OFF miter prints a sentence, not a gap.
Blank (unanswered) prints nothing here, same as Eriksson's page shows
nothing extra for a miter nobody has touched yet -- only an explicit
"no" earns the sentence, because blank means "not yet decided", not
"excluded" (see `_tri_bool`).
"""
if _tri_bool(values.get(key)) is False:
return {'kind': 'sentence',
'text': '{0} miter not included in this panel\'s design.'
.format(side)}
return None
# ------------------------------------------------------------- sub-sections
def _panel_geometry_columns(values, problems):
"""The three columns Eriksson's own Panel Geometry block prints:
(Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) |
(Width, Left Miter, Right Miter, Top In Form, Width, Height) |
(Thickness).
`length_ft` (this codebase's name for the panel's long dimension) is
what Eriksson calls "Height" on this page -- the same field, read
through `panelanalysisform`'s own label rather than renamed here, so a
reviewer sees the one name every section of the form uses for it.
"""
col1 = [
_field('top_elevation_ft', 'num', 'Top elevation', 'ft',
'How high the top edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft',
'How high the bottom edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('length_ft', 'num', 'Panel length', 'ft',
"The panel's long dimension, end to end. Required -- "
'everything downstream is measured against this span.',
True, problems),
_field('section_type', 'text', 'Section type', '',
'A short description of the cross-section (solid, '
'sandwich, ribbed, ...). Reference only.', False, problems),
_field('top_in_form', 'text', 'Which face is up in the casting '
'bed', 'Interior / Exterior',
'Which face of the panel faces up while it is cast. '
'Reference only.', False, problems,
choices=['Interior', 'Exterior']),
]
col2 = [
_field('width_ft', 'num', 'Panel width', 'ft',
"The panel's dimension across the span -- how wide it is. "
'Required.', True, problems),
_field('left_miter', 'check', 'Left miter', '',
'Whether the left end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems),
]
sentence = _miter_sentence(values, 'left_miter', 'Left')
if sentence:
col2.append(sentence)
col2.append(
_field('right_miter', 'check', 'Right miter', '',
'Whether the right end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems))
sentence = _miter_sentence(values, 'right_miter', 'Right')
if sentence:
col2.append(sentence)
col2.append(
_field('top_in_form_check', 'check',
'Top edge has a formed step', '',
'Whether the top edge of the panel has its own formed '
'step or insert built in, separate from which face faces '
'up (see "Which face is up" at left). Leave unchecked if '
'there is none.', False, problems))
col2.append(
_field('top_in_form_width_in', 'num', 'Formed step width', 'in',
'How wide the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
col2.append(
_field('top_in_form_height_in', 'num', 'Formed step height', 'in',
'How tall the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
col3 = [
_field('thickness_in', 'num', 'Panel thickness', 'in',
'How thick the panel is. Required.', True, problems),
]
return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2},
{'kind': 'columns', 'columns': [col1, col2, col3]}]
# The grouped header the Openings / Reveals / Solid Zones table prints,
# spanning the last 6 of its 7 columns: 'Type' stands alone at the left the
# way Eriksson's own page leaves it, and (2, 4) is what leaves the group
# spans adding up to exactly `len(headers) - 1`.
_OPENINGS_HEADERS = ['Type', 'Position', 'Length', 'Horizontal Position',
'Width', 'Vertical Position', 'Height']
_OPENINGS_GROUPS = [('Position along Length', 2), ('Position in Cross '
'Section', 4)]
def _openings_row_cells(row):
row = row or {}
return [
_type_label(row.get('type')),
_pos_cell(row.get('pos_along'), row.get('pos_along_from')),
_dim_or_dash(row.get('length')),
_pos_cell(row.get('pos_cross'), row.get('pos_cross_from')),
_dim_or_dash(row.get('width')),
_dim_or_dash(row.get('pos_vert')),
_dim_or_dash(row.get('height')),
]
def _openings_blocks(values, problems):
"""The Openings, Reveals, and Solid Zones heading + table (or fallback).
`values['openings_rows']` is the structured shape a sibling agent is
meant to be adding (see the module docstring); when it is missing, or
is not a list, this degrades to the raw `values['openings']` text field
and a `problems` entry -- never a guessed re-parse of that free text
into the grouped shape, which would be inventing datum words
("from Start" / "from Left") nobody has actually said.
"""
blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid '
'Zones', 'level': 2}]
rows = values.get('openings_rows') if values else None
if isinstance(rows, list):
table_rows = [_openings_row_cells(r) for r in rows]
blocks.append({'kind': 'table', 'headers': _OPENINGS_HEADERS,
'header_groups': _OPENINGS_GROUPS,
'rows': table_rows, 'row_key': None})
if not rows:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
problems.append(
"'openings_rows' is not present in the form values (or is not a "
'list), so the grouped Position-along-Length / Position-in-'
'Cross-Section table cannot be built; falling back to the raw '
"'openings' text field.")
raw = (values.get('openings') if values else None) or ''
raw = raw.strip() if isinstance(raw, str) else str(raw).strip()
if raw:
blocks.append({'kind': 'sentence',
'text': 'Openings were entered as plain text and '
'cannot be shown as the grouped table yet: '
'{0}'.format(raw)})
else:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
# Every Section Properties row Eriksson's own page prints. Only the first
# three (`_DERIVABLE_PROPS`) are ever computed here -- see the module
# docstring for why the rest are refused rather than guessed.
_DERIVABLE_PROPS = ('Area, in²', 'Width, in', 'Height, in')
_OMITTED_PROPS = ('Cx, in', 'Cy, in', 'Izz, in⁴', 'Iyy, in⁴',
'Izy, in⁴', 'Sb, in³', 'St, in³')
def _section_properties_column(values, problems):
"""The Section Properties kv list -- Area / Width / Height ONLY.
Both come straight from what the user entered (panel width, panel
thickness) with no engineering judgement in between, so they are the
only rows honest to print. `Height` here is Eriksson's name for the
section's own depth through the panel, i.e. the panel THICKNESS -- not
the panel's long dimension, which the Panel Geometry block above already
calls "Height" in its own different sense. Printing both under the same
word in two different places on the same page is exactly Eriksson's own
layout, confusing as that is; relabelling one of them would stop this
matching the page the owner asked for.
"""
col = [{'kind': 'heading', 'text': 'Section Properties', 'level': 3}]
width_ft = _num(values.get('width_ft'))
thickness_in = _num(values.get('thickness_in'))
if width_ft is not None and width_ft > 0 and \
thickness_in is not None and thickness_in > 0:
width_in = width_ft * 12.0
area_in2 = width_in * thickness_in
rows = [('Area, in²', _fmt_num(area_in2)),
('Width, in', _fmt_num(width_in)),
('Height, in', _fmt_num(thickness_in))]
col.append({'kind': 'kv', 'rows': rows})
problems.append(
'{0} are not shown: deriving them honestly needs an axis '
'convention and, wherever openings are present, a '
'net-of-openings deduction this module does not compute -- a '
'guessed section modulus is worse than none.'
.format(', '.join(_OMITTED_PROPS)))
else:
col.append({'kind': 'sentence',
'text': 'Section properties are waiting on Panel '
'width and Panel thickness; nothing is shown '
'until both are entered.'})
problems.append(
'{0} could not be derived -- Panel width and/or Panel '
'thickness is missing.'.format(
', '.join(_DERIVABLE_PROPS + _OMITTED_PROPS)))
return col
def _drawings_row(problems):
"""The Side Elevation / Front Elevation / Section Properties row.
Both drawings name `source: 'elevation'` -- see the module docstring's
"WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT" for
why that is today's correct, if unsatisfying, answer rather than a name
this module invents to look like two different pictures.
"""
problems.append(
"Side Elevation and Front Elevation both use panelsketch."
'elevation(), which currently draws the side and front views '
'together as ONE picture (per that function\'s own docstring) '
'rather than as two separate drawings; until panelsketch is split '
'in two, both captions below show the same combined image.')
side_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Side Elevation', 'w': 260, 'h': 520}]
front_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Front Elevation', 'w': 420, 'h': 520}]
return side_col, front_col
# ------------------------------------------------------------------ page()
def page(values, reads=None):
"""(blocks, problems) for the Concrete Extents section.
`values` is the form's current string-valued dict (plus, where a
sibling agent has landed it, the structured `openings_rows` list -- see
the module docstring). `reads` is what the button read straight off the
live framing element, the same shape `panelanalysisform.prefill` takes;
it is accepted for symmetry with the other section builders but not
read here -- everything this section needs is already folded into
`values` by the time `page` sees it, and reading `reads` again here
would risk showing a MODEL value beside a form value the user has since
typed over it, exactly the kind of stale-looking page the owner has
complained about.
Never raises. Every field this module cannot place is a `problems`
sentence, not a traceback -- see the module docstring for the two
running examples (a `panelanalysisform` key that is not there yet, and
an `openings_rows` shape that has not landed yet).
"""
values = values if values is not None else {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}]
blocks.extend(_panel_geometry_columns(values, problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.extend(_openings_blocks(values, problems))
blocks.append({'kind': 'space', 'h': 8})
side_col, front_col = _drawings_row(problems)
props_col = _section_properties_column(values, problems)
blocks.append({'kind': 'columns',
'columns': [side_col, front_col, props_col]})
blocks.append({'kind': 'space', 'h': 8})
blocks.append({'kind': 'drawing', 'source': 'section',
'caption': 'Cross Section', 'w': 900, 'h': 260})
return blocks, problems
+528
View File
@@ -0,0 +1,528 @@
# -*- coding: utf-8 -*-
"""The Panel Analysis form's "Handling" section, rendered as generic blocks.
WHY THIS EXISTS: the owner has now said three times that the Panel Analysis
input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能
看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂
的样子", make every part of it something an ordinary person can understand. The
"Handling" section is the worst offender: it used to be three lonely text
boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson
Wall's own printed page for this section is a whole per-stage MATRIX --
eleven questions asked once per handling stage, plus lifter/dunnage tables and
a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring
covers that ruling and the Eriksson-parity one before it) and now exposes a
real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`,
`stage_field_key`). This module is the second half: turning that data plus
the plain-language field metadata into the flat, renderer-agnostic BLOCK list
a generic WPF page consumes, matching Eriksson's own page 25/26 layout
(Stage Options matrix + legend + weight/centroid + plan drawing + lifter and
dunnage tables + Lifting Hardware) as closely as the two modules' real data
will honestly support.
THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this
module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' /
'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown
`kind` renders as nothing, so a typo here is a silently blank section rather
than a crash; `page()` is tested against exactly that vocabulary for that
reason.
READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and
was mid-restructure of exactly the stage matrix this file depends on while
this file was written. Every accessor this module leans on --
`STAGES` / `STAGE_OPTIONS` / `stage_field_key` / `FIELDS` / `label_of` /
`symbol_of` / `help_of` -- is reached through `getattr`/`hasattr` or a
try/except, never assumed. A shape that is not there yet (or has changed
shape) becomes one plain-English sentence in `problems`, and the rest of the
page still renders around the hole -- never a raised exception, and never an
invented value standing in for the one that could not be read. This is the
same "could not judge" vs "judged, found nothing" discipline as everywhere
else in this codebase (see CLAUDE.md SS3): a missing accessor is reported by
name, not silently skipped and not guessed at.
WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry
instead of a fabricated block: Eriksson's own "Handling" pages carry several
things `vibe.panelanalysisform` does not model yet --
* a per-lifter STRUCTURED row (quantity, longitudinal position, two
transverse positions, a tick per stage, "on top face") for Face / Side
Edge Lifters. The form only has `face_lifters` / `edge_lifters` as two
free-text fields ("column @ fraction of width", one line per lifter), so
the structured table is rendered as those two text fields instead of an
invented set of per-row keys nothing in the form can parse or validate.
* a dedicated "top edge lifters" field. There isn't one -- only
`face_lifters` / `edge_lifters` exist -- so the section renders a
sentence saying so, in the same voice Eriksson's own page uses for "no
top edge lifters on this design" (never a blank, per CLAUDE.md SS3's
"nothing here" vs "never asked" rule -- an omitted field and an empty
one must not look the same).
* a "Dunnage" field of any kind. Same treatment.
* the computed Panel Weight / Centroid block. `weight_lb` is a real field
(the user's override, or what the model could read) so its row renders
from `values`/`reads`; the three centroid components (X from start, Y
from bottom, Z from left) are not produced anywhere in this pipeline yet,
so those rows say so in plain words rather than printing a fabricated
0.000.
* the "Lifting Configurations" sketches on Eriksson's second page (one
small diagram per included stage, each with its own G/CG marker) and the
"Face Lifters / Name / Capacity Method" block above them. No
`panelsketch` entry point for either exists (only `plan` and
`weight_diagram` do, per the sibling agent adding them), and no
panelanalysisform field carries a lifter name or capacity-check method --
so both become one `problems` entry apiece and a short explanatory
sentence, not an invented drawing source or an invented field.
THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which
questions make sense for which stage, not a report of which stage happens to
be ticked on right now. Reading Eriksson's own page 25 by column: Rigging
Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt
Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the
two erection stages (Primary, Secondary) and dashed for Stripping and
Transportation in EVERY sample seen, independent of whether a given erection
stage is itself switched on -- a crane pick has a rigging configuration and a
tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below
is that fixed structural map. It is deliberately NOT keyed off each stage's
own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix`
is that switching a stage off must never make its OTHER cells un-requestable
or drop what the user already typed into them, and printing an em dash over a
cell the user could still type into would do exactly that. So the only cells
this module ever marks `{'na': True}` are ones no stage state can turn back
on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide.
Standard-library only. No Revit, no WPF, no `panelsketch` import (a source
name is enough -- see the block vocabulary's 'drawing' kind). Runs on
IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings,
no dataclasses/typing/enum, `'{0}'.format(...)` throughout.
"""
# Editor kind a FIELDS-style `kind` renders as, for the plain fields this
# section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb,
# self_weight_factor, face_lifters, edge_lifters). Anything not listed here
# falls back to a free-text box -- 'supports' / 'openings' / 'strands' are
# all typed as structured-but-still-one-text-box fields on the real form, and
# nothing in the Handling section uses them, but the fallback keeps this
# honest if that ever changes.
_EDITOR_OF_KIND = {
'text': 'text',
'num': 'num',
'int': 'num',
'supports': 'text',
'openings': 'text',
'strands': 'text',
}
# Fallback choice vocabulary for the free-text stage-matrix options, used
# ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no
# choices slot of its own (see `_unpack_stage_option`). Once the row carries
# its own choices, that is authoritative and this is never consulted -- two
# sources of truth for the same list would drift. These are OFFERED choices,
# never a default -- the current value still comes from whatever is in
# `values[key]`, blank if nothing was typed. Picking one is the user's
# decision; the owner's instruction is explicit that none of them stand in
# as a default.
_STAGE_CHOICES_FALLBACK = {
'lift_type': ['Static Lift', 'Rolling Block'],
'concrete_strength': ['Initial', 'In-Place'],
'stress_limits': ['Handling', 'In-Place'],
'rigging': ['Auto'],
}
def _unpack_stage_option(opt):
"""(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS
row, or None if the row cannot be read at all.
Defensive against exactly the kind of mid-refactor shape change this
module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot
on live disk between this module's first draft and this test run (a
7-tuple with the new slot, where the 6-tuple it replaced had none). Both
shapes are accepted; `choices` falls back to
`_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]`
for an option with no fixed vocabulary either way. `drives` (index 5 of
the 7-tuple) is not needed here -- NA-ness is decided structurally by
`_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked."""
try:
opt_key, label, symbol, help_text, kind, _drives, choices = opt
choices = list(choices) if choices else None
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
pass
try:
opt_key, label, symbol, help_text, kind, _drives = opt
choices = _STAGE_CHOICES_FALLBACK.get(opt_key)
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
return None
# The stage-matrix options that only make sense for a stage where the panel
# is actually being picked up and set (Primary / Secondary Erection). See the
# module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether
# a given stage is ticked on.
_ERECTION_ONLY = set([
'rigging', 'sling_angle', 'ground_reaction',
'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries',
])
_NOT_ERECTION_STAGES = set(['Stripping', 'Transportation'])
# Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS`
# by key, grouped the way this page presents them. Every key here is checked
# against the real module before use -- see `_field_block`.
_LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor')
_LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters')
def _import_paf():
"""The real `vibe.panelanalysisform`, or (None, reason).
A separate function so `page()` reads like the rest of this module: one
early check, one plain-English reason, never a raised ImportError.
"""
try:
from vibe import panelanalysisform as paf
except ImportError as exc:
return None, ('vibe.panelanalysisform could not be imported '
'({0}); the Handling section cannot be built at '
'all'.format(exc))
return paf, None
def _field_exists(paf, key):
"""Whether `key` is a real entry in `paf.FIELDS` right now.
Never assumes `FIELDS` itself is present or list-shaped -- the module
this reads from is being restructured by another agent as this is
written.
"""
fields = getattr(paf, 'FIELDS', None)
if not fields:
return False
try:
for f in fields:
if isinstance(f, dict) and f.get('key') == key:
return True
except TypeError:
return False
return False
def _field_kind(paf, key):
for f in getattr(paf, 'FIELDS', None) or []:
if isinstance(f, dict) and f.get('key') == key:
return f.get('kind') or 'text', bool(f.get('required'))
return 'text', False
def _field_block(paf, key):
"""A `{'kind': 'field', ...}` block for a real `panelanalysisform` key.
Caller must have already confirmed `_field_exists(paf, key)`. Labels,
symbols and hints all come from the module's own accessors rather than
being re-typed here, so this page can never drift from the field it is
describing -- and those accessors already degrade to the bare key if
something about the field record is missing, so this cannot raise.
"""
label = paf.label_of(key) if hasattr(paf, 'label_of') else key
symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else ''
hint = paf.help_of(key) if hasattr(paf, 'help_of') else ''
kind, required = _field_kind(paf, key)
editor = _EDITOR_OF_KIND.get(kind, 'text')
return {
'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint or None,
'required': required, 'editor': editor, 'choices': None,
}
def _row_label(label, symbol):
"""'label — symbol', matching `panelanalysisform.display_label`'s own
join -- the symbol rides beside the plain English, never replaces it."""
if symbol:
return u'{0} — {1}'.format(label, symbol)
return label
def _stage_options_editor(kind):
if kind == 'bool':
return 'check'
if kind == 'num':
return 'num'
return 'text'
def _stage_matrix_block(paf, problems):
"""The Stage Options matrix block, or None if it cannot be built.
Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing
or malformed piece is one `problems` sentence naming it, and a row that
cannot be unpacked is skipped rather than raising, so a partial
restructure of the source module degrades this one matrix instead of the
whole page.
"""
stages = list(getattr(paf, 'STAGES', ()) or ())
if not stages:
problems.append('vibe.panelanalysisform has no STAGES list yet; '
'the Stage Options matrix cannot be built')
return None
options = getattr(paf, 'STAGE_OPTIONS', None)
if not options:
problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list '
'yet; the Stage Options matrix cannot be built')
return None
stage_field_key = getattr(paf, 'stage_field_key', None)
if not callable(stage_field_key):
problems.append('vibe.panelanalysisform has no stage_field_key() '
'yet; the Stage Options matrix cells have no keys '
'to write to')
return None
rows = []
for opt in options:
unpacked = _unpack_stage_option(opt)
if unpacked is None:
problems.append('a Handling stage option row has an unexpected '
'shape ({0!r}); skipped'.format(opt))
continue
opt_key, label, symbol, help_text, kind, choices = unpacked
editor = _stage_options_editor(kind)
cells = []
for stage in stages:
if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES:
cells.append({'na': True})
continue
try:
key = stage_field_key(stage, opt_key)
except Exception as exc:
# A broken key builder must not take the whole page down;
# name it and leave that one cell blank instead.
problems.append(
'could not build the form key for {0} / {1} ({2}); that '
'cell is left blank'.format(stage, label, exc))
cells.append(None)
continue
cells.append({'editor': editor, 'key': key, 'choices': choices})
rows.append({'label': _row_label(label, symbol),
'hint': help_text or None, 'cells': cells})
if not rows:
problems.append('every Handling stage option row was unreadable; '
'the Stage Options matrix has nothing to show')
return None
return {'kind': 'matrix', 'columns': stages, 'rows': rows}
def _legend_block():
"""Eriksson's own two-column legend, redrawn generically.
Shape names ('triangle', 'bar') and fill names ('blank', 'light',
'hatch', 'solid', 'tan') are markers for the renderer to draw, not
engineering values -- there is nothing here for `panelanalysisform` to
supply, so this block is static.
"""
return {
'kind': 'legend',
'groups': [
('Shapes', [
('triangle', 'Face lifter'),
('bar', 'Dunnage'),
]),
('Fills', [
('blank', 'Not specified'),
('light', 'Stripping'),
('hatch', 'Erection'),
('solid', 'Stripping + Erection'),
('tan', 'Transportation'),
]),
],
}
def _weight_centroid_block(paf, values, reads, problems):
"""The Panel Weight / Centroid kv block.
Weight comes from the real `weight_lb` field -- the user's override, or
whatever the model could read, exactly like every other prefilled field
on this form -- converted to kips to match Eriksson's own display. The
three centroid components are not computed anywhere in this pipeline
yet, so they say that in plain words rather than printing a fabricated
0.000; see the module docstring.
"""
weight_row = ('Panel weight', 'not entered yet -- computed from the '
'geometry above once you run the analysis')
if _field_exists(paf, 'weight_lb'):
text = (values.get('weight_lb') or '').strip()
if not text and reads:
text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \
else str(reads.get('weight_lb') or '')
if text:
try:
kips = float(text) / 1000.0
weight_row = ('Panel weight',
'{0:.3f} kips (as entered)'.format(kips))
except (TypeError, ValueError):
weight_row = ('Panel weight',
'{0} lb (as entered, not a plain number)'
.format(text))
else:
problems.append('vibe.panelanalysisform has no weight_lb field; '
'panel weight cannot be shown')
not_tracked = 'not tracked on this form yet'
problems.append('panel centroid (X from start / Y from bottom / Z '
'from left) is not computed anywhere in this pipeline '
'yet; the Panel Weight / Centroid block shows weight '
'only')
return {
'kind': 'kv',
'rows': [
weight_row,
('Centroid, X (from start)', not_tracked),
('Centroid, Y (from bottom)', not_tracked),
('Centroid, Z (from left)', not_tracked),
],
}
def _lifter_section_blocks(paf, problems):
"""Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three blocks.
Only `face_lifters` / `edge_lifters` are real fields; the structured
per-lifter table Eriksson prints (qty, two positions, a tick per stage)
has no backing data model here, and "top edge lifters" / "dunnage" have
no field at all. See the module docstring for why each of those is a
sentence and a `problems` entry rather than an invented table.
"""
blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters',
'level': 2}]
any_lifter_field = False
for key in _LIFTER_TEXT_KEYS:
if _field_exists(paf, key):
blocks.append(_field_block(paf, key))
any_lifter_field = True
else:
problems.append('vibe.panelanalysisform has no {0} field; that '
'lifter position cannot be entered'.format(key))
if any_lifter_field:
blocks.append({
'kind': 'sentence',
'text': ('Eriksson prints one row per lifter with a quantity, '
'two positions and a tick per stage; this form does '
'not yet parse the text above into that shape, so '
'each line is kept as typed.'),
})
blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2})
if _field_exists(paf, 'top_edge_lifters'):
# Not expected today, but if a future restructure adds the field,
# render it rather than silently keeping the old sentence.
blocks.append(_field_block(paf, 'top_edge_lifters'))
else:
problems.append('vibe.panelanalysisform has no top edge lifters '
'field; this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no top edge lifters.'})
blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2})
if _field_exists(paf, 'dunnage'):
blocks.append(_field_block(paf, 'dunnage'))
else:
problems.append('vibe.panelanalysisform has no dunnage field; '
'this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no dunnage entered.'})
return blocks
def _lifting_hardware_blocks(problems):
"""Eriksson page 26: the weight-distribution drawing plus a note on what
could not be carried over (see the module docstring's last bullet)."""
blocks = [
{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2},
{'kind': 'drawing', 'source': 'weight_diagram',
'caption': 'PANEL WEIGHT DISTRIBUTION', 'w': 760, 'h': 220},
]
problems.append(
'Eriksson also prints a small lifting-configuration sketch per '
'included stage (a G/CG marker over each stage\'s lift points) '
'and a Face Lifters name / capacity-check method block above the '
'weight distribution; neither has a panelsketch drawing source or '
'a panelanalysisform field yet, so neither is shown')
return blocks
def page(values, reads=None):
"""(blocks, problems) for the Handling section of the Panel Analysis form.
`values` is the form's current string-valued dict, read-only here -- this
function never assigns into it, so the caller's dict comes back
unchanged no matter what this renders. `reads` is the optional dict of
what the model could read off the framing element (the same shape
`panelanalysisform.prefill` takes); it is used only as a fallback for the
weight row when `values` itself is blank, and its absence never raises.
Returns a flat list of block dicts and a list of plain-English problem
sentences -- never raises, per the module docstring's "could not judge"
discipline. An empty `problems` list means every accessor this section
depends on was present and shaped as expected; a non-empty one names
every gap, so the caller can decide whether to surface it to the user
(a stale accessor is worth knowing about even though the page itself
still renders around it).
"""
problems = []
paf, reason = _import_paf()
if paf is None:
problems.append(reason)
return ([{'kind': 'heading', 'text': 'Handling', 'level': 1},
{'kind': 'sentence',
'text': ('The Handling section could not be built -- see '
'the problem above.')}],
problems)
values = values or {}
blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}]
# --- Stage Options matrix -------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2})
matrix_block = _stage_matrix_block(paf, problems)
if matrix_block is not None:
blocks.append(matrix_block)
else:
blocks.append({'kind': 'sentence',
'text': ('The per-stage handling options could not '
'be built -- see the problem above.')})
blocks.append({'kind': 'space', 'h': 8})
blocks.append(_legend_block())
blocks.append({'kind': 'space', 'h': 8})
# --- Panel Weight / Centroid ------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid',
'level': 3})
blocks.append(_weight_centroid_block(paf, values, reads, problems))
blocks.append({'kind': 'rule'})
# --- Load factors (weight override / self-weight factor) --------------
load_factor_blocks = []
for key in _LOAD_FACTOR_KEYS:
if _field_exists(paf, key):
load_factor_blocks.append(_field_block(paf, key))
else:
problems.append('vibe.panelanalysisform has no {0} field'
.format(key))
if load_factor_blocks:
blocks.append({'kind': 'heading', 'text': 'Load Factors',
'level': 3})
blocks.extend(load_factor_blocks)
blocks.append({'kind': 'space', 'h': 8})
# --- plan drawing -------------------------------------------------
blocks.append({'kind': 'drawing', 'source': 'plan',
'caption': 'LIFTER AND DUNNAGE DETAIL', 'w': 760,
'h': 420})
blocks.append({'kind': 'space', 'h': 8})
# --- lifter / dunnage sections -----------------------------------
blocks.extend(_lifter_section_blocks(paf, problems))
blocks.append({'kind': 'rule'})
# --- Lifting Hardware (page 26) ------------------------------------
blocks.extend(_lifting_hardware_blocks(problems))
return blocks, problems
@@ -0,0 +1,365 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the Reinforcement section's block builder.
WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as
one raw eccentricity number in an unlabelled box, and the owner has now said
three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and
finally "全部给我做成普通人能看懂的样子" -- make every part of it something an
ordinary person can understand). This module rebuilds the Reinforcement half
of that form to read like Eriksson Wall's own printed page (strands, rebar,
mesh, interrupts, then the drawn cross-section), while staying an honest INPUT
form rather than a finished report: every optional block that is empty says so
in a sentence, exactly the way Eriksson prints "Design contains no rebar" /
"no mesh" / "no prestressing" rather than leaving a blank space that could
mean either "nothing here" or "nobody has typed it yet".
`page(values, reads=None)` returns `(blocks, problems)` and never raises.
`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see
the button's WPF renderer) -- 'heading', 'columns', 'field', 'table', 'kv',
'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as
nothing, so adding a new one here without also teaching the renderer is a
silent no-op, not a crash -- worth remembering while extending this file.
`problems` is the sentence list the button shows above the section: every
field key this module could not find in `vibe.panelanalysisform`, every
strand size with no known area, and every summary quantity (cgx, ex, ey) that
this module refuses to compute rather than invent.
WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own
rule for `strand_area_in2` (a strand size it does not recognise is refused BY
NAME, never guessed, because an invented area is a fabricated capacity):
* A strand size with no published area never gets a made-up one -- the
table shows the size, refuses the area, and `problems` names the size.
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
never printed. The per-strand `y` `_prestress_from` computes is an even
spread across the panel WIDTH assumed for the Stage-0 solve, not a
position the user typed or the model measured, so presenting it as cgx
would dress up an assumption as a fact. Depth (cgy) IS printed, because
depth-to-CG is exactly what the user types per strand group -- a
count-weighted average of real entered numbers, not an invented one.
* Horizontal strand spacing is not collected anywhere on this form, so no
table here carries a "Spacing" column or any other layout that implies
it is known -- Eriksson's own rebar table has one; this module's strand
table deliberately does not, matching the real page 27 strand table
(which has none either) rather than the rebar table on the same PDF.
WHY `strand_rows` IS READ DEFENSIVELY: a sibling agent is restructuring
`panelanalysisform` to replace the single `ps_ecc_in` box with structured
strand-group rows, exposed on `values['strand_rows']` as
`[{'count', 'depth_in', 'pull_pct'}, ...]`. That key does not exist on
`values` in every revision of that module while the restructure is in
flight, so its absence is read as "waiting on input", never as an error --
this module falls back to parsing the raw `ps_rows` text itself with
`panelanalysisform.parse_strand_rows` (the same parser `validate()` uses) and
says in `problems` that it had to.
No Revit import, no WPF import, no `panelsketch` import -- only
`vibe.panelanalysisform`, for field names/labels/hints and the strand-area
table, exactly as the rest of this input form does. Must run on IronPython
2.7 in the button (`vibe.helpers` never reaches this module) AND on plain
CPython under `WillTools.extension/tests/conftest.py`'s import trick: no
f-strings, no typing, no dataclasses.
"""
from vibe import panelanalysisform as pf
# --------------------------------------------------------------- constants
# Section/field keys this module owns, all declared under the 'Reinforcement'
# section in panelanalysisform.FIELDS. Kept as a list (not re-derived from
# FIELDS by section name) so a key panelanalysisform drops or renames shows up
# as an explicit MISSING in `problems` instead of just vanishing from the page.
_STRAND_FIELD_KEYS = ('strand_rail_location', 'strand_rail_offset_in',
'ps_dia_in', 'ps_pull_lb', 'ps_count', 'ps_loss_pct')
_REBAR_FIELD_KEYS = ('mild_as',)
_MESH_FIELD_KEYS = ('mesh',)
# panelanalysisform has no field for interrupts at all yet -- not "blank",
# genuinely absent. Kept as its own name (rather than an empty tuple reused
# for "no fields") so the one `problems` sentence that explains it is written
# once, beside the constant it is about.
_INTERRUPTS_MISSING = 'interrupts'
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
# page speaks. Nothing here is 'check' or 'choice' -- every Reinforcement
# field is free text or a number.
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
def _field_block(key, problems):
"""A 'field' block for a panelanalysisform key, or None + a MISSING note.
Reads the label/symbol/hint/required-ness straight off panelanalysisform
so this page can never drift from the form it is rendering -- a rename in
that module shows up here as a MISSING problem instead of a stale label.
"""
spec = pf._spec(key)
if spec is None:
problems.append(
"field {0!r} is not declared in panelanalysisform -- this page "
"cannot show it (MISSING)".format(key))
return None
editor = _EDITOR_OF_KIND.get(spec['kind'], 'text')
return {
'kind': 'field', 'key': key, 'label': spec['label'],
'symbol': spec['symbol'] or None, 'hint': spec['help'] or None,
'required': spec['required'], 'editor': editor, 'choices': None,
}
def _text_of(values, key):
"""The current string in `values` for `key`, '' if absent/None.
`values` is meant to be string-valued, but this page must never raise on
a caller's mistake -- a non-string value (an int typed straight into a
dict in a test, a stray object) is coerced rather than crashed on.
"""
raw = values.get(key)
if raw is None:
return ''
if not hasattr(raw, 'strip'):
try:
raw = str(raw)
except Exception:
return ''
return raw.strip()
def _num_of(values, key):
"""(float_or_None, error_or_None) for a numeric field's current text."""
text = _text_of(values, key)
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
return None, 'is not a number ({0!r})'.format(text)
# ------------------------------------------------------------------ strands
def _strand_rows_from(values, problems):
"""[{'count', 'depth_in', 'pull_pct'}, ...] read defensively.
Prefers the structured `values['strand_rows']` a sibling agent is adding;
falls back to parsing the raw `ps_rows` text with panelanalysisform's own
parser (the same one `validate()` uses) when that key is absent, and says
so in `problems` -- never treats the absence as an error by itself.
Genuinely blank input (`strand_rows` absent AND `ps_rows` blank) returns
`[]` with no problem: "nobody has typed any strand groups yet" is a real,
quiet state, not a fault.
"""
raw = values.get('strand_rows')
if isinstance(raw, list):
rows = []
for entry in raw:
try:
count = int(entry['count'])
depth_in = float(entry['depth_in'])
except (KeyError, TypeError, ValueError):
problems.append(
"a strand_rows entry from the form could not be read "
"(missing or non-numeric count/depth_in) -- skipped it "
"rather than guessing its numbers")
continue
pull_raw = entry.get('pull_pct') if hasattr(entry, 'get') else None
pull_pct = None
if pull_raw is not None:
try:
pull_pct = float(pull_raw)
except (TypeError, ValueError):
problems.append(
"a strand_rows entry's pull_pct could not be read "
"({0!r}) -- treated as not given".format(pull_raw))
rows.append({'count': count, 'depth_in': depth_in,
'pull_pct': pull_pct})
return rows
# Fall back to the raw text field this page also renders as a 'field'
# block below, so the user always has somewhere to type even while the
# structured key is not yet on `values`.
text = _text_of(values, 'ps_rows')
if not text:
return []
rows, parse_problems = pf.parse_strand_rows(text)
if parse_problems:
problems.append(
"structured strand_rows was not supplied by the form; tried to "
"read the strand-group text instead and could not: " +
'; '.join(parse_problems))
return []
if rows:
problems.append(
"structured strand_rows was not supplied by the form yet; "
"parsed the raw strand-group text (ps_rows) instead")
return rows
def _strand_block(values, problems):
"""The whole Strands sub-block: rail fields, then table or sentence.
Matches the real page's own shape: the Rail Location / Rail Offset pair
prints unconditionally, and what follows is either the strand table or
the single sentence "Design contains no prestressing" -- never a table
with a header row and no data, and never a blank space.
"""
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
rail_fields = []
for key in ('strand_rail_location', 'strand_rail_offset_in'):
fb = _field_block(key, problems)
if fb is not None:
rail_fields.append([fb])
if rail_fields:
blocks.append({'kind': 'columns', 'columns': rail_fields})
rows = _strand_rows_from(values, problems)
if not rows:
blocks.append({'kind': 'sentence', 'text':
'Design contains no prestressing'})
return blocks
for key in ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_loss_pct'):
fb = _field_block(key, problems)
if fb is not None:
blocks.append(fb)
dia_text = _text_of(values, 'ps_dia_in')
area, area_err = pf.strand_area_in2(dia_text if dia_text else None)
if area_err:
problems.append(area_err)
pull_lb_text = _text_of(values, 'ps_pull_lb')
headers = ['Size', 'Area (in²)', 'Diameter', 'Quantity', '% Pull',
'Depth to CG (in)']
table_rows = []
for row in rows:
size_cell = dia_text if dia_text else '?'
area_cell = ('%.4f' % area).rstrip('0').rstrip('.') if area is not \
None else '?'
if row['pull_pct'] is not None:
pull_cell = ('%g%%' % row['pull_pct'])
elif pull_lb_text:
pull_cell = 'default pull ({0} lb)'.format(pull_lb_text)
else:
pull_cell = 'default pull (not entered)'
depth_cell = ('%g"' % row['depth_in'])
table_rows.append([size_cell, area_cell, size_cell,
str(row['count']), pull_cell, depth_cell])
blocks.append({'kind': 'table', 'headers': headers,
'header_groups': None, 'rows': table_rows,
'row_key': None})
# Strand summary. Aps and cgy are real derived numbers (a count-weighted
# sum/average of what the user actually typed); cgx/ex/ey are refused --
# see the module docstring for why.
if area is not None:
total_count = sum(r['count'] for r in rows)
if total_count > 0:
aps = total_count * area
cgy = sum(r['count'] * r['depth_in'] for r in rows) / \
float(total_count)
kv_rows = [
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.')),
('cgy, in (depth to CG)',
('%.4f' % cgy).rstrip('0').rstrip('.')),
('cgx, in', 'not shown -- horizontal strand position is not '
'collected on this form, only assumed for the '
'solve'),
('ex, in', 'not shown -- see cgx above'),
('ey, in', 'not shown -- needs a defined section centroid, '
'not derived by this page'),
]
blocks.append({'kind': 'kv', 'rows': kv_rows})
problems.append(
'strand summary: cgx and both eccentricities (ex, ey) are '
'not computed -- horizontal strand position on this panel '
'is an assumption made for the solve, not a real input, and '
'printing it as cgx would dress up that assumption as a '
'measured fact')
else:
problems.append(
'strand summary (Aps, cgy) could not be computed -- the strand '
'area is unknown (see the strand-size problem above)')
return blocks
# --------------------------------------------------------------- rebar/mesh
def _rebar_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
fb = _field_block('mild_as', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mild_as'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no rebar'})
return blocks
def _mesh_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
fb = _field_block('mesh', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mesh'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no mesh'})
return blocks
def _interrupts_block(problems):
"""The Interrupts sub-block -- no field exists on the form for this yet.
This is deliberately NOT "Design contains no interrupts": that sentence
would assert a judged, empty answer, and nothing here has judged
anything -- panelanalysisform simply has no key to read. "Could not
judge" and "judged, found nothing" must never look alike (CLAUDE.md
section 3), so this prints the honest could-not-judge sentence and names
the gap in `problems` for whoever is meant to add the field.
"""
problems.append(
"interrupts: panelanalysisform has no field for interrupts yet "
"(MISSING) -- this page cannot say whether the design has any")
return [
{'kind': 'heading', 'text': 'Interrupts', 'level': 2},
{'kind': 'sentence', 'text':
'Could not judge -- this form does not yet collect interrupts '
'data'},
]
# ------------------------------------------------------------------- public
def page(values, reads=None):
"""(blocks, problems) for the Reinforcement section of the input form.
`values` is the form's current string-valued dict (plus, once the
sibling restructure lands, a `strand_rows` list -- see the module
docstring). `reads` is accepted for signature parity with the rest of
this form's per-section builders; nothing under Reinforcement is
prefilled from the model (`panelanalysisform.PREFILLABLE` does not
include any of these keys), so it is not read here. Never raises --
every failure becomes a sentence in `problems` instead.
"""
values = values or {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}]
blocks.extend(_strand_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_rebar_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_mesh_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_interrupts_block(problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.append({
'kind': 'drawing', 'source': 'section',
'caption': 'Reinforcement Cross-Section', 'w': 480, 'h': 360,
})
return blocks, problems
@@ -0,0 +1,666 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Structural Model" section, laid out.
WHY THIS EXISTS: the owner rejected the Panel Analysis form three times as
unreadable ("太丑了", "你这么输入用户上哪儿能看懂") and finally ruled, 2026-09-02,
"全部给我做成普通人能看懂的样子" -- make every part of it something an ordinary
person can understand. The fix is not a paint job: `page()` below rebuilds
the Structural Model section (restraints, bearing, releases, concentrated
loads, and the wind / seismic / temperature / soil load families) to look
like Eriksson Wall's own printed page for it, because that page is the
document a reviewing engineer already knows how to read. Every field still
carries the engineering `symbol` beside its plain-English `label` -- the
Unicode notation is not decoration, it is what gets checked against
Eriksson's own printout -- and every field carries a one-sentence `hint`
saying what it is for and what a blank means, because a symbol alone
("Vult, mph") is exactly the kind of label that got this form rejected.
`page(values, reads=None)` returns `(blocks, problems)` -- a flat list of
BLOCK dicts from a small fixed vocabulary a generic WPF renderer consumes
(an unrecognised `kind` renders as nothing, so this module can only ever
degrade a layout, never crash one), plus a list of plain-English sentences
describing anything that could not be built as designed. `page` never
raises: a field the shared field model does not have yet, or a `values`
dict missing a key entirely, becomes a `problems` sentence and a
stand-in label, not an exception and not an invented value.
TWO RULES FROM CLAUDE.md THIS MODULE IS BUILT AROUND, section 3:
* "Could not judge" and "judged, found nothing" must never look alike.
Every OPTIONAL block here (Releases, Concentrated Loads, the Wind /
Seismic zone tables, both Soil Loads, Additional Self Weight, Design
Strips) has to say something when it has nothing to show, and there
are two different somethings: the key existing in `values` but blank
means the user was asked and answered "none" (Eriksson's own wording,
e.g. "Design contains no exterior soil loads" -- generic boilerplate,
not a client's text, and kept because it is the plain-English answer a
non-engineer would also write); the key not existing in `values` at
all means this form has never asked yet, and the sentence says so
instead of quietly agreeing with "none". Collapsing those two into one
sentence is exactly the failure `knowledge.same_face()` returning
`None` instead of `False` guards against elsewhere in this codebase.
* Nothing here computes wind or seismic. The owner ruled it explicitly,
2026-09-02: "风的计算还没有你就留着空着,让用户输入" -- the wind engine is a
later stage, so every wind/seismic field is collected, labelled as not
yet used by the analysis, and left blank. No pressure, no Kz, no
exposure default, nothing invented to make the page look more finished
than the analysis actually is.
WHERE THE LAYOUT COMES FROM: Eriksson Wall's own printed "Structural
Model" page -- a Restraints table (foundation connection, one row per tie
elevation with a gravity/thermal restraint flag and a stiffness) beside a
Releases block, an elevation diagram, a Concentrated Loads table split into
dead/live/roof/wind-pressure/wind-suction/seismic components, Load
Options, then the Wind / Seismic / Temperature / Soil / Additional Self
Weight load families and a Design Strips note. Only the STRUCTURE and
generic field names are taken from that page -- no project, client,
licensee, engineer, job number, file name or date from it appears
anywhere below; every label is written from scratch in plain engineering
English. See CLAUDE.md's "No client or job information in the repo. Ever."
READING `vibe.panelanalysisform` DEFENSIVELY: that module owns the actual
key names and is being restructured by another agent while this one is
being written. `page()` imports it, reads its `FIELDS` list once to find
out which keys it already knows about, and for every key this section
wants that it does NOT find there, it renders the field anyway (with a
label and hint written locally, right here) and adds one sentence to
`problems` naming the key and what it is for -- never a raised exception,
never a guess passed off as the shared field model's answer. Some of the
keys used below (`supports_ft`, `restraints`, `conc_loads`, every
`wind_*` key, `seismic_cs`, `ext_soil_loads`, `int_soil_loads`) already
exist in `panelanalysisform.FIELDS` today and are read from there;
Eriksson's page has considerably more than that thin set, and the rest are
new key names this module proposes (`restraint_rows`, `releases`,
`wind_zone_rows`, `seismic_zone_rows`, `temp_ext_f`, ... -- see the field
list inline) for the field-model agent to land.
Standard library only, no Revit, no WPF, no `panelsketch` import -- the
`kind: 'drawing'` blocks below only NAME a `vibe.panelsketch` entry point
as their `source`; a sibling agent owns what that module draws.
IronPython 2.7 throughout: no f-strings, `'{0}'.format(...)` everywhere,
`# -*- coding: utf-8 -*-` up top, no `pathlib` / `dataclasses` / `typing`.
"""
import re
# --------------------------------------------------------------------------
# Small helpers shared by every block below. None of these touch `values` --
# they only ever read it, and `page()` never assigns into a caller-owned
# dict, so the same `values` a caller hands in comes back untouched.
# --------------------------------------------------------------------------
def _text(value):
"""Coerce whatever sits in `values` to a string, defensively.
`values` is documented as string-valued, but this form is fed by a
live model read and a WPF text box on one side and a mid-refactor
field model on the other -- a stray non-string (an `int` typed
straight into a dict during a test, a `None` a half-written prefill
left behind) must degrade to an empty string, never raise.
"""
if value is None:
return ''
try:
value.strip # duck-typed string check -- works for str/unicode both
return value
except AttributeError:
try:
return str(value)
except Exception:
return ''
def _known_field_keys(paf):
"""The set of keys `vibe.panelanalysisform.FIELDS` already defines.
Empty set (never an exception) if the module failed to import or its
`FIELDS` list could not be read -- the caller has already logged why in
`problems` by the time this runs, and an empty set makes every field
below fall through to its local stand-in label, which is the correct
degrade rather than a crash.
"""
if paf is None:
return set()
try:
return set(f['key'] for f in paf.FIELDS)
except Exception:
return set()
def _field(paf, known, problems, key, required, editor, choices=None,
fallback_label=None, fallback_symbol='', fallback_hint='',
what=''):
"""One `kind: 'field'` block, sourced from `panelanalysisform` when it
already knows `key`, and from the fallback text passed in when it does
not -- appending one `problems` sentence in that second case, naming
the key and what it is for, so the field-model agent has a punch list
rather than a form that silently drifted from `panelanalysisform`.
"""
if key in known:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or None
else:
label = fallback_label or key
symbol = fallback_symbol or None
hint = fallback_hint or None
problems.append(
"'{0}' ({1}) has no entry yet in vibe.panelanalysisform -- "
'shown with a stand-in label until the field model adds it.'
.format(key, what or fallback_label or key))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
def _optional_note(values, key, blank_sentence, missing_sentence, problems,
what):
"""(state, text) for one optional free-text block.
Three states, and the whole point of this helper is that the first two
never look alike:
'missing' -- `key` is not in `values` at all. This form has not asked
the question yet; `text` is `missing_sentence` and one
line is appended to `problems` naming the gap. This is
"could not judge", never "judged, found nothing".
'blank' -- `key` is in `values` but empty/whitespace. The user was
asked and the honest answer is none; `text` is
`blank_sentence`. This IS "judged, found nothing".
'filled' -- `key` has real text; `text` is that text, stripped, for
the caller to parse into a table.
"""
if key not in values:
problems.append(
"'{0}' ({1}) is not collected by this form yet -- rendered as "
"unknown, never silently as \"none\"."
.format(key, what))
return 'missing', missing_sentence
text = _text(values.get(key)).strip()
if not text:
return 'blank', blank_sentence
return 'filled', text
def _rows_from_lines(text, extra_cols=0):
"""[[id, col, col, ...], ...] -- one row per non-blank line, comma-split.
This is a DISPLAY parse, not `panelanalysisform`'s validating one: a
line that does not split into the expected shape is shown with
whatever columns it did produce rather than refused, because this
table exists to show the user what they typed back at them, not to
gate the Run button (that gate is `panelanalysisform.validate`'s job,
on the raw field text, elsewhere).
"""
rows = []
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
cols = [p.strip() for p in line.split(',')]
rows.append([str(i)] + cols)
return rows
def _restraint_rows_from(values, problems):
"""(rows, state) for the Restraints table's ID/Elevation/Type/Gravity/
Thermal/Stiffness columns.
Prefers the full `restraint_rows` key (one line per restraint: elevation,
type, gravity y/n, thermal y/n, stiffness) once the field model carries
it. Until then this falls back to the existing `restraints` key, which
today is elevations only -- so the Elevation column still fills in from
whatever the user already typed, and the Type/Gravity/Thermal/Stiffness
columns show blank rather than inventing a restraint type nobody typed.
Both cases add a `problems` line; only the outcome (which columns come
from where) differs.
"""
if 'restraint_rows' in values:
text = _text(values.get('restraint_rows')).strip()
if not text:
return [], 'blank'
rows = []
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split(',')]
parts = (parts + ['', '', '', '', ''])[:5]
rows.append([str(i)] + parts)
return rows, ('filled' if rows else 'blank')
problems.append(
"'restraint_rows' (restraint type, gravity/thermal restraint flags "
"and stiffness per elevation) has no entry yet in "
"vibe.panelanalysisform -- only plain elevations ('restraints') "
'are collected today, so those four columns show blank rather '
'than an invented restraint type.')
if 'restraints' not in values:
return [], 'missing'
text = _text(values.get('restraints')).strip()
if not text:
return [], 'blank'
toks = [t for t in re.split(r'[,\n]+', text) if t.strip()]
rows = []
for i, tok in enumerate(toks, 1):
rows.append([str(i), tok.strip(), '', '', '', ''])
return rows, 'filled'
def _table_from_text(text, headers):
return {'kind': 'table', 'headers': headers, 'header_groups': None,
'rows': _rows_from_lines(text), 'row_key': None}
# --------------------------------------------------------------------------
# page()
# --------------------------------------------------------------------------
def page(values, reads=None):
"""(blocks, problems) for the Structural Model section.
`values` is the form's current string-valued dict, keyed by
`vibe.panelanalysisform` field names (plus new names this module
proposes -- see the module docstring). Never mutated: every access
below is `values.get(...)` or `key in values`, nothing is assigned
into it. `reads` is accepted for symmetry with the rest of the form
(a future model-read prefill for this section) but nothing here is on
`panelanalysisform.PREFILLABLE` today, so it is not read.
Never raises. A field the shared field model does not know yet, an
unreadable `panelanalysisform`, or `values` missing a key entirely all
degrade to a `problems` sentence plus a reasonable stand-in in the
layout -- never an exception, never a fabricated value.
"""
values = values if values is not None else {}
problems = []
blocks = []
paf = None
try:
from vibe import panelanalysisform as paf
except Exception as exc:
problems.append(
'could not import vibe.panelanalysisform ({0}); every field '
'below is shown with a locally-written label instead of the '
'shared field model.'.format(exc))
known = _known_field_keys(paf)
blocks.append({'kind': 'heading', 'text': 'Structural Model', 'level': 1})
blocks.append({'kind': 'sentence', 'text':
'How this panel is held up, tied back and loaded once it is '
'standing in place, separate from its geometry and how it is '
'handled before it gets there.'})
# --------------------------------------------------------- Restraints
restraints_col = [
{'kind': 'heading', 'text': 'Restraints', 'level': 2},
{'kind': 'sentence', 'text':
'Where the panel is connected to the foundation, and then, '
'going up, where it is tied back to each floor as the '
'building is built around it.'},
]
restraints_col.append(_field(
paf, known, problems, 'restraint_foundation_connection', False,
'choice', choices=['Pin', 'Fixed', 'Roller', 'Slide'],
fallback_label='Foundation connection type',
fallback_hint='How the bottom of the panel is attached to the '
'foundation: pinned (free to rotate), fixed, on a '
'roller, or free to slide. Leave blank if this is '
'not decided yet.',
what='the foundation connection type'))
restraints_col.append(_field(
paf, known, problems, 'restraint_floor_tie', False, 'check',
fallback_label='Add a floor tie',
fallback_hint='Check this to add another restraint row above the '
'foundation for a floor tying the panel back. Leave '
'unchecked if there are no floor ties.',
what='whether an extra floor-tie restraint row is added'))
restraints_col.append({'kind': 'sentence', 'text':
"Gravity restraint means that tie also carries the panel's own "
'weight at that point; thermal restraint means it stops the '
'panel from bowing out of plane if one face gets hotter than the '
'other.'})
rrows, rstate = _restraint_rows_from(values, problems)
if rstate == 'missing':
restraints_col.append({'kind': 'sentence', 'text':
'No restraint elevations have been entered on this form yet '
'-- this is unknown, not "no restraints".'})
elif rstate == 'blank':
restraints_col.append({'kind': 'sentence', 'text':
'Design contains no restraints.'})
else:
restraints_col.append({'kind': 'table',
'headers': ['ID', 'Elevation (ft)', 'Type', 'Gravity Rest.',
'Thermal Rest.', 'Stiffness (k/in)'],
'header_groups': None, 'rows': rrows, 'row_key': None})
# ------------------------------------------------------------ Releases
releases_col = [
{'kind': 'heading', 'text': 'Releases', 'level': 2},
{'kind': 'sentence', 'text':
'Any restraint above that is deliberately freed in one '
'direction -- for example a tie that carries the weight but '
'is left free to slide sideways.'},
]
rel_state, rel_text = _optional_note(
values, 'releases',
blank_sentence='Design contains no releases.',
missing_sentence='Releases are not collected on this form yet -- '
'this is unknown, not "no releases".',
problems=problems, what='deliberately-freed restraint directions')
if rel_state == 'filled':
releases_col.append(_table_from_text(rel_text, ['ID', 'Release']))
else:
releases_col.append({'kind': 'sentence', 'text': rel_text})
blocks.append({'kind': 'columns',
'columns': [restraints_col, releases_col]})
blocks.append({'kind': 'drawing', 'source': 'elevation',
'caption': 'Restraint and support elevations',
'w': 640, 'h': 160})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------------- Bearing
blocks.append({'kind': 'heading', 'text': 'Bearing for this analysis',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Which elevations actually hold the panel up for THIS run -- the '
'analysis needs at least two, and they do not have to be every '
'restraint listed above.'})
blocks.append(_field(paf, known, problems, 'supports_ft', True, 'text',
what='the bearing positions the self-weight solve uses'))
blocks.append({'kind': 'space', 'h': 8})
# ---------------------------------------------------- Concentrated Loads
blocks.append({'kind': 'heading', 'text': 'Concentrated Loads',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Extra point loads hung on the panel once it is standing -- a '
'ledger, a beam pocket, attached equipment -- and where along the '
'wall each one acts. A positive offset pulls toward the outside '
'face of the wall.'})
cl_state, cl_text = _optional_note(
values, 'conc_loads',
blank_sentence='No concentrated point loads entered -- design '
'assumes none.',
missing_sentence='Concentrated loads are not collected on this '
'form yet -- this is unknown, not "no point '
'loads".',
problems=problems, what='point loads once the panel is in place')
if cl_state == 'filled':
blocks.append(_table_from_text(
cl_text, ['ID', 'Elevation / offset / dead / live (as typed)']))
problems.append(
"'conc_loads' only carries elevation, offset, dead and live "
"today; Eriksson's own table also splits each point load into "
'roof, wind pressure, wind suction and seismic components -- '
'those columns have no key yet in vibe.panelanalysisform.')
else:
blocks.append({'kind': 'sentence', 'text': cl_text})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Load Options
blocks.append({'kind': 'heading', 'text': 'Load Options', 'level': 2})
blocks.append(_field(
paf, known, problems, 'load_opt_pattern_live', False, 'check',
fallback_label='Pattern live loads for flexure',
fallback_hint='Whether live load is applied in a checkerboard '
'pattern (only some spans loaded at once) when '
'checking bending, instead of everywhere at once at '
'the same time. Leave unchecked if unsure.',
what='whether live load is patterned for the bending check'))
blocks.append(_field(
paf, known, problems, 'load_opt_selfweight_ecc_in', False, 'num',
fallback_label='Self-weight eccentricity', fallback_symbol='in',
fallback_hint="How far the panel's own weight is assumed to act "
'off its centerline. Leave blank to assume it acts '
'right on the centerline.',
what='the self-weight eccentricity'))
blocks.append(_field(
paf, known, problems, 'load_opt_offset_from', False, 'choice',
choices=['Panel CG', 'Other'],
fallback_label='Offset measured from',
fallback_hint='What point the point-load offsets above are '
"measured from. Leave blank to assume the panel's "
'own center of gravity.',
what='what point-load offsets are measured from'))
blocks.append({'kind': 'rule'})
# ------------------------------------------------------------- Wind
blocks.append({'kind': 'heading', 'text': 'Wind', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Nothing in this tool computes a wind pressure yet -- that engine '
'is a later stage. Every field below is collected so nothing '
'typed here is lost, and every one of them is plainly not used '
'by the analysis until the engine exists.'})
wind_fields = (
('wind_speed_vult', 'num', None, 'Design wind speed', 'Vult, mph'),
('wind_risk_category', 'text', None, 'Risk category', ''),
('wind_exposure', 'text', None, 'Exposure category', ''),
('wind_enclosure', 'text', None, 'Enclosure classification', ''),
('wind_asce_version', 'text', None, 'ASCE code version', ''),
('wind_cc_chapter', 'text', None,
'Components & Cladding chapter used', ''),
('wind_mwfrs_chapter', 'text', None,
'Main wind force resisting system chapter used', ''),
('wind_pressure_psf', 'num', None,
'Wind pressure (typed directly)', 'psf'),
('wind_suction_psf', 'num', None,
'Wind suction (typed directly)', 'psf'),
)
for key, editor, choices, flabel, fsym in wind_fields:
blocks.append(_field(
paf, known, problems, key, False, editor, choices=choices,
fallback_label=flabel, fallback_symbol=fsym,
fallback_hint='Not yet used by the solve -- nothing computes '
'wind pressure yet.', what=flabel.lower()))
wz_state, wz_text = _optional_note(
values, 'wind_zone_rows',
blank_sentence='No wind pressure zones entered.',
missing_sentence='Wind pressure zones (start/end elevation, '
'pressure or suction, magnitude) are not '
'collected on this form yet -- this is unknown, '
'not "no wind zones".',
problems=problems, what='wind pressure zones along the height')
if wz_state == 'filled':
blocks.append(_table_from_text(
wz_text,
['ID', 'Start / end / type / magnitude (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': wz_text})
blocks.append({'kind': 'rule'})
# ----------------------------------------------------------- Seismic
blocks.append({'kind': 'heading', 'text': 'Seismic', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Nothing in this tool computes a seismic force yet either -- the '
'same later-stage engine as wind. Shown here so it is not lost, '
'not used by the analysis.'})
blocks.append(_field(paf, known, problems, 'seismic_cs', False, 'num',
what='the seismic coefficient, as a percent of dead load'))
blocks.append(_field(
paf, known, problems, 'seismic_zone_location', False, 'text',
fallback_label='Seismic zone location(s)',
fallback_hint='Which part of the panel height the seismic load '
'applies over (e.g. full height). Not yet used by '
'the solve.',
what='where along the panel the seismic load applies'))
blocks.append(_field(
paf, known, problems, 'seismic_apply_to', False, 'text',
fallback_label='Apply seismic multiplier to',
fallback_hint='Which loads the seismic coefficient multiplies '
'(dead load, self weight, or both). Not yet used '
'by the solve.',
what='what the seismic coefficient is applied to'))
sz_state, sz_text = _optional_note(
values, 'seismic_zone_rows',
blank_sentence='No seismic load zones entered.',
missing_sentence='Seismic load zones (start/end position, load '
'percentage) are not collected on this form yet '
'-- this is unknown, not "no seismic zones".',
problems=problems, what='seismic load zones along the height')
if sz_state == 'filled':
blocks.append(_table_from_text(
sz_text, ['ID', 'Start / end / seismic load % (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': sz_text})
blocks.append({'kind': 'rule'})
# ------------------------------------------------------- Temperature
blocks.append({'kind': 'heading', 'text': 'Temperature Loads',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'How much hotter or colder one face of the panel gets than the '
'other, and how stiff the concrete is treated as being while it '
'moves. Not yet used by the solve.'})
temp_fields = (
('temp_ext_f', 'Exterior temperature', 'deg F'),
('temp_int_f', 'Interior temperature', 'deg F'),
('temp_elasticity_mult', 'Thermal elasticity multiplier', ''),
('temp_alpha_t', 'Coefficient of thermal expansion', u'αT × 10⁻⁶'),
)
for key, flabel, fsym in temp_fields:
blocks.append(_field(
paf, known, problems, key, False, 'num',
fallback_label=flabel, fallback_symbol=fsym,
fallback_hint='Not yet used by the solve.',
what=flabel.lower()))
blocks.append({'kind': 'rule'})
# ------------------------------------------------------------ Soil
blocks.append({'kind': 'heading', 'text': 'Soil Loads', 'level': 2})
ext_col = [{'kind': 'heading', 'text': 'Exterior Soil Loads',
'level': 3}]
es_state, es_text = _optional_note(
values, 'ext_soil_loads',
blank_sentence='Design contains no exterior soil loads.',
missing_sentence='Exterior soil loads are not collected on this '
'form yet -- this is unknown, not "no exterior '
'soil loads".',
problems=problems, what='soil pressing on the outside face')
ext_col.append({'kind': 'sentence', 'text': es_text})
if es_state == 'filled':
ext_col.append(_field(
paf, known, problems, 'ext_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the exterior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='exterior soil elevation'))
ext_col.append(_field(
paf, known, problems, 'ext_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='exterior soil surcharge pressure'))
int_col = [{'kind': 'heading', 'text': 'Interior Soil Loads',
'level': 3}]
is_state, is_text = _optional_note(
values, 'int_soil_loads',
blank_sentence='Design contains no interior soil loads.',
missing_sentence='Interior soil loads are not collected on this '
'form yet -- this is unknown, not "no interior '
'soil loads".',
problems=problems, what='soil pressing on the inside face')
int_col.append({'kind': 'sentence', 'text': is_text})
if is_state == 'filled':
int_col.append(_field(
paf, known, problems, 'int_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the interior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='interior soil elevation'))
int_col.append(_field(
paf, known, problems, 'int_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='interior soil surcharge pressure'))
blocks.append({'kind': 'columns', 'columns': [ext_col, int_col]})
blocks.append({'kind': 'rule'})
# ------------------------------------------------- Additional self weight
blocks.append({'kind': 'heading', 'text': 'Additional Self Weight',
'level': 2})
asw_state, asw_text = _optional_note(
values, 'addl_sw_magnitude_plf',
blank_sentence='No additional self weight entered -- the '
"analysis uses only the panel's own concrete "
'weight.',
missing_sentence='Additional self weight is not collected on '
'this form yet -- this is unknown, not "none".',
problems=problems, what='extra weight added over part of the panel')
if asw_state == 'missing':
blocks.append({'kind': 'sentence', 'text': asw_text})
else:
if asw_state == 'blank':
blocks.append({'kind': 'sentence', 'text': asw_text})
blocks.append(_field(
paf, known, problems, 'addl_sw_start_in', False, 'num',
fallback_label='Start from bottom', fallback_symbol='in',
fallback_hint='Where the extra weight starts, measured up '
'from the bottom of the panel. Not yet used by '
'the solve.', what='additional self weight start'))
blocks.append(_field(
paf, known, problems, 'addl_sw_end_in', False, 'num',
fallback_label='End from top', fallback_symbol='in',
fallback_hint='Where the extra weight ends, measured down '
'from the top of the panel. Not yet used by '
'the solve.', what='additional self weight end'))
blocks.append(_field(
paf, known, problems, 'addl_sw_magnitude_plf', False, 'num',
fallback_label='Magnitude', fallback_symbol='plf',
fallback_hint='How much extra weight per foot of height is '
'added over that stretch. Leave blank for none. '
'Not yet used by the solve.',
what='additional self weight magnitude'))
blocks.append({'kind': 'drawing', 'source': 'weight_diagram',
'caption': 'Point loads and self weight along the wall',
'w': 640, 'h': 200})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Design Strips
blocks.append({'kind': 'heading', 'text': 'Design Strips', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Whether the panel is analysed as one member across its full '
'width, or split into narrower strips first.'})
blocks.append(_field(
paf, known, problems, 'design_strips_method', False, 'choice',
choices=['Single Member', 'Design Strips'],
fallback_label='Analysis method',
fallback_hint='Whether the whole cross section is analysed as one '
'member, or split into design strips. Blank uses '
'the whole cross section. Not yet used by the '
'solve.', what='the design-strip analysis method'))
ds_state, ds_text = _optional_note(
values, 'design_strips_rows',
blank_sentence='No design strips are defined; the entire cross '
'section will be used.',
missing_sentence='Design strips are not collected on this form '
'yet -- this is unknown, not "no strips".',
problems=problems, what='the design strip boundaries')
if ds_state == 'filled':
blocks.append(_table_from_text(ds_text, ['ID', 'Strip (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': ds_text})
return blocks, problems
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""Where the PLANT knowledge layer lives on disk.
Split out of `designspec` for one reason: `designspec` imports the Revit API
at module scope (evidence scraping needs a live document), and choosing the
plant knowledge file is pure path arithmetic that a host CPython test can and
should exercise directly. `designspec` re-exports every name here, so nothing
that already says `designspec.plant_knowledge_path()` had to change.
THE LAYOUT, in one line:
<root>/knowledge.yaml the machine-wide plant file
<root>/<plant>/knowledge.yaml one folder per plant
A job's own knowledge file names its plant in `job.plant`; `knowledge.load()`
reads that name BEFORE it merges anything and passes it here. A job that
names no plant reads the machine-wide file, which is how every install worked
before per-plant folders existed and still does.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=invalid-name
import os
import re
from vibe.compat import string_types
# The plant-wide knowledge file: one per machine, above every project.
PLANT_FOLDER = 'WillTools'
PLANT_ENV = 'WILLTOOLS_KNOWLEDGE'
# What a plant id may be made of, because it is used as a PATH COMPONENT.
# It arrives from a YAML file, so `..`, a slash or a drive letter in it would
# be a directory traversal written by whoever edits a job file. Anything
# outside this set is refused outright rather than scrubbed into something
# else: a plant id that quietly became a different folder name is exactly the
# silence this codebase keeps paying for.
_PLANT_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
def valid_plant_id(plant):
"""Is this string usable as a plant id, i.e. as one folder name?
Returns False for None, for the empty string, and for anything carrying a
separator, a drive letter or a dot-dot. See _PLANT_ID_RE.
"""
if not plant or not isinstance(plant, string_types):
return False
return bool(_PLANT_ID_RE.match(plant.strip()))
def plant_root():
"""The folder the plant knowledge lives in -- one file, or one per plant.
THE ROOT IS A FOLDER AND THE MACHINE-WIDE FILE IS `knowledge.yaml` INSIDE
IT. That relationship has always held (%APPDATA%/WillTools/knowledge.yaml)
and is what lets WILLTOOLS_KNOWLEDGE keep meaning what it always meant
while also naming a root:
set to a FOLDER that folder is the root. Nothing else to say.
set to a FILE that file is the machine-wide plant file, so its
DIRECTORY is the root, by the same relationship.
Not a guess -- the definition, read backwards.
unset %APPDATA%/WillTools, as ever.
Which is why a machine already pointing the variable at a single file
picks up per-plant folders with no change to the variable: the folders
are siblings of the file it already names.
A path that is set but exists as neither is treated as a FILE, because
that is what the variable has meant for its whole life and the operator
almost certainly meant a file that has moved -- the missing path is then
reported by name instead of a different, existing path being read in
silence.
"""
override = os.environ.get(PLANT_ENV)
if not override:
base = os.environ.get('APPDATA') or os.path.expanduser('~')
return os.path.join(base, PLANT_FOLDER)
if os.path.isdir(override):
return override
return os.path.dirname(override) or override
def plant_knowledge_path(plant=None):
"""The plant knowledge file this run should read.
No plant named -- the ordinary case, and every install that predates the
key -- returns the machine-wide file exactly as this function always has:
WILLTOOLS_KNOWLEDGE if it names one, else <root>/knowledge.yaml.
A plant named (`job.plant` in the job layer) returns
<root>/<plant>/knowledge.yaml. A SECOND PLANT IS A NEW FOLDER, never an
edit to the first one's file. See plant_root() for what <root> is and why
a machine already pointing the variable at a single file needs no change.
The per-plant FOLDER and the machine-wide FILE share a root and cannot
collide: one is a directory, the other is called `knowledge.yaml`.
An INVALID plant id -- a separator, a `..`, an empty string -- resolves to
NOTHING and returns None. load() reports it. Scrubbing a malformed name
into some nearby folder is how a run ends up on another plant's rules
while looking entirely normal.
"""
if plant is None:
override = os.environ.get(PLANT_ENV)
if override and not os.path.isdir(override):
return override # the single-file arrangement, untouched
return os.path.join(plant_root(), 'knowledge.yaml')
if not valid_plant_id(plant):
return None
return os.path.join(plant_root(), plant.strip(), 'knowledge.yaml')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,807 @@
# -*- coding: utf-8 -*-
"""Reading the model for the reinforcing index. The Revit half only.
The split is the one `ticketpage.py` / `ticketchecks.py` already models:
everything that can be decided without a document lives in
`vibe.reinforcecore` and is unit-tested on host CPython; this file does the
gathering and nothing else. It is READ ONLY -- no Transaction, no parameter
write, no delete, anywhere in it, and there is no reason for that to change:
the feature writes nothing to the model.
WHERE THE CACHE LIVES
---------------------
Beside the model, under the same `_ticketqc` root the ticket evidence and
specs already use (`designspec.root_dir`), in `reinforce/index.json`. It
holds job geometry, so it may not enter the code repository; that root is
gitignored and, when `WILLTOOLS_JOBS` is set, is not inside the repository at
all. Nothing about this index is portable between models, so the model's own
folder is also where it is actually useful.
WHAT THE COST ACTUALLY IS -- measured, this model, 2026-08-30
-------------------------------------------------------------
755 assemblies collected + both flags read 0.29 s
every assembly's cheap signature 0.71 s
full geometry build, all 690 eligible donors 6.9 s (10 ms each)
slowest single piece 0.061 s
serialise + write the finished index (983 kB) 2.0 s
---- what a button press actually costs ----
full rebuild, cold cache 10.1 s
full rebuild, first press after a Revit restart 16.2 s
refresh with nothing changed 2.3 s
read the index without refreshing 1.4 s
---- schema 5, same model, measured 2026-08-30 ----
assemblies in the model 755
eligible donors 690
in-scope pieces added as TARGETS 28 (WP, not donors)
pieces in the file 718
index size 1.01 MB
Quote a rebuild as "~10-16 s", not as ten: the lower number was measured in a
session that had been open for hours, and the first press after a restart is
the one the owner actually experiences.
That is not the 30-60 s per piece this feature was warned about, and the
difference is worth stating because it moves the design: the expensive read
in this project is GENERATING A VIEW -- what `read_ticket` pays when it walks
a ticket sheet -- and nothing here touches a view. `concrete_solid` asks the
panel element for its own geometry and `_openings` reads faces off that
solid. So the index is not expensive to build; it is still cached, because
the owner's constraint is that a button press must not re-analyse the model,
and 6 s of frozen UI per click is 6 s too many.
Honesty about that number: this session was already open when it was
measured. Only a Revit restart restores a truly cold cache and a restart is
the owner's to make. The structural argument -- no view is generated -- is
what the number rests on, not a claim that the session was cold.
"""
# pylint: disable=import-error,broad-except,invalid-name
import os
import time
from vibe import reinforcecore as core
FOLDER = 'reinforce'
FILENAME = 'index.json'
# The two instance parameters on the assembly that decide donor eligibility.
# Named here rather than in the pure module because they are this job's
# ticketing-system parameters, read by name off the element.
P_REINFORCED = 'TICKET_IS_REINFORCED'
P_ON_HOLD = 'ON_HOLD'
# ------------------------------------------------------- the review signal
#
# WHERE 'MASTER' ACTUALLY LIVES -- measured on this model, 2026-08-30, before
# anything was written to match on it, because a guess about a hand-typed
# field is the kind of thing that quietly matches nothing.
#
# The owner said "identity description". Four candidate free-text fields were
# scanned across all 755 assemblies -- the assembly's identity comment, the
# panel instance's identity comment, and two identity fields on the panel
# type and instance. Only the first two ever contain the word:
#
# assembly identity comment 18 pieces <- what this reads
# panel-instance identity comment 16 pieces (a strict subset of it)
# panel-type identity field 0 -- 5 distinct values, all of the
# form 'PRECAST CONCRETE WALL
# PANEL'; a product class, not a
# review state
# instance short identity field 0 -- a category stub per piece
#
# The assembly field is a SUPERSET of the instance field on this model: every
# one of the 18 carries it on the assembly, two carry it ONLY there, and none
# carries it only on the instance. So this reads the assembly and nothing
# else -- one parameter read, no member walk, and the same source in the cheap
# signature pass as in the full extract, which is what keeps the staleness
# stamp honest about this field.
#
# THE PARAMETER IS NAMED BY ROLE, NEVER BY LITERAL. This extension is the
# shareable half of the project and a shared-parameter name is plant
# knowledge: another office's template calls the same field something else,
# and a name written into code is a plant that cannot be served without
# editing the code. `parameters.member.identity_description` is the role;
# `knowledge.param_names` resolves it to the office's real name(s), tried in
# order.
#
# UNMAPPED MEANS NOT RUN, NEVER A FALLBACK. With no name behind the role the
# signal STANDS DOWN: every piece gets `master = None`, which is the project's
# could-not-judge value and is not False. It must not quietly guess a name --
# that would work in this office and silently do nothing in every other one,
# while the report went on printing "no donor here is reviewed" as though
# somebody had looked. A plant that genuinely has no such field says so the
# way every other role does, under `parameters.not_applicable`.
R_IDENTITY = 'member.identity_description'
def master_token(know=None):
"""The plant's own word for a reviewed template.
The default lives in `reinforcecore.DEFAULT_MASTER_TOKEN`, so a plant
that maps the field but uses another word only has to set the key.
The path is written out as a LITERAL rather than through a module
constant on purpose: `tools/knowledge_drift.py` matches literal paths and
a key reached through a variable lands in its blind-spot list instead of
being credited with a reader -- which is how a key ends up reported as
declared-and-never-read while a check is quietly using it.
"""
from vibe import knowledge
return knowledge.get(know, 'reinforce.master_token',
core.DEFAULT_MASTER_TOKEN)
def identity_params(know=None):
"""The office's name(s) for the identity field, or [] when unmapped."""
from vibe import knowledge
return knowledge.param_names(know, R_IDENTITY)
def master_of(el, names, token):
"""(master, note) off one element. `master` is None when NOT RUN.
None is could-not-judge -- the role is unmapped and nothing was looked at.
False is judged: the field was read and does not carry the token.
"""
if not names:
return None, None
for name in names:
note = _text(el, name)
if note:
return core.is_master_text(note, token), note
return False, None
# ------------------------------------------------- the elevation accelerator
#
# THE OPTIONAL FAST PATH, NEVER THE SOURCE OF TRUTH. The source of truth for
# same-elevation is geometric (`_elevation_band_of`, below, in the expensive
# pass) and is computed for every piece regardless of this section. This is
# only a shortcut: a plant-declared GROUPING KEY a wall may already carry
# for an unrelated reason -- MC's is `Tagged_Sheet`, the drafting sheet a
# wall's elevation is drawn on. Owner, 2026-09-02: "对MC而言,所有的墙应该都
# 有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个
# elevation,不用跑哪个几何引擎。" Two panels sharing a non-empty value here
# are the same elevation with no geometry involved.
#
# ABSENCE IS THE NORMAL STATE, not a stand-down worth reporting. Owner, the
# same day, correcting the scope: other plants carry no such parameter at
# all, and even on MC it exists only on complex jobs -- a simple job with a
# handful of elevations carries none, by a deliberate human-vs-computer
# workload balance. So an unmapped role, or a blank value on a piece, is
# expected far more often than not, and `reinforcecore.elevation_match`
# simply falls through to the geometric floor without comment.
#
# Read off the PANEL, exactly as `design_number` is (by role, never a
# literal name) -- `parameters.panel.elevation_group`.
R_ELEVATION_GROUP = 'panel.elevation_group'
def _panel_member(doc, asm):
"""The Structural Framing member of one assembly, cheaply -- no geometry.
The same walk `extract()` does to find the panel it measures, pulled out
so the CHEAP pass can find it too: reading `elevation_group` needs the
panel, and the signature has to see the same source `extract()` records
or the two could disagree about what changed.
"""
try:
member_ids = list(asm.GetMemberIds())
except Exception:
return None
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing':
return member
return None
def elevation_group_of(doc, asm, know=None):
"""The plant's elevation-grouping text off one assembly's panel, or None.
None means could-not-judge, for any of: no Structural-Framing member to
read it off, the role is unmapped (the common case -- see the section
note above), or the field is blank on this piece. `Q.rstr` already
returns None for an unmapped role, the same stand-down `design_number`
uses. Never raises: a piece this cannot be read for still has the
geometric floor to fall back on.
"""
panel = _panel_member(doc, asm)
if panel is None:
return None
from vibe import ticketqc as Q
try:
return Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
return None
def index_path(doc):
from vibe import designspec
return os.path.join(designspec.sub_dir(doc, FOLDER), FILENAME)
def index_path_in(job_folder):
"""The index inside one model's job folder. THE OFFLINE RESOLVER.
It takes a folder rather than a document, so nothing on this path imports
Revit -- `index_path` reaches `designspec`, which imports the Revit API at
module scope, and that single line is what would otherwise drag the whole
API into an offline run. It also does not CREATE anything: reading an
index must never leave a folder behind on a machine that has no model.
The layout is `designspec`'s and is repeated here rather than shared,
because the doc-side function is the one that knows about the model-folder
and APPDATA fallbacks. When WILLTOOLS_JOBS is set -- the case that matters
offline -- the whole path is `<jobs root>/<model title>/reinforce/index.json`
and `jobs_folder()` builds the first half of it.
"""
return os.path.join(job_folder, FOLDER, FILENAME)
def jobs_folder(jobs_root, model):
"""`<jobs root>/<model title>` -- what WILLTOOLS_JOBS means, offline."""
return os.path.join(jobs_root, model or 'untitled')
def doc_key(doc):
"""Which model a cache belongs to, without writing a path into the repo."""
try:
return doc.Title or ''
except Exception:
return ''
# ------------------------------------------------------------- the cheap pass
def _name_of(el):
from Autodesk.Revit.DB import Element
try:
return Element.Name.GetValue(el)
except Exception:
return None
def _text(el, pname):
"""A string parameter's value, or None. Never raises. READ ONLY."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
value = p.AsString()
except Exception:
value = None
if not value:
return None
return value
def _flag(el, pname):
"""1 / 0 / None. None means the parameter is not on this element."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
return int(p.AsInteger())
except Exception:
return None
def assemblies(doc):
"""Every assembly instance, as (name, element). Scoped collector only."""
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
out = []
for a in (FilteredElementCollector(doc)
.OfClass(AssemblyInstance)
.WhereElementIsNotElementType()
.ToElements()):
name = _name_of(a)
if name:
out.append((name, a))
return out
def live_signatures(doc, elements=None, know=None):
"""{name: (signature, reinforced, on_hold)} for every assembly.
No geometry is read here. This is the pass that decides what has to be
rebuilt, so it has to stay cheap enough to run on every button press --
measured at 0.23 s over 755 assemblies. The MASTER flag joined it in
2026-08-30 at the cost of one string parameter read per assembly:
measured over the same 755 assemblies, reading the identity field and
five other string fields together took 1.07 s including the member walk,
so one field on its own is well inside the same budget. It has to be here --
see `reinforcecore.signature` for why a hand-typed field that is not in
the stamp is a field that never takes effect.
THE GEOMETRIC ELEVATION FLOOR IS NOT HERE, and it briefly stood in this
spot under a different name for one day (2026-09-02): the owner's
ruling made it a purely GEOMETRIC fact, and geometry needs the panel's
measured solid -- unaffordable in a pass that has to stay cheap enough
to run on every button press. See `reinforcecore.elevation_band`'s
module note for the full reasoning; the band is captured only in
`extract()`, alongside `thickness_in` and `features`, and never feeds
this cheap stamp.
THE ELEVATION ACCELERATOR IS HERE, and costs about what MASTER does:
reading `elevation_group` needs the PANEL, not the assembly, so
`elevation_group_of` pays its own member walk per assembly
(`_panel_member`) rather than one parameter read off `a` directly. That
walk is the same one `extract()` already pays to find the panel it
measures -- it is not new work, only moved earlier. On most jobs the
role is unmapped (see the section note above `R_ELEVATION_GROUP`), so
this is one more `LookupParameter` call that returns nothing -- cheap
even when it never answers.
"""
token = master_token(know)
names = identity_params(know)
out = {}
for name, a in (elements if elements is not None else assemblies(doc)):
reinforced = _flag(a, P_REINFORCED)
on_hold = _flag(a, P_ON_HOLD)
master = master_of(a, names, token)[0]
elevation_group = elevation_group_of(doc, a, know)
try:
member_ids = [_int_id(i) for i in a.GetMemberIds()]
except Exception:
member_ids = []
out[name] = (core.signature(reinforced, on_hold, member_ids, master,
elevation_group),
reinforced, on_hold)
return out
def _int_id(element_id):
try:
return element_id.IntegerValue
except Exception:
return element_id.Value
# ------------------------------------------------------- the expensive pass
def _global_box_of_solid(solid):
"""The solid's own bounding box, in RAW GLOBAL (model) coordinates.
`Solid.GetBoundingBox()` over the solid's 8 box corners, mirroring
`ticketqc._one_solid_box`'s reasoning exactly: a curved edge's two
"endpoints" coincide at the seam, so walking `solid.Edges` directly
collapses a round feature to a sliver -- `Solid.GetBoundingBox()` does
not have that failure. Unlike `_one_solid_box` this stops at the
solid's own `box.Transform` and never applies the panel's inverse
afterward: a building elevation is measured in the MODEL's frame, not
the panel's own local one.
Returns `(lo, hi)`, each an (x, y, z) list, or `(None, None)`.
"""
if solid is None:
return None, None
try:
from Autodesk.Revit.DB import XYZ
box = solid.GetBoundingBox()
if box is None:
return None, None
local = box.Transform
lo = [None, None, None]
hi = [None, None, None]
for i in (0, 1):
for j in (0, 1):
for k in (0, 1):
corner = XYZ(box.Min.X if i else box.Max.X,
box.Min.Y if j else box.Max.Y,
box.Min.Z if k else box.Max.Z)
q = local.OfPoint(corner)
for axis, value in enumerate((q.X, q.Y, q.Z)):
if lo[axis] is None or value < lo[axis]:
lo[axis] = value
if hi[axis] is None or value > hi[axis]:
hi[axis] = value
if lo[0] is None:
return None, None
return lo, hi
except Exception:
return None, None
def _elevation_band_of(panel, extents, solid):
"""`core.elevation_band(...)` off the panel already being measured.
`extents` is `Q.concrete_extents(panel, frame)`'s (lo, hi) LOCAL-frame
feet triples -- the same measurement `thickness_in` already comes from,
read again here rather than reconverted from `lo_in` / `hi_in` to avoid
a needless feet -> inches -> feet round trip. `solid` is the same one
`extract()` already paid for to build `features` -- no second geometry
pass; only `panel.GetTransform()` is read again, and that is a
placement property, not a solve.
Never raises: any failure reading the transform or the box is
could-not-judge, exactly what `core.elevation_band` itself returns for
an ambiguous or off-axis panel -- a ranking signal going quiet, never a
reason to fail the whole piece.
"""
from vibe import knowledge
try:
spans_ft = [extents[1][i] - extents[0][i] for i in (0, 1, 2)]
transform = panel.GetTransform()
basis = (transform.BasisX, transform.BasisY, transform.BasisZ)
basis_xyz = [(b.X, b.Y, b.Z) for b in basis]
except Exception:
return None
box_lo, box_hi = _global_box_of_solid(solid)
try:
return core.elevation_band(
spans_ft, basis_xyz[0], basis_xyz[1], basis_xyz[2],
box_lo, box_hi,
near_square_ratio=knowledge.UP_END_NEAR_SQUARE_RATIO)
except Exception:
return None
def extract(doc, name, asm, sig, know=None):
"""One piece's index record. Never raises: a failure becomes `error`.
Every early return is a could-not-judge with a reason attached, never an
empty feature list. A piece whose solid would not read and a piece with
no openings are different answers.
"""
from vibe import ticketqc as Q, liftexport as L
reinforced = _flag(asm, P_REINFORCED)
on_hold = _flag(asm, P_ON_HOLD)
# The review signal. Read here as well as in the cheap pass, and from the
# same parameter, so a record and its own signature can never disagree
# about it. A piece that could not be measured still carries it -- being
# unreadable does not un-review a piece.
master, note = master_of(asm, identity_params(know), master_token(know))
def fail(why, **extra):
return core.piece_record(name, _int_id(asm.Id), sig,
reinforced, on_hold, error=why,
master=master, master_note=note, **extra)
panel = None
families = {}
try:
member_ids = list(asm.GetMemberIds())
except Exception as exc:
return fail('members unreadable: {0}'.format(exc))
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
fam = member.Symbol.Family.Name
except Exception:
continue
families[fam] = families.get(fam, 0) + 1
# NOT filtered on SuperComponent -- but the reason written here
# first was wrong in both halves, and it was corrected 2026-08-30
# against the live model:
#
# * SuperComponent set means the element IS A CHILD; it holds the
# PARENT. Measured: SP03 members return
# CONNX_HPKM39_COLUMN_SHOE(10 ANCHOR BOLTS) as their super
# component. The old comment had it backwards, claiming the
# property marks an element that HAS children.
# * It is not "nearly every member". Over 25 WP assemblies,
# 553 of 2755 members carry one -- 20.1%.
#
# So filtering would not empty the count. It would remove a fifth of
# it, and that fifth is nested children that a naive count counts
# twice: once as themselves and once inside their parent.
#
# The inventory is still recorded RAW and deliberately so -- this is
# an index, and a consumer that needs a de-duplicated count should
# decide its own rule and say which it used. But a consumer that
# counts `families` without de-duplicating is over-counting by
# roughly that 20%, and it should know that rather than inherit a
# comment saying the property is useless.
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing' and panel is None:
panel = member
if panel is None:
return fail('no structural-framing member to measure',
families=families)
try:
design_number = Q.rstr(panel, know, 'panel.design_number')
except Exception:
design_number = None
# THE ELEVATION ACCELERATOR -- read the same way and at the same point
# as design_number, since both are hand-set fields off the same panel.
# None the vast majority of the time (unmapped role, or blank on this
# piece) is expected, not a fault -- the geometric floor below is what
# actually answers on most jobs. See `reinforcecore.elevation_match`'s
# module note.
try:
elevation_group = Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
elevation_group = None
try:
frame = Q._panel_frame(panel)
except Exception as exc:
return fail('frame failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if frame is None:
return fail('no panel frame', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
try:
extents = Q.concrete_extents(panel, frame)
except Exception as exc:
return fail('extents failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if extents is None:
return fail('no concrete extents', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
# concrete_extents is in FEET; _openings and opening_penetration both want
# INCHES. Getting this backwards is a 12x error, which is the single most
# expensive mistake this project has made.
lo_in = [v * 12.0 for v in extents[0]]
hi_in = [v * 12.0 for v in extents[1]]
axes = L._axes(lo_in, hi_in)
length_axis, width_axis, thick_axis = axes
length_ft = (hi_in[length_axis] - lo_in[length_axis]) / 12.0
width_ft = (hi_in[width_axis] - lo_in[width_axis]) / 12.0
thickness_in = hi_in[thick_axis] - lo_in[thick_axis]
try:
solid = L.concrete_solid(panel)
except Exception as exc:
return fail('solid failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
if solid is None:
return fail('no concrete solid', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# THE ELEVATION FLOOR -- purely geometric, owner's ruling 2026-09-02;
# see `reinforcecore.elevation_band`'s module note for the definition.
# Computed for EVERY piece regardless of `elevation_group` above -- it
# is the answer a job with no grouping parameter has, and the one that
# keeps working if the parameter is added or dropped later. Off the
# SAME solid `features` is about to be measured from -- no second
# geometry pass -- plus the panel's own placement transform, which is a
# cheap property read, not a solve. A failure here is a ranking signal
# that could not be determined, never a reason to fail the whole piece:
# `thickness_in` and `features` are still worth having even when the
# panel turns out to be near-square or skewed off every global axis.
elevation_band = _elevation_band_of(panel, extents, solid)
try:
rects = L._openings(solid, frame, lo_in, hi_in, axes)
except Exception as exc:
return fail('openings failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
elevation_band=elevation_band,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# Penetration is a separate solve and can fail on its own. When it does,
# the rectangles are still real -- so the features are kept and `through`
# goes to None on each, which is the could-not-judge value, not False.
try:
pen = L.opening_penetration(panel, frame, solid, rects, axes,
lo_in, hi_in)
except Exception:
pen = []
features = []
for i, rect in enumerate(rects):
p = pen[i] if i < len(pen) else {}
features.append(core.feature(
name, i, rect[0], rect[1], rect[2], rect[3],
p.get('through'), p.get('concrete_in'),
length_ft, width_ft, thickness_in))
return core.piece_record(
name, _int_id(asm.Id), sig, reinforced, on_hold,
design_number=design_number, panel_id=_int_id(panel.Id),
length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in,
families=families, features=features,
master=master, master_note=note,
elevation_band=elevation_band, elevation_group=elevation_group)
# ------------------------------------------------------------------ refresh
def scope_of(know):
"""The declared scope, or an undeclared one. Never raises on a bad file."""
from vibe import scope as scopelib
if not know:
return scopelib.Scope()
try:
return scopelib.of(know)
except Exception:
return scopelib.Scope()
def scope_in(cache):
"""(Scope, stamped) out of a cache. NO DOCUMENT, NO KNOWLEDGE FILE.
`stamped` is False when the index carries no scope at all -- built before
the stamp existed, or by a caller that had no knowledge layer. That is not
the same as "no scope was declared", and an offline reader has to say
which: the first means nobody knows what the pool was bounded by, the
second means it was deliberately unbounded. Both come back as an
undeclared `Scope()`; only this flag tells them apart.
"""
from vibe import scope as scopelib
stamp = (cache or {}).get('scope')
if not isinstance(stamp, dict):
return scopelib.Scope(), False
return scopelib.Scope(prefixes=stamp.get('prefixes') or (),
note=stamp.get('note'),
declared_by=stamp.get('declared_by'),
source='the index, built ' +
str((cache or {}).get('built_at'))), True
def refresh(doc, budget_s=None, limit=None, rebuild_all=False, know=None,
on_piece=None, targets=True):
"""Bring the index up to date and return (cache, report). READ ONLY.
`budget_s` stops the build partway and leaves the rest for the next call.
That is what makes a cold index usable immediately: the first press
indexes what it can afford, answers with what it has, and says what it
could not see. `report['coverage']['missing']` is that list and a caller
that drops it is claiming a completeness the index does not have.
`rebuild_all` ignores the cached signatures. It is the escape hatch for
the thing signatures cannot see -- an opening moved or resized without
the member set changing -- and it is cheap: a full build of this model's
690 eligible donors measured 6.07 s (16.2 s on the first build after a
Revit restart, measured 2026-08-30).
`targets` (schema 5, default ON) also measures every piece INSIDE THE
DECLARED SCOPE that is not an eligible donor. Those are the pieces
somebody actually wants suggestions for -- not yet reinforced, so donor
eligibility left them out of the file altogether and the target had to be
measured through a live document on every press. With them in the file the
whole chain runs off the snapshot and `tools/reinforce_offline.py` can
answer with no Revit at all.
IT IS THE SCOPE THAT BOUNDS THIS, NOT A PREFIX IN THE CODE. The scope is
read from the job knowledge (`scope.mark_prefixes`), which is where this
project already keeps the answer to "whose pieces are these". An
UNDECLARED scope includes everything, by `vibe.scope`'s standing rule that
no declaration means judge everything -- which here costs measuring the
whole model rather than the donors, roughly one extra second per hundred
assemblies, and produces a `features=None` record for anything with no
structural-framing member to measure. Pass `targets=False` for the
donors-only index schema 4 built.
"""
started = time.time()
path = index_path(doc)
cache = core.load(path)
if cache.get('doc_key') not in (None, doc_key(doc)):
cache = core.new_cache(doc_key(doc))
cache['doc_key'] = doc_key(doc)
the_scope = scope_of(know)
# Stamped into the file so the offline reader, which cannot load a
# knowledge layer without a model, gets the same scope the button had.
cache['scope'] = {'prefixes': list(the_scope.prefixes),
'note': the_scope.note,
'declared_by': the_scope.declared_by}
# And the nomenclature, for the same reason and to the same reader. A
# donor must be the SAME DESIGN as its target (owner ruling, 2026-09-01),
# and reducing two design numbers to one family is done with the plant's
# `nomenclature.design_number_re`. The offline reader has no knowledge
# layer to get that from, so it is stamped here alongside the scope.
# Additive: a file written before this key falls back exactly as before.
from vibe import knowledge as K
cache['nomenclature'] = {
'design_number_re': K.get(know, 'nomenclature.design_number_re', None)
if know else None}
elements = assemblies(doc)
by_name = dict(elements)
t_sig = time.time()
live = live_signatures(doc, elements, know=know)
seconds_signatures = time.time() - t_sig
if rebuild_all:
cache['pieces'] = {}
the_plan = core.plan(cache, live,
include=the_scope.includes if targets else None)
todo = the_plan['build']
if limit is not None:
todo = todo[:limit]
records = []
t_build = time.time()
ran_out = False
for name in todo:
if budget_s is not None and (time.time() - t_build) > budget_s:
ran_out = True
break
asm = by_name.get(name)
if asm is None:
continue
sig = live[name][0]
records.append(extract(doc, name, asm, sig, know=know))
if on_piece is not None:
on_piece(name)
seconds_build = time.time() - t_build
# Rewriting an unchanged cache costs a full serialise for nothing, and the
# commonest press of all is the one where nothing moved. Measured: writing
# the 690-piece file back is the single largest slice of a no-change run.
changed = bool(records) or bool(the_plan['drop'])
cache = core.apply_plan(cache, the_plan, records,
built_at=_stamp() if changed else None)
# MAINTAIN THE FEATURE VOCABULARY on every refresh (schema 6). The registry
# lives on the cache and is NEVER reset by `rebuild_all` -- only
# `cache['pieces']` is cleared above -- so `get_or_add` keeps every existing
# id stable and only assigns ids for shapes genuinely seen for the first
# time. Registering the freshly-built records is sufficient: an unchanged
# (fresh) piece's descriptors were registered when it was first built and
# its ids never move. `_pack`/`_unpack` carry the registry to disk, so the
# offline reader gets the same vocabulary this build produced.
if records:
core.register_features(cache, records)
if changed:
core.save(path, cache)
report = {
'path': path,
'assemblies': len(elements),
'scope': the_scope.label(),
'targets_wanted': len(the_plan.get('targets') or ()),
'seconds_signatures': round(seconds_signatures, 3),
'seconds_build': round(seconds_build, 3),
'seconds_total': round(time.time() - started, 3),
'built': len(records),
'reused': len(the_plan['fresh']),
'dropped': len(the_plan['drop']),
'stopped_early': ran_out or (limit is not None
and len(the_plan['build']) > len(todo)),
'coverage': core.coverage(cache, the_plan),
}
return cache, report
def _stamp():
return time.strftime('%Y-%m-%d %H:%M:%S')
def read(doc):
"""The index as it stands, without rebuilding anything. READ ONLY.
For a caller that only wants to search: `refresh` first if you mean to be
current, `read` if you mean to be fast. Neither touches the model.
"""
return core.load(index_path(doc))
@@ -0,0 +1,805 @@
# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30): "我想 reinforce 一个 piece,然后 AI 帮我找最
# 合适的几个母版 ... 这几个母版拼吧拼吧能把这个 panel 给拼出来 ... 每一个细节
# 都要照顾到,但是尽量选少的母版,覆盖多的细节。"
# Owner, verbatim (2026-08-30), on the measurement unit: "实际测量全部用英寸,
# 带两位小数。"
# Owner, verbatim (2026-08-30), on exactness ranking, not classifying:
# "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排在后
# 面。"
# Owner, verbatim (2026-08-30), on the donor cap: "一个方案,只要找到足够覆盖
# 全部特征的母版,就够了。我期待是 1-4 个,最多不超过 4 个。"
"""The reinforcing-suggestion matcher: a set-cover solver over opaque features.
WHAT THIS ANSWERS
-----------------
Will, 2026-08-30: "I want to reinforce a piece, and have AI find me the
best-fitting master(s) ... these masters, pieced together, can reconstruct
this panel ... every detail has to be accounted for, but pick as few
masters as possible, covering the most detail."
That is not a similarity ranking (the 08-26 attempt built one of those, and it
answers a different question). It is SET COVER: every feature of the target
panel must be accounted for by some donor, and the number of donors must be as
small as possible. Donors may overlap; overlap is expected and is not a
conflict (ruling, 2026-08-30). Coverage is counted BY FEATURE, not by area --
the target's small end features appear identically in both candidate donors of
the worked example, so an area weighting would let two big openings drown them
out, while counting features keeps each one a thing that must be covered.
THE SEAM, AND WHY IT IS HERE ON PURPOSE
---------------------------------------
This module never reads a field off a feature record. Not one. Every
comparison of two features happens inside a single injected callable, `covers`,
whose contract is stated below. Everything else in this file -- coverage, set
selection, ranking, tie-breaking -- works on opaque records and on the numbers
that callable returns.
That boundary is not a scheduling convenience, and it should survive after the
index lands. Comparing two precast features is the one part of this problem
that needs engineering judgement about geometry: what makes a blockout "the
same" blockout, whether a notch at the same station but a different Z band is
the same feature, how a through opening compares to a blind one. That judgement
is exactly the thing this project has been burned by before -- the shim check
that measured the wrong axis, the clash check that reported nothing because a
cylinder's edge curves close on themselves. Isolating it in one small callable
means it can be argued about, measured, and replaced without touching a line of
the selection logic, and means the selection logic can be tested to death on
synthetic data with no model, no licence and no Revit.
So: do not add a fallback here that peeks at a field "just in case". If the
matcher needs something `covers` cannot give it, that is a finding to report,
not a thing to work around.
THE `covers` CONTRACT
---------------------
covers(donor_feature, target_feature) -> None or float
None this donor feature does not cover that target feature at all.
0.0 exact match.
> 0.0 covers it; the number is how far from exact, smaller being closer.
Requirements on any implementation:
1. TOTAL. It must accept any pair of records the caller passes and must not
raise. A pair it cannot judge is `None`, not an exception.
2. PURE AND DETERMINISTIC. Same two records, same answer, every time, with no
dependence on call order or on any global. The matcher's determinism rests
entirely on this.
3. DIRECTIONAL. `covers(a, b)` need not equal `covers(b, a)`. A donor feature
may stand in for a target feature without the reverse being true.
4. COMMENSURABLE, IN INCHES. The returned distances are compared and sorted
ACROSS DIFFERENT FEATURE PAIRS and across different feature kinds. A
distance of 0.4 on a notch must mean the same amount of "off" as 0.4 on a
blockout, or the ranking is comparing apples to oranges. ONE UNIT FOR ALL
KINDS, and that unit is INCHES (owner's ruling, 2026-08-30:
"all actual measurements use inches, to two decimal places"). The owner answered a UNIT question, not a
WEIGHTING one -- there is no per-kind weight here and none is invented; an
inch off on a blockout is an inch off on a notch.
THE STORED GEOMETRY IS STILL FEET and must stay that way: feet is Revit's
internal unit and `reinforcecore` caches feet. The production `covers`
(`reinforcebind.covers`) is the single place the crossing happens, via
`reinforcecore.inches()`. Nothing in THIS module converts anything; it
sorts numbers and does not know what they measure.
PRECISION IS NOT THE SAME AS DISPLAY. Two-decimal inches is 0.01", an
order of magnitude finer than any tolerance this tool uses (dimensions
3/8", interference 1/8"). The production `covers` therefore returns
distances QUANTIZED TO 1/8 in so the ranking cannot be steered by
differences the measurement cannot support. This module imposes nothing:
it will rank whatever grain it is handed. See `reinforcebind.GRAIN_IN`.
5. ABSOLUTE, NOT PROPORTIONAL. Positions compare as absolute distance from the
start of the panel, never as a fraction of panel length. Measured: the two
big openings of the worked example land at the identical feet-from-start
rectangle on panels of two different lengths, and a proportional comparison
would have missed the match entirely. Nothing in this module normalises by
length, and nothing in `covers` should either.
6. TWO-DIMENSIONAL. The same measurement showed two donor features sharing an
x-range while sitting in different Z bands. A single-axis comparison calls
those the same feature. They are not.
7. CHEAP. It is called exactly once per (donor, donor feature, target
feature) triple -- the matcher builds the whole comparison table up front
and never calls it again -- but that is still on the order of a few
thousand calls per run.
Note what the contract does NOT contain: a similarity threshold. There is none,
anywhere in this module, by ruling -- "if there is a completely identical
shape, that ranks at the front of the recommendation; if it is only similar,
it goes further back." Exactness RANKS, it does not CLASSIFY. The
matcher never asks "are these two the same?", only "which of these is closer?",
which is arithmetic rather than an unfalsifiable judgement call. The one
classification that does exist -- covers-at-all vs. does-not -- lives inside
`covers`, where the geometry knowledge is, and it is the only place it may live.
WHAT THE CALLER PASSES
----------------------
suggest(target_features, donors, covers, ...)
`target_features` is a sequence of opaque feature records. `donors` is a
sequence of `(key, feature_list)` pairs -- the key is any hashable value that
identifies the donor to the caller (an element id, a mark, a tuple); this
module treats it as a label and nothing else. Feature records are whatever the
index produces; this module only ever hands them to `covers`.
SAME ELEVATION, RULED 2026-09-02 -- superseding the note this module used to
carry about donor order being the lever for this. WP425 ranked five donors in
a dead tie on every criterion below, and WP234 -- which shares WP425's own
building elevation -- printed last, by mark order, because nothing here knew
elevation existed: owner, "WP234明明在同一个elevation,为什么排名没有提前?"
Approved as briefed the same day ("同意,就这么做"): elevation is an AFFINITY,
the LAST tie-break -- after fewest donors, most exact, most reviewed and
total distance, before mark order -- never a filter, exactly the shape
Will's standing correction on donor order already described (a cross-design
or cross-fit exclusion is not what this is; a better-shaped cross-elevation
donor still wins). It is now an explicit ranking term, `same_elevation`,
rather than a reordering of the input list: see `Proposal.same_elevation`
and the ranking notes below. Donor input order is still the true last
resort -- it decides a tie elevation cannot, same as it always has.
WHY A TERM AND NOT THE ORDERING TRICK THIS MODULE USED TO SUGGEST. Sorting
same-elevation donors to the front of the input list would have worked for
the reported case (a single donor covers everything, so the tie-break really
is just "which donor is listed first"), but it hides the reason inside sort
position -- a report that wants to say WHY WP234 won would have to
recompute the same ordering itself. An explicit term, exactly like `masters`
below, is a fact the caller can read straight off the winning `Proposal` and
print.
AT MOST FOUR DONORS IN A PROPOSAL
---------------------------------
Owner's ruling, 2026-08-30: "for one proposal, it is enough to find masters
that cover all the features between them. I'd expect 1-4, never more than
4." `DEFAULT_MAX_DONORS = 4`, and it is a HARD
cap on the donors WITHIN one proposal. Do not confuse it with `max_proposals`,
which is a different axis entirely: `max_proposals` is how many alternative
answers get printed, `max_donors` is how big any one answer may be.
WHAT HAPPENS WHEN FOUR CANNOT COVER EVERYTHING -- INFERRED, NOT STATED. The
owner ruled the cap; he did not rule the overflow. The reading implemented
here is that the cap is hard: the search returns the best partial cover of
four donors and NAMES what it could not reach, rather than relaxing to a
fifth. That is already this module's established behaviour for the other
reason a feature goes uncovered (nobody has it at all), it is the answer the
first live run gave, and a suggestion that quietly grew to six donors would
not be the thing that was asked for. The two causes are reported separately
and never merged -- see `CAPPED` below.
Everything here is IronPython 2.7 compatible: no f-strings, no pathlib, no
modern typing, no Python 3 `functools`.
"""
# --------------------------------------------------------------------------
# Outcome vocabulary. "Could not judge" and "judged and found nothing" are
# different answers and must read differently -- the standing convention on
# this project, learned from a check that reported a clean result when it had
# in fact measured nothing.
# --------------------------------------------------------------------------
NO_DONORS = 'no_donors' # nothing eligible was offered; nothing was judged
NO_COVERAGE = 'no_coverage' # donors were judged; not one covers any feature
PARTIAL = 'partial' # some feature no donor covers, in any combination
CAPPED = 'capped' # every feature IS coverable; four donors cannot reach them all
COMPLETE = 'complete' # every target feature is covered
# PARTIAL and CAPPED are different findings and must never be collapsed.
# PARTIAL means nobody in the pool has that feature -- the engineer details it
# from scratch. CAPPED means somebody does have it, but not within the four
# donors a proposal is allowed; the engineer can go and find that donor
# himself. When both are true the status is PARTIAL (the harder fact) and
# `Suggestion.capped_out` still names the second set.
# `suggest` returns None -- not an empty result -- when the input cannot be
# judged at all (no target features). An empty `proposals` with a status is
# always the result of an actual judgement.
DEFAULT_MAX_PROPOSALS = 5
# How many donors ONE proposal may contain. The owner's number. This is not
# `max_proposals` -- see the module docstring; conflating the two axes gives
# you either one giant answer or four tiny ones.
DEFAULT_MAX_DONORS = 4
class Proposal(object):
"""One set of donors, and what each target feature got from it."""
def __init__(self, donor_indexes, donor_keys, assignment, uncovered,
master_keys=(), same_elevation_keys=()):
self.donor_indexes = tuple(donor_indexes)
self.donors = tuple(donor_keys)
# Which of THIS proposal's donors are reviewed templates, in the same
# order as `donors`. A label and a tie-break, never a filter.
self.masters = tuple(k for k in self.donors if k in set(master_keys))
self.master_count = len(self.masters)
# Which of THIS proposal's donors share the target's building
# elevation, same shape as `masters` and ranked directly below it --
# see the module docstring's 2026-09-02 note and the ranking
# comment above `_proposal_sort_key`. A label and a tie-break,
# never a filter: a donor whose elevation is unknown, or differs, is
# simply absent from `same_elevation_keys` and this stays empty.
self.same_elevation = tuple(
k for k in self.donors if k in set(same_elevation_keys))
self.elevation_count = len(self.same_elevation)
# assignment[i] is None (feature i uncovered by this proposal) or a
# 3-tuple (donor_index, donor_feature_index, distance).
self.assignment = tuple(assignment)
self.uncovered = tuple(uncovered)
dists = [a[2] for a in self.assignment if a is not None]
# Ascending: the ranking key. See `_proposal_sort_key`.
self.distances = tuple(sorted(dists))
self.exact_count = len([d for d in self.distances if d == 0.0])
self.covered_count = len(self.distances)
self.total_distance = sum(self.distances) if self.distances else 0.0
self.is_exact = (self.covered_count > 0 and
self.exact_count == self.covered_count)
def donor_for(self, feature_index):
"""(donor_key, donor_feature_index, distance) for one target feature.
None when this proposal does not cover that feature.
"""
got = self.assignment[feature_index]
if got is None:
return None
return (self.donors[self.donor_indexes.index(got[0])], got[1], got[2])
def is_master(self, donor_key):
return donor_key in self.masters
def __repr__(self):
return ('<Proposal {0} exact={1}/{2} master={3} elevation={4} '
'total={5:.4f}>').format(
list(self.donors), self.exact_count, self.covered_count,
self.master_count, self.elevation_count, self.total_distance)
class Suggestion(object):
"""The whole answer: ranked proposals plus what could not be covered."""
def __init__(self, status, proposals, uncovered, donor_count,
target_feature_count, donors_needed, cover_count,
donor_cap=None, capped_out=()):
self.status = status
self.proposals = tuple(proposals)
# Target feature indexes that NO donor covers, in any combination.
self.uncovered = tuple(uncovered)
# The donor cap this answer was found under, and the features that
# SOME donor covers but the best proposal could not reach inside it.
# Disjoint from `uncovered` by construction: a feature nobody has is
# not a feature the cap kept out.
self.donor_cap = donor_cap
self.capped_out = tuple(capped_out)
self.donor_count = donor_count
self.target_feature_count = target_feature_count
# Size of the smallest cover FOUND. Not proven minimal -- the search
# is greedy; see the note above `_greedy_covers`. There is no
# `minimal` flag because the answer is always the same: no.
self.donors_needed = donors_needed
self.cover_count = cover_count # how many covers of that size exist
@property
def best(self):
return self.proposals[0] if self.proposals else None
def summary(self):
"""One sentence a button can print without knowing the vocabulary."""
n = self.target_feature_count
if self.status == NO_DONORS:
return ('No eligible donors were offered, so nothing was judged. '
'{0} target feature(s) unexamined.'.format(n))
if self.status == NO_COVERAGE:
return ('Judged {0} donor(s): not one of them covers any of the '
'{1} target feature(s).'.format(self.donor_count, n))
k = self.donors_needed
if self.status == CAPPED:
return ('At most {0} donor(s) per proposal; {1} of them cover {2} '
'of {3} target feature(s). {4} feature(s) are covered by '
'some donor but not by any {0}-donor set: {5}.'.format(
self.donor_cap, k, n - len(self.capped_out), n,
len(self.capped_out), list(self.capped_out)))
if self.status == COMPLETE:
return ('{0} donor(s) cover all {1} target feature(s); {2} such '
'cover(s) found, {3} shown.'.format(
k, n, self.cover_count, len(self.proposals)))
tail = ''
if self.capped_out:
tail = (' A further {0} feature(s) are covered by some donor but '
'not within the {1}-donor cap: {2}.'.format(
len(self.capped_out), self.donor_cap,
list(self.capped_out)))
return ('{0} donor(s) cover {1} of {2} target feature(s); {3} '
'feature(s) no donor covers at all: {4}.{5}'.format(
k, n - len(self.uncovered) - len(self.capped_out), n,
len(self.uncovered), list(self.uncovered), tail))
def __repr__(self):
return '<Suggestion {0} donors={1} features={2} proposals={3}>'.format(
self.status, self.donor_count, self.target_feature_count,
len(self.proposals))
# --------------------------------------------------------------------------
# The coverage table. This is the only place `covers` is called.
# --------------------------------------------------------------------------
def _coverage(target_features, donors, covers):
"""Build, for every donor, the best it can do on every target feature.
Returns (best, masks) where
best[d][i] = None, or (donor_feature_index, distance)
masks[d] = bitmask over target feature indexes donor d covers
A donor may carry several features that cover one target feature (two
notches near one blockout, say). The best one wins: lowest distance, and
among equal distances the lowest donor-feature index, so the choice does
not depend on iteration luck.
An exception raised by `covers` is NOT caught. A comparison that blows up
is a bug in the comparison, and swallowing it here would turn it into a
quiet "this donor covers nothing" -- a confident wrong answer, which is
the exact failure mode this project exists to prevent. A contract-breaking
return value (negative, NaN, not a number) is raised on, naming the pair,
for the same reason.
"""
best = []
masks = []
for d_index in range(len(donors)):
donor_features = donors[d_index][1] or []
row = [None] * len(target_features)
mask = 0
for f_index in range(len(donor_features)):
df = donor_features[f_index]
for t_index in range(len(target_features)):
got = covers(df, target_features[t_index])
if got is None:
continue
dist = _checked_distance(got, d_index, f_index, t_index)
current = row[t_index]
if current is None or dist < current[1]:
row[t_index] = (f_index, dist)
mask |= (1 << t_index)
best.append(row)
masks.append(mask)
return best, masks
def _checked_distance(value, d_index, f_index, t_index):
try:
dist = float(value)
except (TypeError, ValueError):
raise ValueError(
'covers() returned a non-number {0!r} for donor {1} feature {2} '
'vs target feature {3}; the contract is None or a float >= 0'
.format(value, d_index, f_index, t_index))
if dist != dist: # NaN, which compares false with itself
raise ValueError(
'covers() returned NaN for donor {0} feature {1} vs target '
'feature {2}'.format(d_index, f_index, t_index))
if dist < 0.0:
raise ValueError(
'covers() returned a negative distance {0!r} for donor {1} '
'feature {2} vs target feature {3}; 0.0 is exact and there is '
'nothing closer than exact'.format(value, d_index, f_index,
t_index))
return dist
# --------------------------------------------------------------------------
# Set cover, by greedy with restarts.
#
# WHY GREEDY, AND WHERE IT IS WRONG.
#
# Set cover is NP-hard, so this does not solve it; it approximates it, and the
# approximation is chosen deliberately over an exhaustive search. A panel
# carries on the order of five to ten features, an exhaustive search over
# subsets of that universe would also be affordable, and the honest reason to
# take greedy anyway is that it is twenty lines instead of a hundred, has no
# recursion, no node budget and no depth cap to get wrong, and can be read in
# one sitting by whoever inherits it. If evidence ever turns up that it is
# picking badly on real panels, replacing it is a local change: everything
# else in this module works off the donor sets it returns.
#
# WHERE IT CAN RETURN A WORSE ANSWER THAN OPTIMAL. Greedy takes the donor that
# covers the most still-uncovered features, and that first bite can be the
# wrong one. Nine features 1..9, and donors
# D = {3, 6, 9} A = {1, 2, 3} B = {4, 5, 6} C = {7, 8, 9}
# Plain greedy takes D first (it is tied for the biggest bite and happens to
# be listed first), and then still needs all three of A, B and C to finish:
# four donors where {A, B, C} covers everything with three. In general greedy
# can return a factor of ln(n) more donors than the minimum, and a suggestion
# that says "you need four donors" when three would do is exactly the kind of
# wrong that gets a tool stopped being used.
#
# Restarting from every possible first donor (below) removes that whole family
# of failures -- the walk seeded from A finds {A, B, C} -- and at five-to-ten
# features it is hard to construct a case that beats it. It is still not a
# proof, there is no guarantee here, and nothing in the result claims one. If
# a real panel ever shows this picking badly, swap `_greedy_covers` for an
# exhaustive search over subsets: the universe is the target's features, which
# is small enough that a complete search is affordable, and everything else in
# this module works off the donor sets that function returns.
#
# THE RESTARTS. Plain greedy gives one answer, and one answer is not a
# suggestion -- the engineer needs to see the second and third options and be
# able to disagree with the ranking. So greedy is run once per donor, each run
# forced to start from that donor and then filling greedily; the resulting
# covers are deduplicated, the smallest size wins (fewest donors is the
# objective), and exactness ranks what is left. It costs D passes over D
# donors, which at these sizes is nothing.
# --------------------------------------------------------------------------
def _popcount(mask):
n = 0
while mask:
mask &= mask - 1
n += 1
return n
def _greedy_fill(masks, full, chosen, covered, max_donors=None):
"""Extend `chosen` greedily until nothing more can be added.
Takes the donor adding the most still-uncovered features; ties go to the
lowest donor index, so the walk is fixed and the whole module stays
reproducible run to run.
`max_donors` stops the walk once the proposal is that big, even if
features remain. That is the owner's cap, and it is enforced HERE rather
than by trimming a finished cover -- trimming would drop whichever donor
happened to be last, while stopping early means every donor in the four
was chosen for being the biggest remaining bite.
"""
chosen = list(chosen)
while covered != full:
if max_donors is not None and len(chosen) >= max_donors:
break
pick = None
gain = 0
for d in range(len(masks)):
if d in chosen:
continue
added = _popcount(masks[d] & ~covered)
if added > gain:
pick, gain = d, added
if pick is None:
break
chosen.append(pick)
covered |= masks[pick]
return tuple(sorted(chosen)), covered
def _greedy_covers(masks, full, max_donors=None):
"""Candidate covers: greedy restarted from every donor that covers anything.
Returns (k, covers, reached) -- the smallest cover size found among the
covers that get FURTHEST, every distinct cover of that size, and the union
of what they reach. `k` is None and `covers` empty when no donor covers
anything.
REACH BEFORE SIZE, and only because of the cap. Without a cap every greedy
walk ends at `full` (it stops only when no donor adds anything, and every
feature in `full` has some donor), so all candidates reach equally far and
this ordering is the old "fewest donors wins" unchanged. With a cap the
walks can end short, and then a two-donor set covering four features must
not beat a four-donor set covering eighteen: covering the target is the
objective, fewest donors is the tie-break under it.
"""
if full == 0:
return None, [], 0
seen = {}
for d in range(len(masks)):
if not masks[d]:
continue
cover, covered = _greedy_fill(masks, full, (d,), masks[d], max_donors)
seen[cover] = covered
if not seen:
return None, [], 0
reach = max([_popcount(m) for m in seen.values()])
best = [c for c in seen if _popcount(seen[c]) == reach]
k = min([len(c) for c in best])
keep = sorted([c for c in best if len(c) == k])
reached = 0
for c in keep:
reached |= seen[c]
return k, keep, reached
# --------------------------------------------------------------------------
# Ranking.
#
# Fewest donors is the objective and it is not negotiable; exactness only
# breaks ties between covers of equal size. Within one size, the question is
# how per-feature distances combine into something comparable.
#
# NOT BY SUM. Summing loses precisely the information the owner's ruling is
# about. A cover that is exact on four features and 2.0 off on the fifth, and
# a cover that is 0.4 off on all five, have the same sum of 2.0 -- so a sum
# calls them equal, when the owner said plainly that the exact shapes go in
# front: "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排
# 在后面。"
#
# THE KEY IS THE SORTED DISTANCE VECTOR, ASCENDING, COMPARED LEXICOGRAPHICALLY.
# Sort each cover's per-feature distances smallest-first and compare position
# by position. Since exact matches are 0.0 and sort to the front, this makes
# "has more exact features" the first thing that decides, then "its next-best
# feature is closer", and so on down. It is the owner's rule written as
# arithmetic, and it needs no weights, no normalisation and no threshold.
#
# WHAT THAT COSTS, STATED PLAINLY. It is a strict preference for exactness
# over evenness, and it will sometimes look odd. Cover A = [0.0, 5.0] beats
# cover B = [0.01, 0.02], because A is exact on one feature and B is exact on
# none, even though B is obviously the better fit overall. That is the chosen
# behaviour, not an oversight: it is what "完全一样的形状排在前面" means, and
# the alternative (rank by the worst feature, i.e. compare the vectors
# descending) is the "mediocre everywhere wins" rule the brief warns against.
# `Proposal.total_distance` carries the sum anyway, so a UI can show the
# overall figure next to the ranking without it steering the ranking.
#
# THE MASTER TIE-BREAK, AND WHERE IT SITS.
#
# Some donors are marked MASTER by hand in the model, and the owner's ruling
# (2026-08-30) is that those have been REVIEWED: "某一些模板带有 MASTER,给这些
# MASTER 当母版的优先级提升到最高。他们是被审核过的。" It is the only provenance
# signal this feature has -- `TICKET_IS_REINFORCED` says somebody drew bars,
# not that anybody checked them.
#
# Asked directly how it interacts with exactness -- a loosely-fitting MASTER
# against a perfectly exact non-MASTER -- the owner ruled: 拟合更好的排在前面.
# FIT WINS. So MASTER sits BELOW the distance vector and ABOVE input order.
# It cannot reorder anything the distance vector has already separated; it
# decides only between covers the geometry has declared indistinguishable,
# and that is exactly where "who checked this?" is the right question.
#
# Counted, not flagged: a cover with two masters ranks above a cover with one,
# because more of it has been reviewed. Negated in the key so that MORE sorts
# FIRST while everything else still sorts ascending.
#
# THE SAME-ELEVATION TIE-BREAK, AND WHERE IT SITS.
#
# Owner, 2026-09-02, on WP425: five donors tied on every criterion above --
# 1 donor, 5/5 covered, 4 exact, 0 reviewed, 0.04 in total distance, all
# five identical -- and the tie fell through to mark order, so WP133
# printed first though WP234 shares WP425's own building elevation:
# "WP234明明在同一个elevation,为什么排名没有提前?" Ruled the same day,
# approved as briefed ("同意,就这么做"): elevation is an AFFINITY, and it
# sits LAST, below MASTER -- after fewest donors, most exact, most reviewed
# and total distance, and above only the donors' input order. It is ranked
# below MASTER on the same reasoning MASTER is ranked below the distance
# vector: a fact about WHERE a donor sits does not say whether it is a good
# template, and a piece somebody has actually reviewed is worth more than
# one that merely happens to be on the right facade. Nothing here reorders
# what MASTER or the distance vector have already separated -- it only
# decides among covers every criterion above it left dead level, exactly
# the case WP425 hit.
#
# Counted the same way `master_count` is, for the same reason: a cover with
# two same-elevation donors ranks above a cover with one. Negated in the key
# so MORE sorts FIRST.
#
# UNKNOWN IS NEVER A MATCH. `same_elevation_keys` (built by
# `reinforcebind.propose`) never contains a donor whose own elevation could
# not be read, and it is empty outright when the TARGET's elevation could
# not be read -- an unread elevation is could-not-judge, not a wildcard that
# matches everything. See `reinforcecore.elevation_key`.
#
# LAST TIE-BREAK: the donors' input order. Two covers with identical distance
# vectors, master counts and elevation counts are, on the evidence available
# here, equally good, and the matcher has nothing further to say. Falling
# back to input order makes the answer reproducible -- an engineer who runs
# it twice and gets two different answers will not trust it a third time --
# and it is now genuinely the LAST word: mark order, nothing more, no
# remaining lever a caller is expected to pull by reordering donors.
# --------------------------------------------------------------------------
def _proposal_sort_key(proposal):
# `-covered_count` first, and it is a NO-OP everywhere except under the
# donor cap: uncapped, every candidate cover reaches the same feature set,
# so this term is constant and the ranking is exactly what it always was
# (fewest donors, then the distance vector, then MASTER, then same
# elevation, then input order). Capped, it is the difference between
# "eighteen of nineteen" and "four of nineteen, but with only two
# donors".
return (-proposal.covered_count,
len(proposal.donor_indexes),
proposal.distances,
-proposal.master_count,
-proposal.elevation_count,
proposal.donor_indexes)
def _build_proposal(donor_indexes, donors, best, target_count,
master_keys=(), same_elevation_keys=()):
assignment = []
uncovered = []
for t_index in range(target_count):
pick = None
for d_index in donor_indexes:
got = best[d_index][t_index]
if got is None:
continue
if pick is None or got[1] < pick[2]:
pick = (d_index, got[0], got[1])
if pick is None:
uncovered.append(t_index)
assignment.append(pick)
keys = [donors[d][0] for d in donor_indexes]
return Proposal(donor_indexes, keys, assignment, uncovered, master_keys,
same_elevation_keys)
# --------------------------------------------------------------------------
# The entry point.
# --------------------------------------------------------------------------
def suggest(target_features, donors, covers,
max_proposals=DEFAULT_MAX_PROPOSALS, masters=None,
max_donors=DEFAULT_MAX_DONORS, same_elevation=None):
"""Rank the smallest sets of donors that between them cover the target.
target_features -- sequence of opaque feature records for the panel being
reinforced. Order matters only as identity: every index
in the result refers back into this sequence.
donors -- sequence of (key, feature_list) pairs. `key` is opaque
and is only ever used as a label and, at the very end,
as the source of the input order that breaks a dead-even
tie. See the module docstring on why that is a lever.
covers -- the comparison callable. Contract in the module
docstring. This is the only thing in this module that
may read a feature's fields.
masters -- the donor keys that are REVIEWED templates, as any
iterable of keys. Purely a tie-break, ranked below the
distance vector: see the ranking notes above. Keys not
present among `donors` are ignored, and the default of
none is a matcher that ranks exactly as it did before.
same_elevation -- the donor keys that share the TARGET's building
elevation, as any iterable of keys. Purely a
tie-break, ranked below `masters` and above input
order -- see the ranking notes above and
`Proposal.same_elevation`. Keys not present among
`donors` are ignored; the default of none ranks
exactly as before this term existed.
max_proposals -- how many ranked proposals to return. Five by default:
enough that the engineer can see the second and third
options and disagree with the ranking, few enough to
read. `Suggestion.cover_count` reports how many there
actually were.
max_donors -- how many donors ONE proposal may contain. FOUR by the
owner's ruling, and a different axis from
`max_proposals`: this bounds the size of an answer, that
bounds the number of answers. `None` removes the cap.
When four donors cannot reach every coverable feature,
the search returns the best four and names what it could
not reach in `Suggestion.capped_out`, with status
`CAPPED`. It does NOT relax to a fifth. That overflow
rule is inferred, not ruled -- see the module docstring.
Returns a `Suggestion`, or None when the input cannot be judged at all --
that is, when there are no target features. No features means there is
nothing to cover and no basis for any suggestion; that is "could not
judge", and it must not be confused with the empty-but-real results this
returns when donors were examined and found wanting. `Suggestion.status`
separates those: NO_DONORS (nothing eligible was offered, so nothing was
judged) from NO_COVERAGE (donors were examined; none covers anything).
PARTIAL COVERS ARE RETURNED, deliberately. When some feature is covered by
no donor at all, "nothing found" and "here is a cover of nine of your ten
features, and this is the one nobody has" are very different answers, and
only the second is any use: it tells the engineer which detail they have to
reinforce by hand, which is a much shorter job than the whole panel. So the
search runs over the coverable features, the proposals cover all of those,
and `Suggestion.uncovered` names the rest by index. `status` is PARTIAL so
no caller can mistake it for a complete answer.
"""
if not callable(covers):
raise ValueError('suggest() needs a covers(donor_feature, '
'target_feature) callable; got {0!r}'.format(covers))
if not target_features:
return None # could not judge -- see docstring
target_count = len(target_features)
all_features = tuple(range(target_count))
donors = list(donors or [])
master_keys = frozenset(masters or ())
same_elevation_keys = frozenset(same_elevation or ())
if not donors:
return Suggestion(NO_DONORS, [], all_features, 0, target_count,
None, 0)
best, masks = _coverage(target_features, donors, covers)
full = 0
for m in masks:
full |= m
if full == 0:
return Suggestion(NO_COVERAGE, [], all_features, len(donors),
target_count, None, 0)
uncovered = tuple([i for i in all_features if not (full & (1 << i))])
k, cover_sets, _reached = _greedy_covers(masks, full, max_donors)
proposals = [_build_proposal(cs, donors, best, target_count, master_keys,
same_elevation_keys)
for cs in cover_sets]
proposals.sort(key=_proposal_sort_key)
# What the CAP kept out, read off the proposal that will be shown first
# rather than off the union: the engineer acts on the answer he is given.
# A feature nobody has is never counted here -- that is `uncovered`, a
# different finding with a different remedy.
nobody = set(uncovered)
capped_out = tuple([i for i in (proposals[0].uncovered if proposals else ())
if i not in nobody])
if uncovered:
status = PARTIAL
elif capped_out:
status = CAPPED
else:
status = COMPLETE
return Suggestion(status, proposals[:max_proposals], uncovered,
len(donors), target_count, k, len(cover_sets),
donor_cap=max_donors, capped_out=capped_out)
# --------------------------------------------------------------------------
# A reference `covers`, for tests and for reading.
#
# THIS IS NOT THE PRODUCTION COMPARISON and must not be mistaken for one. It
# makes no claim about the index's feature schema -- it operates on a bare
# 4-tuple chosen because it is the smallest thing that can express the worked
# example, and it encodes no precast engineering judgement whatsoever. Its job
# is to be a fixed, legible stand-in so the selection logic above can be tested
# exhaustively before the real comparison exists, and to be an executable
# statement of the contract.
# --------------------------------------------------------------------------
def rect_distance(donor_rect, target_rect):
"""Reference `covers` over rectangles `(x0, x1, y0, y1)`, unit-agnostic.
Coordinates are absolute, measured from the start of the piece -- never a
fraction of its length.
Covers-at-all: each rectangle must contain the other's centre. That is a
two-dimensional test on purpose, and it is why it is written as a mutual
condition rather than a range overlap: in the worked example two donor
features share an x-range with a target feature while sitting in a
different Z band, and a single-axis check calls those the same feature.
They are not.
Distance: the sum of the four absolute corner offsets, IN WHATEVER UNIT
THE COORDINATES ARE IN. Zero for identical rectangles, and one unit for
every kind of feature, as the contract requires.
DELIBERATELY UNIT-AGNOSTIC, unlike the production comparison. This is a
stand-in over bare tuples for testing the selection logic; it declares no
unit because its inputs declare none, and it applies no quantization. The
production `covers` -- `reinforcebind.covers` -- reads feet off index
records and returns INCHES on a 1/8 in grain, which is where the owner's
unit ruling actually lands. Do not read this function as the unit
contract; read requirement 4 above.
"""
try:
dx0, dx1, dy0, dy1 = [float(v) for v in donor_rect]
tx0, tx1, ty0, ty1 = [float(v) for v in target_rect]
except (TypeError, ValueError):
return None # not judgeable -> not covered
def contains(x0, x1, y0, y1, cx, cy):
return x0 <= cx <= x1 and y0 <= cy <= y1
if not contains(dx0, dx1, dy0, dy1, (tx0 + tx1) / 2.0, (ty0 + ty1) / 2.0):
return None
if not contains(tx0, tx1, ty0, ty1, (dx0 + dx1) / 2.0, (dy0 + dy1) / 2.0):
return None
return (abs(dx0 - tx0) + abs(dx1 - tx1) +
abs(dy0 - ty0) + abs(dy1 - ty1))
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
"""The DECLARED SCOPE: which pieces of a job this engineer is answerable for.
WHY THIS EXISTS
---------------
Several engineers work one job. The owner of this tool details the wall
panels; the beams, the slabs and the columns belong to other people. The
plant-level knowledge every check in here runs on -- naming, hardware,
bed layout, tolerances -- was validated against the panels and NOT against
the rest, and the owner cannot validate it against the rest, because he does
not hold the job knowledge for somebody else's pieces.
So a tool that judges all of them is not being thorough. It is producing
verdicts on work it has no basis to judge, in the same report and the same
typeface as the verdicts it can stand behind.
WHY THIS IS NOT AN IGNORE LIST
------------------------------
An exclusion that silently drops things is the exact failure this project has
already paid for: one unauthorised exclusion hid forty tickets and every
check that ran came back clean. Nothing here may repeat it, so:
* OUT OF SCOPE IS A THIRD STATE. Not a pass, not a failure, not an absence.
Every report that leaves work alone says so, in a line, naming how much
and of what kind.
* THE SCOPE IS DECLARED, NEVER INFERRED. It is read from the JOB knowledge
layer -- `scope.mark_prefixes` -- because who is responsible for what is
a fact about this job and this office, not about the plant and certainly
not about the code. No prefix is written into this file.
* NO DECLARATION MEANS JUDGE EVERYTHING. An undeclared scope is not an
empty scope. `Scope()` includes every piece, and its report line says
that nothing was set aside AND that the knowledge behind those verdicts
may never have been validated outside the usual work.
* A PIECE WHOSE KIND CANNOT BE READ IS NEVER SET ASIDE. If a mark carries
no readable letter prefix, the tool does not know whether it is in scope,
and "I could not tell" may only ever resolve towards judging it. Those
are counted and named on the same line.
* A NEAR MISS IS REPORTED, NEVER RESOLVED. See below.
THE NEAR MISS, AND WHY THE MATCH IS NOT WIDENED
------------------------------------------------
`prefix_of` reads the WHOLE leading letter run, so a declaration of `AAA`
does not reach a mark `AAAB001` -- its prefix is `AAAB`, which is not `AAA`,
and the piece is set aside as somebody else's. That is a real hazard: on a
job whose marks carry a suffix letter for a variant, a genuine piece of this
engineer's is quietly handed to another engineer.
The obvious repair -- match a declared prefix as a PREFIX, so `AAA` reaches
`AAAB` -- is not made here, and deliberately:
* It would also reach every OTHER letter run that starts the same way,
pulling pieces that really are somebody else's INTO the checked set.
* A false in-scope is worse than a false out-of-scope. Out of scope is
reported, in a line, by kind. In scope is judged silently against
knowledge that was never validated for it -- which is the exact failure
this module exists to prevent.
* Which of the two a given job means is a JOB FACT. It belongs in the job
file, declared by a person, not in a rule this code guessed.
So the behaviour is unchanged and the near miss is REPORTED: a set-aside
kind that is a prefix of a declared one, or that a declared one is a prefix
of, is named on the report with both spellings and the sentence saying the
tool did not guess. A human widens the declaration -- by adding the longer
prefix to `scope.mark_prefixes` -- or leaves it alone. Nothing here decides.
WHAT THE SCOPE IS EXPRESSED IN, AND WHY
---------------------------------------
The MARK PREFIX -- the letters a piece mark starts with. Reasons, in order:
* Every entry point already has a mark, or an assembly name that is the
mark, in its hand. Sheets are named for their mark too. One rule reaches
all of them.
* The prefix is the ONE stable classifier. A design number moves -- new
designs are added to a live job weekly, and a scope listed as design
numbers goes stale silently, which is the failure mode this file exists
to prevent.
* It is what the reinforcing donor filter already discriminated on, so
adopting it is not a new concept in this codebase, it is the existing
one lifted out of the code and into the job file.
A job whose marks do NOT encode the discipline in their prefix cannot use
this key, and must not be given a guessed answer: it declares nothing, gets
"no scope declared", and every report says every piece was judged. Adding a
second expression -- design numbers, a category, an explicit mark list -- is
a new key beside this one, never a reinterpretation of this one.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.compat import string_types
# Where the declaration lives. JOB layer -- see the module docstring.
KEY = 'scope.mark_prefixes'
NOTE_KEY = 'scope.note'
BY_KEY = 'scope.declared_by'
# The leading letters of a mark. Deliberately letters-only: a mark is
# <letters><number> at every plant seen so far, and anything else is
# 'could not read', which is a reported state rather than a guess.
_PREFIX_RE = re.compile(r'^([A-Za-z]+)')
# Letters followed by a number: the shape of a piece mark or a design number.
# Used where a caller holds a name that MIGHT be a mark -- a sheet name, say
# -- and must not treat 'the general arrangement drawing' as somebody else's
# piece. Anything that is not this shape is left alone by the scope entirely.
_MARK_RE = re.compile(r'^[A-Za-z]+\s*\d')
# What a piece with no readable prefix is called on a report line.
UNREADABLE = '(no readable mark prefix)'
def prefix_of(name):
"""The leading letters of a mark, upper case, or None if there are none."""
if not isinstance(name, string_types):
name = '' if name is None else str(name)
m = _PREFIX_RE.match(name.strip())
return m.group(1).upper() if m else None
def looks_like_mark(name):
"""True when this text has the shape of a piece mark or design number.
A caller holding something that is only SOMETIMES a mark (a sheet name)
passes names through this first, so a drawing that is not a piece is
never counted as another engineer's piece.
"""
if not isinstance(name, string_types):
name = '' if name is None else str(name)
return bool(_MARK_RE.match(name.strip()))
def as_mark(name):
"""The name if it reads as a mark, else '' -- which every predicate here
treats as 'could not tell', i.e. left in."""
return name if looks_like_mark(name) else ''
class Scope(object):
"""The declared scope as a predicate. Construct with no argument for
'nobody has declared one', which includes everything."""
def __init__(self, prefixes=(), note=None, declared_by=None,
source='job knowledge: ' + KEY):
cleaned = []
for p in (prefixes or ()):
text = ('' if p is None else str(p)).strip().upper()
if text and text not in cleaned:
cleaned.append(text)
self.prefixes = tuple(cleaned)
self.note = note or None
self.declared_by = declared_by or None
self.source = source
# -------------------------------------------------------------- state
@property
def is_declared(self):
return bool(self.prefixes)
def label(self):
"""What the scope IS, in words a reader can check against reality."""
if not self.is_declared:
return 'no scope declared'
return 'piece marks beginning {0}'.format(
' or '.join(self.prefixes))
# ---------------------------------------------------------- predicate
def includes(self, name):
"""True when this mark is the engineer's to judge.
True for everything when no scope is declared, and true for a mark
whose prefix cannot be read -- 'I could not tell' never removes work.
"""
if not self.is_declared:
return True
prefix = prefix_of(name)
if prefix is None:
return True
return prefix in self.prefixes
def is_unreadable(self, name):
return prefix_of(name) is None
def kind(self, name):
"""The group a set-aside item is reported under."""
return prefix_of(name) or UNREADABLE
# ------------------------------------------------------------ splits
def split(self, items, key=None):
"""(inside, outside) preserving order. `key` reads the mark."""
key = key or (lambda x: x)
inside, outside = [], []
for item in (items or []):
(inside if self.includes(key(item)) else outside).append(item)
return inside, outside
def split_names(self, names):
return self.split(list(names or []))
def tally(self, names):
"""[(kind, count)] for the things set aside, biggest first."""
counts = {}
for name in (names or []):
kind = self.kind(name)
counts[kind] = counts.get(kind, 0) + 1
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
# -------------------------------------------------------- the near miss
def nearly(self, kind):
"""The declared prefix `kind` NEARLY is, or None.
'Nearly' is deliberately narrow: one of the two spellings is a
prefix of the other. `AAAB` nearly matches a declared `AAA`, and a
bare `AAA` nearly matches a declared `AAAB` -- either way somebody
wrote one and meant, perhaps, the other. `BBB` nearly matches
nothing. An exact match is not a near miss, it is a match.
"""
if not self.is_declared or not kind or kind == UNREADABLE:
return None
for declared in self.prefixes:
if kind == declared:
return None
if kind.startswith(declared) or declared.startswith(kind):
return declared
return None
def near_misses(self, names):
"""[(kind, count, declared)] for set-aside kinds that nearly match.
Biggest first. Empty when the scope is undeclared, when nothing was
set aside, or when nothing set aside resembles the declaration --
which is the ordinary case and prints nothing.
"""
out = []
for kind, count in self.tally(names):
declared = self.nearly(kind)
if declared:
out.append((kind, count, declared))
return out
def near_miss_line(self, outside_names, unit='piece'):
"""The sentence a human has to read, or None.
Says both spellings and says the tool did not choose between them.
Never a verdict: a near miss is a question for the person who holds
the job knowledge, and the only two answers -- widen the declaration
or leave it -- are both his.
"""
misses = self.near_misses(outside_names)
if not misses:
return None
bits = ', '.join(
'{0} {1}(s) with mark prefix {2} (the declaration says {3})'
.format(count, unit, kind, declared)
for kind, count, declared in misses)
return ('NEAR MISS -- SOMEBODY SHOULD LOOK: {0} were set aside. A '
'prefix that only NEARLY matches is either work of yours the '
'declaration does not reach, or somebody else\'s work that '
'happens to start the same way, and this tool will not guess '
'which -- guessing wrong towards "yours" would judge another '
'engineer\'s piece silently. If they are yours, add the '
'longer prefix to the declaration ({1}); if they are not, '
'nothing needs doing and this line is the record that it was '
'looked at.'.format(bits, self.source))
# ------------------------------------------------------------ report
def line(self, outside_names, total=None, unit='piece',
unreadable_names=None):
"""ONE line saying what was left alone and why. Never a pass.
`outside_names` are the marks that were NOT judged. `total` is how
many there were altogether, so the reader can see the proportion;
omit it and the line simply does not claim one.
A NEAR MISS RIDES ON THIS LINE rather than on a line of its own, so
that every consumer of `line()` reports it without having to know it
exists. A report that wants it on a shut fold's summary as well asks
for `near_miss_line()` directly.
"""
outside = list(outside_names or [])
unreadable = list(unreadable_names or [])
tail = ''
if unreadable:
tail = (' {0} {1}(s) carry no readable mark prefix; they could '
'not be placed inside or outside the scope and were '
'judged rather than set aside.'
.format(len(unreadable), unit))
near = self.near_miss_line(outside, unit=unit)
if near:
tail += ' ' + near
if not self.is_declared:
return ('NOT JUDGED, OUT OF SCOPE: none set aside. No scope is '
'declared for this job ({0} is empty), so every {1} was '
'judged -- including work this plant knowledge has never '
'been validated against. Declare a scope to be told what '
'is being taken on trust.'.format(self.source, unit)
+ tail)
if not outside:
# Deliberately does NOT say 'everything here was in scope'. The
# user may have selected an out-of-scope piece himself, which is
# judged and annotated rather than set aside; this line's job is
# only to say that nothing was removed from the run.
return ('NOT JUDGED, OUT OF SCOPE: none -- nothing was set aside '
'from this run. The declared scope is {0} ({1}).'
.format(self.label(), self.source) + tail)
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
of_total = ' of {0}'.format(total) if total else ''
return ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) were left alone -- '
'{3}. The declared scope is {4} ({5}); everything else '
'belongs to another engineer and the knowledge this tool '
'runs on is not validated for it. Not judged is not clean, '
'and not a failure either.'
.format(len(outside), of_total, unit, kinds, self.label(),
self.source) + tail)
def short_line(self, outside_names, total=None, unit='piece'):
"""The same three states in ONE SHORT CLAUSE, for a console.
`line()` is the sentence; this is the headline. It exists because
some consumers truncate -- `tools/sweep.py` prints the first 200
characters of what the in-Revit half returns -- and a scope line
that gets cut off the end of a progress line is a scope line nobody
read. It says the COUNT, which is what tells a reader there is a
full sentence somewhere worth opening.
It never replaces `line()`. Anything printing this must also write
`line()` where the reader can reach it.
"""
outside = list(outside_names or [])
if not self.is_declared:
return ('SCOPE: none declared -- every {0} was judged'
.format(unit))
of_total = ' of {0}'.format(total) if total else ''
if not outside:
return ('SCOPE {0}: no {1}{2} was set aside'
.format(self.label(), unit, of_total))
kinds = self.tally(outside)
shown = ', '.join('{0} {1}'.format(k, n) for k, n in kinds[:3])
if len(kinds) > 3:
shown += ', +{0} more'.format(len(kinds) - 3)
text = ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) left alone [{3}]'
.format(len(outside), of_total, unit, shown))
if self.near_misses(outside):
text += ' -- NEAR MISS'
return text
def annotation(self, outside_names, unit='piece'):
"""The line for work the USER picked that falls outside the scope.
Nothing the user selected is ever dropped -- see `honoured_selection`
in the design notes. It is judged, and this says the verdict rests on
knowledge nobody has validated for that kind of piece.
"""
outside = list(outside_names or [])
if not self.is_declared or not outside:
return None
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
# A near miss matters MORE here than on `line()`: the reader is being
# told his own selection rests on unvalidated knowledge, and if the
# prefix only nearly missed, that warning may be the wrong one.
near = self.near_miss_line(outside, unit=unit)
return ('JUDGED OUTSIDE THE DECLARED SCOPE, AT YOUR REQUEST: {0} '
'{1}(s) -- {2}. You selected them, so nothing was dropped; '
'but the declared scope is {3}, and these verdicts rest on '
'knowledge that has not been validated for those pieces. '
'Read them as opinions, not as checks.'
.format(len(outside), unit, kinds, self.label())
+ ((' ' + near) if near else ''))
def declaration_lines(self):
"""Who declared it and what they said, when they said anything."""
out = []
if self.declared_by:
out.append('scope declared by {0}'.format(self.declared_by))
if self.note:
# A YAML folded scalar (`note: >`) arrives with a trailing
# newline, which puts a blank line into every report that prints
# it. Stripped here rather than at four call sites.
out.append(' '.join(str(self.note).split()))
return out
def _as_list(value):
if value is None:
return []
if isinstance(value, string_types):
return [value]
return list(value)
def of(know, get=None):
"""The declared scope out of a loaded knowledge dict.
`get` is `knowledge.get`. It is a parameter so a test can pass its own
and this module never has to import `knowledge` at module scope; the
ordinary path imports it lazily, and reads the three keys through the
alias `K` with LITERAL paths so `tools/knowledge_drift.py` can see that
they are genuinely read. A key nobody can prove is read is a key the
file only claims to honour.
"""
if not know:
return Scope()
if get is None:
from vibe import knowledge as K
return Scope(prefixes=_as_list(K.get(know, 'scope.mark_prefixes', None)),
note=K.get(know, 'scope.note', None),
declared_by=K.get(know, 'scope.declared_by', None))
return Scope(prefixes=_as_list(get(know, 'scope.mark_prefixes', None)),
note=get(know, 'scope.note', None),
declared_by=get(know, 'scope.declared_by', None))
@@ -0,0 +1,720 @@
# -*- coding: utf-8 -*-
"""Naming, finding and vouching for model snapshots. NO REVIT, NO DOCUMENT.
This is the half of the snapshot feature that can be reasoned about without a
licence: what a timestamped detached copy is called, which one is newest, what
a snapshot has to say about where it came from, and what the run log means.
The Revit half is the button
(`WillTools.tab/Tools.panel/SnapshotClose.pushbutton` -- a large button on the
Tools panel since 2026-08-30, a small one in a stack before that) and
the deferred runner (`WillTools.extension/runners/detach_snapshot.py`); both
import this and neither duplicates any of it.
IronPython 2.7 on the extension side, CPython 3.12 in `tools/` -- so no
f-strings, no pathlib, no dataclasses, and `os.path` throughout.
THE THREE PHASES, because every function here is named after one
-----------------------------------------------------------------
The owner's flow is a once-a-day close-model ritual, and the ordering is what
makes it cheap:
(he syncs, himself, outside all of this)
A press the button -> index the OPEN document. The model is already in
RAM and already paid for. Seconds.
B close Revit. The press IS the authorisation; that is the button's name.
C AFTER Revit has exited and the 10 GB is free, a deferred host process
launches one Revit, writes the timestamped detached copy, and quits.
THE ONE-MODEL PREMISE, AND WHY IT NO LONGER RULES THE DESIGN
------------------------------------------------------------
Everything above was built on "the machine holds ONE model at a time, ever":
a 300+ MB file costing 10+ GB open, so Phase C had to wait until the
engineer's Revit was gone. **That premise is empirically false and was
retired on 2026-09-01** -- the live cloud session and a detached sandbox
session ran side by side for a whole morning on this machine. What is still
true is the weaker statement, and it is the one the code now enforces:
NEVER THREE. Two Revits are fine. A third is the one nobody measured.
So `tools/snapshot_cycle.py` sequences rather than waits: it closes the
sandbox BEFORE it launches the Revit that writes the new copy, and launches
the new sandbox only once that one has exited. At every instant there are at
most the live session and one other. The machine-wide "wait until no Revit is
running at all" gate in `tools/snapshot_defer.py` still exists and is still
correct for the close-model ritual, but it is now opt-in
(`--machine-free require`) rather than the law.
The CYCLE that replaced Phase C's tail: make the timestamped copy, close the
sandbox Revit, reopen it on the new copy, run a batch inside it over the
bridge, close it again, journal the lot. Its events are CYCLE_START ..
CYCLE_DONE below, in this same log.
WHY PHASE A NEEDS NO DETACHED COPY, WRITTEN DOWN SO IT IS NOT RE-LITIGATED
--------------------------------------------------------------------------
A snapshot read from the live document immediately after a sync and a
snapshot read from a detached copy of that same sync state contain the same
numbers: the detach is a transport, not a measurement. The detached copy
therefore is not evidence for today's index at all. Its remaining purpose is
FUTURE RE-SCANS -- extractors that do not exist yet, run against the state as
it was on a day nobody can go back to -- and the provenance says exactly that
so a reader does not mistake it for the source of the current numbers.
THE LAYOUT DECISION, AND THE TRAP UNDER IT
-------------------------------------------
The owner asked for the model file to be called
`<name>_detached_YYYYMMDD-HHMM`. Put the stamp in the FILE name and `doc.Title`
changes on every snapshot -- and `doc.Title` is what
`designspec.root_dir` keys the whole job folder on (`WILLTOOLS_JOBS` makes the
job folder `<jobs root>/<doc.Title>` literally). A per-snapshot title means a
per-snapshot job folder: no `knowledge.yaml` in it, so no declared scope and
no parameter-role mapping, and an index built there is both wrong and
invisible to the live session. This office has already met that problem once
and solved it by hand, with a directory junction from the detached title back
to the real job folder -- a fix that does not survive a name that changes
daily.
So the DEFAULT puts the stamp in the FOLDER:
<root>/<title>_20260830-1612/<title> layout 'folder' (default)
<root>/<title>_20260830-1612 layout 'filename'
(both with the model extension on the last segment)
Same string the owner asked for, one path segment up. The title stays
constant, one junction serves every snapshot forever, and the stamp is still
right there in the path Revit prints in its title bar. `layout 'filename'` is
kept, selectable, and `plan()` returns the warning that goes with it rather
than refusing -- a plant that does not use `WILLTOOLS_JOBS` is not exposed to
the trap and should not be lectured about it in code it cannot change.
Discovery reads BOTH layouts, so a folder that already holds hand-made copies
in either shape is understood without being converted.
"""
# pylint: disable=broad-except,invalid-name
import json
import os
import re
import time
# `20260830-1612`. Minute resolution: the process that writes one of these
# takes minutes and nobody presses this button twice inside sixty seconds.
STAMP_FMT = '%Y%m%d-%H%M'
STAMP_RE = re.compile(r'^(?P<base>.+)_(?P<stamp>\d{8}-\d{4})$')
# Named MODEL_EXT rather than after the extension itself: `tools/scrub.py`
# matches <word> + extension case-insensitively, so a constant called RVT
# reads as a model filename at every call site and fails the leak audit.
MODEL_EXT = '.rvt'
# The layouts `plan()` can produce and `snapshots()` can read.
FOLDER = 'folder'
FILENAME = 'filename'
LAYOUTS = (FOLDER, FILENAME)
FILENAME_WARNING = (
"layout 'filename' puts the timestamp in the model's own name, so "
"doc.Title changes on every snapshot. With WILLTOOLS_JOBS set, the job "
'folder is <jobs root>/<doc.Title> -- a new, empty one per snapshot, with '
'no knowledge.yaml, so nothing downstream sees the declared scope. Use '
"layout 'folder' unless you know this job does not resolve that way.")
# What produced a snapshot. `kind` in the provenance block.
LIVE_DOC = 'live-doc' # Phase A: the open model, in session
FRESH_DETACH = 'fresh-cloud-detach' # Phase C: detached from the cloud now
EXISTING_COPY = 'existing-detached-copy' # a re-scan of a file already on disk
KINDS = (LIVE_DOC, FRESH_DETACH, EXISTING_COPY)
PROVENANCE_SCHEMA = 1
# One JSON object per line, appended, never rewritten. Chosen over a single
# JSON document because two processes write it -- the button in Revit and the
# deferred host tool -- and an append of one line cannot corrupt what is
# already there, while a read-modify-write of a whole document can.
LOG_NAME = 'snapshots.jsonl'
# Log event names. The pair that matters is REQUESTED / DONE: a REQUESTED with
# nothing after it is a Phase C that never reported, and that must be visible.
REQUESTED = 'phase-c-requested'
DONE = 'phase-c-done'
FAILED = 'phase-c-failed'
SKIPPED = 'phase-c-skipped'
SNAPSHOT = 'phase-a-snapshot'
# The CYCLE, added 2026-09-01. The old three phases describe one close-model
# ritual; these describe the pipeline that replaced its tail --
# `tools/snapshot_cycle.py`: make the copy, cycle the sandbox Revit onto it,
# run a batch inside it, close it again. Same log, same file, appended by the
# same never-raising `append()`, because a reader wants ONE ledger per job and
# not two that have to be interleaved by hand.
#
# CYCLE_START is written before anything is launched and CYCLE_DONE /
# CYCLE_FAILED after the sandbox is closed, so the same "requested and never
# reported" question `outstanding()` asks of phase C can be asked of a cycle:
# a CYCLE_START with nothing after it is a run that died with a Revit still
# open, which is the one outcome that costs the machine 10 GB until somebody
# notices.
CYCLE_START = 'cycle-start'
CYCLE_DONE = 'cycle-done'
CYCLE_FAILED = 'cycle-failed'
SANDBOX_OPENED = 'sandbox-opened'
SANDBOX_CLOSED = 'sandbox-closed'
BATCH_DONE = 'batch-done'
CYCLE_TERMINAL = (CYCLE_DONE, CYCLE_FAILED)
# `tools/snapshot_defer.py`'s own lifecycle, added 2026-09-02 after an
# incident where it crashed (or was never even reachable) with nothing
# anywhere saying so. WATCHER_LAUNCHED is written once the request has been
# read and the wait/launch is genuinely about to happen -- proof that the
# process this ritual depends on actually started, which a REQUESTED line
# alone cannot prove (the button can write REQUESTED and then fail to spawn
# the host process, or spawn it into a crash before it opens a single file).
# WATCHER_CRASHED is the backstop for anything the existing FAILED paths
# do not already name -- an exception nobody anticipated, caught at the
# outermost level so it is journaled before the process exits rather than
# only printed to a stderr nobody is redirecting (the button spawns this
# tool with `CreateNoWindow = True` and no output redirection at all: an
# unjournalled stderr line there is not merely easy to miss, it is provably
# unread by anything).
WATCHER_LAUNCHED = 'watcher-launched'
WATCHER_CRASHED = 'watcher-crashed'
# ------------------------------------------------------------------- stamping
def stamp(when=None):
"""`YYYYMMDD-HHMM` for a time, or now."""
return time.strftime(STAMP_FMT, time.localtime(when)
if when is not None else time.localtime())
def is_stamp(text):
"""True for exactly `YYYYMMDD-HHMM`. Never raises."""
try:
return bool(re.match(r'^\d{8}-\d{4}$', text or ''))
except Exception:
return False
def split_stamped(name):
"""('base', 'YYYYMMDD-HHMM') out of a stamped name, or (None, None).
The stamp is matched at the END and the base is whatever precedes it, so a
base that itself contains underscores, spaces, or the word `detached` --
all three occur in real titles -- survives intact.
"""
try:
hit = STAMP_RE.match(name or '')
except Exception:
return None, None
if not hit:
return None, None
return hit.group('base'), hit.group('stamp')
def stamped_name(base, stamp_text):
"""`<base>_<stamp>`. The one place the two are joined."""
return '{0}_{1}'.format(base, stamp_text)
# ----------------------------------------------------------- where one goes
def plan(root, base, stamp_text=None, layout=FOLDER):
"""Where the next detached copy goes, and what it will be called.
Returns a dict, never a tuple: this grew a field twice already and a
caller unpacking three values is a caller that breaks on the fourth.
root the configured detached-copies folder
base the SaveAs base name -- normally the detached document's own
title as Revit reports it, so the title never moves
layout 'folder' (default) or 'filename'; see the module docstring
-> {'layout', 'stamp', 'folder', 'path', 'title', 'title_is_stable',
'warning'}
`title` is what `doc.Title` will be after the SaveAs, which is the value
every job-folder lookup downstream keys on. It is returned, rather than
left implicit, because getting it wrong is the expensive failure here and
a caller should be able to print it before committing to the write.
"""
if layout not in LAYOUTS:
layout = FOLDER
stamp_text = stamp_text or stamp()
if layout == FILENAME:
title = stamped_name(base, stamp_text)
return {'layout': FILENAME, 'stamp': stamp_text, 'folder': root,
'path': os.path.join(root, title + MODEL_EXT), 'title': title,
'title_is_stable': False, 'warning': FILENAME_WARNING}
folder = os.path.join(root, stamped_name(base, stamp_text))
return {'layout': FOLDER, 'stamp': stamp_text, 'folder': folder,
'path': os.path.join(folder, base + MODEL_EXT), 'title': base,
'title_is_stable': True, 'warning': None}
# --------------------------------------------------------- finding the newest
def _model_in(folder):
"""The single model file inside a snapshot folder, or None.
More than one is not an error worth raising over -- Revit leaves backups
(a `.0001` counter glued into the name) beside a saved central -- so the SHORTEST name wins,
which is the one without the backup counter glued on.
"""
try:
names = [n for n in os.listdir(folder)
if n.lower().endswith(MODEL_EXT) and
os.path.isfile(os.path.join(folder, n))]
except Exception:
return None
if not names:
return None
names.sort(key=lambda n: (len(n), n))
return os.path.join(folder, names[0])
def snapshots(root):
"""Every timestamped detached copy under `root`, OLDEST FIRST.
Both layouts, in one list, each row saying which it is. A row is only
produced when the model file actually exists: a stamped folder whose model was
deleted or never finished writing is not a snapshot, and reporting it as
one is how a caller ends up handing Revit a path to nothing.
Never raises. A `root` that does not exist is an empty list, because "not
configured yet" and "configured and empty" are the caller's to tell apart
(see `newest`) and neither is exceptional here.
"""
rows = []
try:
names = os.listdir(root)
except Exception:
return rows
for name in names:
full = os.path.join(root, name)
try:
is_dir = os.path.isdir(full)
except Exception:
continue
if is_dir:
base, stamp_text = split_stamped(name)
if not stamp_text:
continue
path = _model_in(full)
if not path:
continue
title = os.path.splitext(os.path.basename(path))[0]
rows.append({'layout': FOLDER, 'stamp': stamp_text, 'base': base,
'folder': full, 'path': path, 'title': title})
continue
if not name.lower().endswith(MODEL_EXT):
continue
base, stamp_text = split_stamped(os.path.splitext(name)[0])
if not stamp_text:
continue
rows.append({'layout': FILENAME, 'stamp': stamp_text, 'base': base,
'folder': root, 'path': full,
'title': os.path.splitext(name)[0]})
# The stamp is fixed-width and zero-padded, so string order IS time order;
# `path` breaks a tie so the answer does not depend on listdir order.
rows.sort(key=lambda r: (r['stamp'], r['path']))
return rows
def newest(root):
"""The most recent snapshot under `root`, or None. Never raises."""
rows = snapshots(root)
return rows[-1] if rows else None
# ------------------------------------------------------------- provenance
def provenance(phase, kind, source_path=None, source_stamp=None,
source_title=None, revit=None, pyrevit=None, job_folder=None,
note=None, when=None):
"""Where a snapshot came from. Attached to the index the snapshot wrote.
A snapshot that cannot say where it came from is the staleness trap this
whole feature exists to close, so every field it needs to answer that is
here whether or not the caller has it:
phase 'A' (from the open document) or 'C' (deferred, detached)
kind one of KINDS -- live document, fresh detach from the
cloud, or a re-scan of a file already on disk. These
are NOT interchangeable and a reader must not have to
guess which happened.
source_path the file the numbers were read from. None for a live
document that has never been saved -- which is itself
worth knowing, so it is recorded as None rather than ''.
source_stamp that file's snapshot timestamp, when it has one. A live
document has none; the field is None and not the run
time, because a source's age and a run's age are
different facts and conflating them is the exact lie
this block exists to prevent.
revit/pyrevit the versions that did the reading.
job_folder where the index was written.
`kind` is validated: an unrecognised value is kept but flagged, so a typo
surfaces as a question instead of quietly becoming a fourth category.
"""
block = {
'schema': PROVENANCE_SCHEMA,
'phase': phase,
'kind': kind,
'kind_known': kind in KINDS,
'source': {'path': source_path, 'stamp': source_stamp,
'title': source_title},
'revit': revit,
'pyrevit': pyrevit,
'job_folder': job_folder,
'at': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(when)
if when is not None else time.localtime()),
'note': note,
}
return block
def provenance_line(block):
"""One printable sentence out of a provenance block. Never raises."""
if not isinstance(block, dict):
return 'no provenance -- this snapshot cannot say where it came from'
src = block.get('source') or {}
where = src.get('path') or src.get('title') or '(unsaved document)'
when = src.get('stamp') or 'no snapshot stamp'
return ('phase {0}, {1}, from {2} ({3}), Revit {4}, written {5}'
.format(block.get('phase') or '?',
block.get('kind') or 'unknown source',
where, when, block.get('revit') or '?',
block.get('at') or '?'))
# --------------------------------------------------------------- the run log
def log_path(folder):
return os.path.join(folder, LOG_NAME)
def append(path, row):
"""One event onto the end of the log. Never raises, ever.
Called from a button that is about to close Revit and from a background
host process, and in both a failure to journal must not become a failure
to do the work. A lost line is acceptable; a crash on the way out is not.
"""
try:
row = dict(row or {})
row.setdefault('at', time.strftime('%Y-%m-%d %H:%M:%S'))
folder = os.path.dirname(path)
if folder and not os.path.isdir(folder):
os.makedirs(folder)
text = json.dumps(row, sort_keys=True)
handle = open(path, 'ab')
try:
handle.write(text.encode('utf-8'))
handle.write(b'\n')
finally:
handle.close()
return True
except Exception:
return False
def read(path, limit=None):
"""The log as a list of dicts, oldest first. A damaged line is skipped.
A half-written last line is the normal consequence of a process being
killed, and it must cost that one line and nothing else -- a log that
raises on read is a log that stops being read.
"""
rows = []
try:
handle = open(path, 'rb')
try:
raw = handle.read().decode('utf-8', 'replace')
finally:
handle.close()
except Exception:
return rows
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
if limit is not None and len(rows) > limit:
rows = rows[-limit:]
return rows
def outstanding(rows):
"""The Phase C that was asked for and never reported back, or None.
A deferred step that silently did not run is the failure mode this design
is most exposed to: the button has already closed Revit, so nobody is
watching, and the next day looks exactly like a day it worked. So the
ledger is paired -- one REQUESTED line written before the close, one
DONE/FAILED/SKIPPED line written by the deferred process -- and this
reports a REQUESTED with nothing after it.
Matching is by `run` id, not by position, so an old unfinished request
still shows after a later one succeeded rather than being papered over by
it. Requests with no id at all fall back to "any later terminal event
clears it", which is the best a line written by an older version allows.
"""
terminal = (DONE, FAILED, SKIPPED)
closed = set()
for row in (rows or ()):
if row.get('event') in terminal and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != REQUESTED:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
# No id: cleared only by a terminal line that comes after it.
later = [r for r in (rows or ())[i + 1:] if r.get('event') in terminal]
if not later:
pending = row
return pending
def last(rows, event):
"""The most recent row with this event, or None."""
for row in reversed(list(rows or ())):
if row.get('event') == event:
return row
return None
def summary(rows):
"""What the button prints about snapshots that already happened.
A list of plain sentences, newest fact first, and it says the awkward
thing out loud: a Phase C that was requested and never reported is named
here, with its timestamp, because the alternative is that it stays
invisible until somebody wonders why the detached folder is empty.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot has been recorded for this job yet']
pending = outstanding(rows)
if pending is not None:
lines.append('WARNING: a detached copy was requested {0} and never '
'reported back. It may have failed, or the machine may '
'have been shut down before it ran.'
.format(pending.get('at') or 'at an unknown time'))
done = last(rows, DONE)
if done is not None:
lines.append('last detached copy: {0} ({1})'
.format(done.get('path') or '?', done.get('at') or '?'))
failed = last(rows, FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last detached copy FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
snap = last(rows, SNAPSHOT)
if snap is not None:
lines.append('last index snapshot: {0}, {1} pieces'
.format(snap.get('at') or '?',
snap.get('pieces')
if snap.get('pieces') is not None else '?'))
return lines
# ------------------------------------------------- the command lines, exactly
def pyrevit_run_argv(pyrevit_exe, script, revit_year=None, purge=True):
"""`pyrevit run <script> [--revit=YYYY] [--purge]` as an argv list.
Built here rather than formatted at the call site so the string the owner
is asked to paste and the string the deferred tool actually runs are
produced by the same code and cannot drift apart.
`--purge` removes the temporary run environment afterwards and is ON:
without it every run leaves a runtime folder behind, and this runs daily.
"""
argv = [pyrevit_exe, 'run', script]
if revit_year:
argv.append('--revit={0}'.format(revit_year))
if purge:
argv.append('--purge')
return argv
def quote(argv):
"""An argv list as a copy-pasteable command line. Windows quoting rules."""
out = []
for arg in argv:
arg = '{0}'.format(arg)
if arg and not re.search(r'[\s"]', arg):
out.append(arg)
else:
out.append('"' + arg.replace('"', '\\"') + '"')
return ' '.join(out)
def revit_exe_guess(year):
"""Where Revit installs itself, for a year. A GUESS -- the caller checks.
Returned unchecked and named `_guess` on purpose: `snapshot.revit_exe` is
the configured answer and this is only what to try when nobody has given
one. A path returned from here that does not exist is the pipeline's cue
to say "configure snapshot.revit_exe", not to invent a second guess.
"""
if not year:
return None
return os.path.join(
os.environ.get('ProgramFiles', 'C:\\Program Files'), 'Autodesk',
'Revit {0}'.format(year), 'Revit.exe')
def pyrevit_exe_guess():
"""Where the pyRevit CLI installs itself. A GUESS -- the caller checks.
Same discipline as `revit_exe_guess`: returned only when the file is
actually there, and never a substitute for `snapshot.pyrevit_exe` stated
and WRONG -- a caller that read an explicit, bad path must never silently
fall back to this. Both `tools/snapshot_cycle.py` and the SnapshotClose
button already did this guess, each with its own copy; this is the one
copy, so `tools/snapshot_defer.py` -- which used to have none at all and
would `argparse.error()` (no journal, nothing) if `--pyrevit` was left
off -- can make the same guess a caller forgot to make for it.
"""
guess = os.path.join(os.environ.get('APPDATA', ''), 'pyRevit-Master',
'bin', 'pyrevit.exe')
return guess if os.path.isfile(guess) else None
_YEAR_RE = re.compile(r'(20\d{2})')
def revit_year_from_exe(path):
"""A 4-digit year recovered from a `Revit.exe` path, or None.
`pyrevit run` accepts an optional `--revit=<year>`; on a machine with
several Revit versions attached (this office had five on 2026-09-01:
2022 through 2026) omitting it is not safe to leave to whatever the CLI
picks by default -- `pyrevit revits` reproducibly throws a bare
"Object reference not set to an instance of an object" on this machine,
and `pyrevit run` with no `--revit=` appears to walk the same path.
`snapshot.revit_exe` almost always carries the year in its own path
(`...\\Autodesk\\Revit 2024\\Revit.exe`), so this recovers it as a
fallback when `snapshot.revit_year` was never separately configured --
which is exactly the job layer shape that produced the 2026-09-01
incident: `revit_exe` set, `revit_year` not.
"""
hit = _YEAR_RE.search(path or '')
return hit.group(1) if hit else None
def revit_exe_argv(revit_exe, model_path):
"""`Revit.exe <model>` as an argv list -- the sandbox launch.
Deliberately not `pyrevit run`: that plays a journal and quits, which is
what a one-shot detach wants and the opposite of what a session an agent
is going to drive over the bridge for ten minutes wants. This starts an
ordinary interactive Revit on one file; pyRevit loads in it exactly as it
does when the file is double-clicked, which is what puts the bridge up.
"""
return [revit_exe, model_path]
_UNSAFE = re.compile(r'[^A-Za-z0-9._-]+')
def batch_log_name(index, command):
"""`batch-01-python-tools-sweep.log` -- one log file per batch command.
The command is in the NAME as well as in the log, because a folder of
`batch-01.log`, `batch-02.log` is a folder nobody opens twice. Truncated
hard: the index is what makes it unique, the words are only there to be
recognised.
"""
text = _UNSAFE.sub('-', '{0}'.format(command or '')).strip('-')
if len(text) > 48:
text = text[:48].rstrip('-')
return 'batch-{0:02d}{1}.log'.format(int(index),
'-' + text if text else '')
def outstanding_cycle(rows):
"""A CYCLE_START with no terminal line after it, or None.
The cycle's version of `outstanding()`, and it matters more: an
unfinished phase C left a copy unwritten, while an unfinished cycle can
have left a Revit open on a 300 MB model with nobody in front of it.
Matched on the `run` id, same as `outstanding()`.
"""
closed = set()
for row in (rows or ()):
if row.get('event') in CYCLE_TERMINAL and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != CYCLE_START:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
later = [r for r in (rows or ())[i + 1:]
if r.get('event') in CYCLE_TERMINAL]
if not later:
pending = row
return pending
def cycle_summary(rows):
"""What the last cycle did, as plain sentences. Newest fact first.
Kept separate from `summary()` rather than folded into it: the button
prints the phase ledger and the pipeline prints this, and a run that
reported nothing has to be as loud in one as in the other.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot cycle has been recorded for this job yet']
pending = outstanding_cycle(rows)
if pending is not None:
lines.append(
'WARNING: a snapshot cycle started {0} and never reported back. '
'A sandbox Revit (pid {1}) may still be open on this machine.'
.format(pending.get('at') or 'at an unknown time',
pending.get('sandbox_pid') or 'unknown'))
done = last(rows, CYCLE_DONE)
if done is not None:
lines.append('last cycle: {0}, {1} batch command(s), {2} s'
.format(done.get('at') or '?',
done.get('batch_count')
if done.get('batch_count') is not None else '?',
done.get('seconds')
if done.get('seconds') is not None else '?'))
failed = last(rows, CYCLE_FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last cycle FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
opened = last(rows, SANDBOX_OPENED)
if opened is not None:
lines.append('last sandbox opened: {0} (pid {1}) on {2}'
.format(opened.get('at') or '?',
opened.get('pid') or '?',
opened.get('path') or '?'))
return lines
+526
View File
@@ -0,0 +1,526 @@
# -*- coding: utf-8 -*-
"""The shape of a design spec, and how it reads and writes as text.
Nothing in this module touches Revit, a document, or the filesystem -- it is
pure data in, text or data out. That is deliberate: it is the part of the
design-spec pipeline that can be exercised by an ordinary CPython test with no
licence, no model and no running Revit, the same way `panellift` is the pure
side of the handling-check boundary (see CLAUDE.md). `designspec.py` is the
Revit side of THIS boundary -- it gathers evidence from the model and reads
and writes these shapes to disk. Anything about what a spec MEANS, or how it
prints, belongs here; anything about reading THIS model belongs there.
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib, no
dataclasses.
"""
# pylint: disable=invalid-name
import datetime
import json
import re
from vibe import yamlkit
from vibe.compat import string_types
SCHEMA_VERSION = 1
# ------------------------------------------------------------- spec shape
#
# The contract every downstream check relies on. Anything the reader is
# unsure about belongs in 'unresolved' rather than being guessed at.
#
# reinforcing.strand_layout is a MATRIX, not four parallel lists: columns_in
# is where the strands sit across the piece (cumulative distance from datum,
# same numbers a detailer would read off the dimension chain), and rows is
# one entry per layer through the thickness, each carrying which of those
# columns actually holds a strand at that height. A design where every column
# has a strand at every row -- the ordinary case -- just has 'strands' filled
# solid; a design that is not a full grid says so directly, column by column,
# rather than forcing a human to reverse-engineer it from a total count.
SPEC_TEMPLATE = {
'schema': SCHEMA_VERSION,
'design_number': None,
'piece_description': None,
'source': {'view': None, 'view_id': None, 'sheet': None,
'model': None, 'extracted': None, 'extractor': None,
'reviewed_by': None, 'reviewed_on': None},
'concrete': {'fc_psi': None, 'fci_psi': None,
'unit_weight_pcf': None, 'weight_class': None},
'reinforcing': {
'mild_grade_ksi': None,
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
'strand_groups': [], # {qty, dia_in, grade_ksi, it_lb, role, note}
'strand_layout': {
'datum': None, # 'left_rail' | 'right_rail' | 'centerline'
'columns_in': [], # strand positions across the piece,
# cumulative distance from the datum
'rows': [], # [{'from_bottom_in':, 'strands': [...]}]
# one entry per layer through the
# thickness; 'strands' marks which column
# holds a strand at that height
'total_in': None, # the chain total; should equal the piece
# width
'note': None,
},
},
'envelope': {'length': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None},
'width': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None}},
'thickness_in': None,
'notes': [], # instructions that stay prose
'unresolved': [], # [{text, reason}] nothing would guess at
'confidence': {}, # field path -> high | medium | low
}
REQUIRED_PATHS = (
'design_number',
'concrete.fc_psi',
'concrete.fci_psi',
)
def get_path(spec, dotted, default=None):
node = spec
for part in dotted.split('.'):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return node if node is not None else default
def validate_spec(spec):
"""Cheap sanity check. Returns a list of complaints, empty when happy."""
problems = []
if not isinstance(spec, dict):
return ['spec is not an object']
if spec.get('schema') != SCHEMA_VERSION:
problems.append('schema is {0}, expected {1}'
.format(spec.get('schema'), SCHEMA_VERSION))
for path in REQUIRED_PATHS:
if get_path(spec, path) is None:
problems.append('missing ' + path)
for i, group in enumerate(get_path(spec, 'reinforcing.strand_groups', []) or []):
for key in ('qty', 'dia_in'):
if group.get(key) is None:
problems.append('strand_groups[{0}] missing {1}'.format(i, key))
if group.get('qty') is not None and group['qty'] <= 0:
problems.append('strand_groups[{0}] qty is {1}'.format(i, group['qty']))
fc = get_path(spec, 'concrete.fc_psi')
fci = get_path(spec, 'concrete.fci_psi')
if fc and fci and fci > fc:
problems.append("f'ci ({0}) is higher than f'c ({1})".format(fci, fc))
for field, lo, hi in (('concrete.fc_psi', 2000, 15000),
('concrete.fci_psi', 1500, 12000),
('concrete.unit_weight_pcf', 90, 200)):
val = get_path(spec, field)
if val is not None and not (lo <= val <= hi):
problems.append('{0} = {1} is outside {2}..{3}'.format(field, val, lo, hi))
return problems
# ---------------------------------------------------------- design numbers
#
# THE ONE PLACE TWO DESIGN NUMBERS ARE COMPARED. It used to live in
# `designspec.py`, which imports `Autodesk.Revit.DB` at module scope, so the
# only way to ask "are these the same design?" was through a module that needs
# a licence. `reinforcebind` has to ask it -- both in Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all -- so the
# function moved down here, where nothing imports Revit, and `designspec`
# re-exports it. Same rule, same name, one implementation: a second spelling
# of this question is how two answers start.
# Used only when nomenclature is unmapped. Letters plus the first number is
# the commonest convention, but it is a fallback, not a rule -- the plant's
# knowledge file is where the real pattern belongs.
FALLBACK_DESIGN_RE = r'^([A-Za-z]+)\s*(\d+)'
def _design_pattern(know, pattern=None):
"""The plant's design-number pattern, or the fallback.
`pattern` wins outright when it is given: it is how a caller that already
resolved the key -- `reinforcebind.DonorFilter`, which must stay free of
any knowledge import -- hands the same rule in without re-reading it.
Imported late: `knowledge` reaches for `designspec`, which imports this
module, so a module-level import would be circular.
"""
if pattern:
return pattern
if know is None:
return FALLBACK_DESIGN_RE
from vibe import knowledge as K
return K.get(know, 'nomenclature.design_number_re', FALLBACK_DESIGN_RE)
def design_key(value, know=None, pattern=None):
"""Reduce anything that names a design to the key everything matches on.
A design packet's cover sheet is numbered with a page suffix, so the same
design is written PNL1 on a piece and PNL1.0 on its summary sheet, with
PNL1.A and PNL1.B1 further into the packet. Comparing those raw splits one
design into several that each fail to find the others -- so every design
number, wherever it comes from, passes through here first.
Returns None for anything the pattern does not recognise, which keeps
junk out of the picker rather than inventing a design for it. None is
COULD NOT READ THIS, never "no design": a caller that treats it as a
value will happily match two pieces whose design numbers nobody parsed.
"""
text = (value or '')
try:
text = text.strip()
except AttributeError:
return None
if not text:
return None
try:
m = re.match(_design_pattern(know, pattern), text)
except Exception: # pylint: disable=W0703
m = re.match(FALLBACK_DESIGN_RE, text)
if not m:
return None
groups = [g for g in m.groups() if g] or [m.group(0)]
return ''.join([g.strip() for g in groups]).upper()
def new_spec(design, evidence=None):
"""A blank spec pre-filled with provenance."""
spec = json.loads(json.dumps(SPEC_TEMPLATE)) # deep copy
spec['design_number'] = design
if evidence:
views = evidence.get('summary_views') or []
sheets = evidence.get('sheets') or []
spec['source']['view'] = views[0]['name'] if views else None
spec['source']['view_id'] = views[0]['id'] if views else None
spec['source']['sheet'] = ('{0} - {1}'.format(sheets[0]['number'],
sheets[0]['name'])
if sheets else None)
spec['source']['model'] = evidence.get('model')
spec['source']['extracted'] = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
return spec
# An extractor that renames a field breaks every check that reads it, silently
# and in the safe-looking direction: the value reads as None and the check
# just does not run. Known synonyms are mapped back onto the canonical names
# on load, and validate_spec still complains about anything left missing.
_ALIASES = {
'qty': ('count', 'quantity', 'number', 'n'),
'dia_in': ('diameter_in', 'dia', 'diameter'),
'grade_ksi': ('grade', 'grade_k', 'ksi', 'strength_ksi'),
'it_lb': ('it_lbs', 'initial_tension_lb', 'it', 'tension_lb',
'initial_tension', 'it_lb_force'),
}
def _first_number(value):
"""270, '270K', '270 ksi' -> 270.0."""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
m = re.search(r'[\d.]+', str(value).replace(',', ''))
return float(m.group(0)) if m else None
def normalize_spec(spec):
"""Map known field synonyms back onto the canonical names, in place."""
if not isinstance(spec, dict):
return spec
groups = get_path(spec, 'reinforcing.strand_groups', []) or []
for group in groups:
if not isinstance(group, dict):
continue
for canonical, synonyms in _ALIASES.items():
if group.get(canonical) is None:
for alt in synonyms:
if group.get(alt) is not None:
group[canonical] = group[alt]
break
for numeric in ('qty', 'dia_in', 'grade_ksi', 'it_lb'):
if group.get(numeric) is not None:
group[numeric] = _first_number(group[numeric])
if group.get('qty') is not None:
group['qty'] = int(group['qty'])
concrete = spec.get('concrete')
if isinstance(concrete, dict):
for canonical, alt in (('fc_psi', 'fc'), ('fci_psi', 'fci'),
('unit_weight_pcf', 'pcf')):
if concrete.get(canonical) is None and concrete.get(alt) is not None:
concrete[canonical] = concrete[alt]
if concrete.get(canonical) is not None:
concrete[canonical] = _first_number(concrete[canonical])
return spec
# --------------------------------------------------------- the strand matrix
_BLANK_MARKS = ('', 'null', 'none', '.', '-', '0')
def strand_positions(layout):
"""Cumulative distances from the datum, whichever spec shape wrote them.
New specs carry 'columns_in'; a spec written before the matrix shape
carries the same numbers under 'positions_in'. Either way this is what
check_strand_layout compares against what was actually modelled.
"""
if not isinstance(layout, dict):
return []
cols = layout.get('columns_in')
if cols is None:
cols = layout.get('positions_in')
return [c for c in (cols or []) if c is not None]
def strand_row_marks(row, n_columns):
"""Which of n_columns holds a strand in this row.
A row from a spec written before the matrix shape has no 'strands' list
at all -- it only ever said WHERE the row sits, not which columns it
fills -- so it is read as a full row, which is what every such spec
actually was.
"""
marks = row.get('strands') if isinstance(row, dict) else None
if marks is None:
return [True] * n_columns
out = []
for i in range(n_columns):
value = marks[i] if i < len(marks) else None
out.append(value is not None and
str(value).strip().lower() not in _BLANK_MARKS)
return out
def _fmt_in(value):
if value is None:
return '?'
text = '{0:.3f}'.format(float(value))
text = text.rstrip('0').rstrip('.')
return text if text else '0'
_DIAGRAM_WIDTH = 68 # target character width of the scaled ruler
def strand_diagram(layout):
"""An ASCII elevation of the strand matrix, drawn from the data itself.
Datum on the left, the chain's segment lengths along a scaled ruler, and
one row of marks per strand row at its height -- the same three things a
detailer reads off the dimension chain on the drawing, in the same left-
to-right order, so the two can be held side by side and compared. There
is nowhere for the picture to get a number the data does not have: every
character position is computed from columns_in / total_in / rows.
"""
if not isinstance(layout, dict):
return ['(no strand layout)']
columns = strand_positions(layout)
total = layout.get('total_in')
rows = [r for r in (layout.get('rows') or []) if isinstance(r, dict)
and r.get('from_bottom_in') is not None]
datum = layout.get('datum') or '(datum not set)'
if not columns or not total:
return ['datum: {0}'.format(datum),
'(not enough data to draw yet -- fill in columns_in, '
'total_in and rows)']
scale = min(1.0, float(_DIAGRAM_WIDTH) / float(total))
def col(pos):
return int(round(pos * scale))
width = col(total) + 1
ticks = [0] + [col(c) for c in columns] + [col(total)]
lengths = []
prev = 0.0
for c in columns:
lengths.append(c - prev)
prev = c
lengths.append(total - prev)
ruler = [u'-'] * width
for t in ticks:
ruler[t] = u'+'
for i in range(len(ticks) - 1):
a, b = ticks[i], ticks[i + 1]
label = _fmt_in(lengths[i])
gap = b - a - 1
if gap >= len(label):
start = a + 1 + (gap - len(label)) // 2
for j, ch in enumerate(label):
ruler[start + j] = ch
lines = [u'datum: {0} (rail on the left, {1}" total)'.format(datum,
_fmt_in(total)),
u''.join(ruler)]
for r in sorted(rows, key=lambda row: -row['from_bottom_in']):
marks = strand_row_marks(r, len(columns))
line = [u' '] * width
for pos, has in zip([col(c) for c in columns], marks):
line[pos] = u'X' if has else u'.'
n = sum(1 for m in marks if m)
lines.append(u'{0} {1}" from bottom ({2} strand{3})'.format(
u''.join(line), _fmt_in(r['from_bottom_in']), n,
u'' if n == 1 else u's'))
lines.append(u'columns (in, from datum): ' +
u', '.join(_fmt_in(c) for c in columns))
return lines
# --------------------------------------------------------- human-readable IO
SPEC_HEADER = u"""# WillTools design spec -- {design}
#
# A HAND-CORRECTABLE reading of the design summary sheet, not a certified
# transcription. Every value below was read by machine (see source.extractor)
# and NONE of it has been checked by a person unless source.reviewed_by is
# set. Ticket QC and Panel Lift Check read this file, not the drawing --
# compare the two before trusting either one.
#
# Edit it by hand where the machine got it wrong. It is read back exactly as
# written: comments and blank lines are ignored, nothing here is re-derived
# from anything else in the file.
"""
_SOURCE_NOTE = u'''# Where this reading came from, and whether anyone has checked it since.
# extractor says HOW it was read: 'regex' is the mechanical text/geometry
# reader -- no judgement applied, every value either matched a rule or is
# null. Nothing branches on the value; it is recorded so a spec written by
# one reader is not mistaken for a spec written by another.
# reviewed_by / reviewed_on are blank until a person sets them by hand after
# checking this file against the drawing.'''
_CONCRETE_NOTE = u'''# f'c and f'ci feed the lift check's concrete-strength gates directly
# (concrete.fc_psi, concrete.fci_psi) -- get one wrong and every handling
# stage is checked against the wrong strength, in whichever direction makes
# the piece look better than it is.'''
_MILD_NOTE = u'''# Mild reinforcing grade, where the design summary states one.'''
_WWF_NOTE = u'''# Welded wire fabric: the mesh designation as printed, how many layers, and
# whether it is one layer per face or shared.'''
_STRAND_GROUPS_NOTE = u'''# One entry per strand group the design summary calls out separately --
# shielded or de-tensioned strands get their own line. it_lb (initial
# tension) feeds the lift check's prestress force directly
# (reinforcing.strand_groups[].it_lb): a wrong qty or it_lb over- or
# understates the precompression credited on every handling stage.'''
_ENVELOPE_NOTE = u'''# The min/max piece size this design is good for, read by pairing each
# MAX./MIN. LENGTH/WIDTH label with the dimension text beside it on the
# drawing. Checked against the piece actually modelled.'''
_UNRESOLVED_NOTE = u'''# Every text note on the design summary that no rule above could place,
# verbatim, with why. This is not optional and it is not a bug list -- it is
# the mechanism that keeps a line of the drawing from being silently dropped
# just because nothing recognised it. A rule that does not match returns
# nothing on its own; landing here is what makes that visible instead of
# quiet. Read every line before trusting this spec, and fold anything real
# into the fields above by hand.'''
_NOTES_NOTE = u'''# Instructions that stay prose rather than being encoded as a field --
# stripping/blocking callouts, cut-down rules, anything that is an
# instruction rather than a number.'''
_CONFIDENCE_NOTE = u'''# Per-field confidence, field path -> high | medium | low. Only ever set
# where a reading made a judgement call -- and only for a field that same
# reading actually just filled, never for one the mechanical reader had
# already placed. The mechanical reader makes no judgement calls to rate, so
# this stays empty on a 'regex' spec.'''
def _strand_layout_note(layout):
diagram = strand_diagram(layout if isinstance(layout, dict) else {})
lines = [u'# Where the strands sit, as a matrix: columns_in is the position of',
u'# each strand across the piece (cumulative distance from datum, the',
u"# same numbers a detailer reads off the dimension chain); each row is",
u'# one layer through the thickness, marking which columns it fills. A',
u'# correct strand COUNT in the wrong PLACE is still a wrong piece, which',
u'# is why this is a grid rather than a total.',
u'#',
u'# The picture below is drawn from the data beneath it and cannot drift',
u'# from it -- if a number here looks wrong, the picture will look wrong',
u'# the same way. Hold it next to the drawing.',
u'#']
for line in diagram:
lines.append(u'# ' + line if line else u'#')
return u'\n'.join(lines)
_REINFORCING_ORDER = ('mild_grade_ksi', 'wwf', 'strand_groups', 'strand_layout')
_REINFORCING_NOTES = {
'mild_grade_ksi': _MILD_NOTE,
'wwf': _WWF_NOTE,
'strand_groups': _STRAND_GROUPS_NOTE,
}
def _reinforcing_block(reinforcing):
reinforcing = reinforcing or {}
lines = [u'reinforcing:']
for key in _REINFORCING_ORDER:
if key not in reinforcing:
continue
lines.append(u'')
if key == 'strand_layout':
lines.append(_strand_layout_note(reinforcing.get('strand_layout')))
else:
for note_line in _REINFORCING_NOTES[key].split(u'\n'):
lines.append(u' ' + note_line)
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
for key in reinforcing:
if key not in _REINFORCING_ORDER:
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
return u'\n'.join(lines)
_SECTION_ORDER = ('schema', 'design_number', 'piece_description', 'source',
'concrete', 'reinforcing', 'envelope', 'thickness_in',
'notes', 'unresolved', 'confidence')
_SECTION_NOTES = {
'source': _SOURCE_NOTE,
'concrete': _CONCRETE_NOTE,
'envelope': _ENVELOPE_NOTE,
'notes': _NOTES_NOTE,
'unresolved': _UNRESOLVED_NOTE,
'confidence': _CONFIDENCE_NOTE,
}
def spec_text(spec):
"""The whole spec, as hand-editable, commented YAML."""
spec = spec or {}
out = [SPEC_HEADER.format(design=spec.get('design_number') or '?')]
for key in _SECTION_ORDER:
if key not in spec:
continue
out.append(u'')
if key == 'reinforcing':
out.append(_reinforcing_block(spec.get('reinforcing')))
continue
note = _SECTION_NOTES.get(key)
if note:
out.append(note)
out.extend(yamlkit.emit({key: spec[key]}, 0))
for key in spec:
if key not in _SECTION_ORDER:
out.append(u'')
out.extend(yamlkit.emit({key: spec[key]}, 0))
return u'\n'.join(out) + u'\n'
def parse_spec_text(text):
"""The inverse of spec_text(): hand-edited YAML back to a spec dict."""
return yamlkit.parse(text)
+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')
+597
View File
@@ -0,0 +1,597 @@
# -*- coding: utf-8 -*-
"""The takeoff: what a job actually contains, per piece and in total. No Revit.
WHAT THIS IS BUILT ON, AND WHY IT IS NOT ANOTHER MODEL SWEEP
-------------------------------------------------------------
`vibe.reinforceindex` already walks every assembly, resolves its members and
records a `families` dict -- family name -> instance count -- on every piece
record. That is a takeoff's raw material and nothing was reading it. This
module takes the index cache as its INPUT and adds no model access of its
own, so a whole-job takeoff costs whatever `reinforceindex.read()` costs and
nothing more.
The consequence is a limit, stated once here because every honest line of the
output depends on it: THIS MODULE CAN ONLY SEE WHAT THE INDEX RECORDED.
Three limits follow and each is reported rather than papered over.
1. The index builds only ELIGIBLE pieces -- reinforced, not on hold. A job
has more assemblies than that. The caller passes `population` so the
report can say what fraction of the job it covered instead of implying
the whole of it.
2. The index keys `families` by FAMILY NAME, and at this plant a
reinforcing family is a BEND SHAPE, not a bar mark: one shape family
carries one bar mark on one piece and a different one on the next. So
hardware resolves to a part number off the family name and REINFORCING
LARGELY DOES NOT. Those instances are counted and reported as
`bar_shape` with the mark unresolved -- never dropped, and never
silently rolled into a bar mark that was guessed.
The fix is one field on the index record and it is written up in
`docs/takeoff.md`: a `marks` counter built from
`ticketqc._member_mark(member, know)`, which already returns the mark an
engineer says out loud on a bar and on an anchor alike. `piece_lines()`
uses it the moment it appears -- see `_bar_lines_from_marks` -- and
needs no other change here.
3. `features is None` on an index record means the piece could not be
MEASURED. It does not mean the piece is empty, and it does not stop the
takeoff: the `families` dict is recorded on nearly every failure path.
A record with no families dict at all is the real could-not-judge, and
it is listed as one with its reason rather than counted as zero.
THE COUNTING RULES THIS JOB HAS ALREADY PAID TO LEARN
------------------------------------------------------
**Control numbers.** An assembly is a MARK. A mark is a drawing. The pieces
on the floor are CONTROL NUMBERS. In one sampled ticket set 151 tickets
carried only 71 distinct marks, and one mark alone accounted for eighteen of
them. So a takeoff that counts marks under-orders every material in the job,
and this one multiplies -- but it prints both figures side by side, with the
multiplier and where the multiplier came from, because per-mark and per-job
answer different questions and a reader will want to check one against the
other. A multiplier the caller could not source is 1 with basis
`assumed_one`, and that is reported as an UNDERCOUNT, loudly, not as a
number.
This module never decides that multiplier and never reads a knowledge key for
it: `pieces` arrives per mark, already sourced and already ranked. What one
exported ticket covers -- `nomenclature.ticket_unit` -- is declared per job,
read in `takeoffgather.run`, and acted on by `takeoffcount.counting_plan`.
Both files carry the reasoning.
**Nesting.** A member whose `SuperComponent` is set is a child and still
appears in `GetMemberIds()`, so a family nested inside another is counted
twice: once as itself, once inside its parent's instance. This module does
not guess. A nesting DECLARED in `takeoff.nested_in` is honoured -- the child
leaves the order list for its own section. A nesting nobody declared is
DETECTED and reported as a suspicion for a person to rule on:
`suspected_nesting()` finds family pairs whose counts are equal on every
piece where both occur. It never subtracts on a suspicion. An automatic
correction that is wrong is invisible in the total; a suspicion that is wrong
costs one sentence to dismiss.
**Attached bars.** `drawings.annotation_covered_by_parent` already records
which bar marks arrive welded to which part. Those bars are real steel and
are always COUNTED. Whether they are ORDERED is a purchasing question, not a
geometry one, so it is a switch -- `takeoff.order_attached_bars_separately`,
default false -- and either way they appear in their own section with the
parent named. Silently either way is the one thing not on offer.
WHAT IT REFUSES TO COUNT
-------------------------
A family the rules cannot classify goes to `unclassified` with its name, its
count and how many pieces carry it. It is never bucketed into 'other'. Every
classification carries a `basis`: `declared` when a knowledge key named the
family outright, `rule:<name>` when a pattern decided it, and the report
groups by basis so every inference this module made is on the page.
"""
# pylint: disable=invalid-name
import re
# Every class this takeoff knows. `unclassified` is a real answer, not a
# failure mode, and it is the only one whose contents are a work list.
BAR = 'bar' # reinforcing, mark resolved
BAR_SHAPE = 'bar_shape' # reinforcing, family is a bend shape, mark unknown
STRAND = 'strand'
MESH = 'mesh'
HARDWARE = 'hardware' # an orderable part with a part number
CAST = 'cast' # separately modelled cast concrete, not an insert
NESTED = 'nested' # declared as a child of another family
NOT_ORDERED = 'not_ordered' # the piece itself, or a modelling construct
UNCLASSIFIED = 'unclassified'
ORDERABLE = (BAR, BAR_SHAPE, STRAND, MESH, HARDWARE, CAST)
# Defaults live here rather than in `knowledge.DEFAULTS` so that this module
# owns them and no other file has to change to add a takeoff. Any knowledge
# layer overrides them under `takeoff:`; the plant or job layer is where the
# real prefixes belong, and `docs/takeoff.md` carries the block to paste.
#
# `not_ordered` DEFAULTS TO EMPTY on purpose. A default list of things to
# ignore is a default under-count, and the families it would name -- the
# panel itself, a modelling zone -- are plant nomenclature that this module
# has no business knowing. Left empty they surface as `unclassified`, which
# is the loud version of the same fact and the one a person can act on.
DEFAULTS = {
'bar_prefixes': ['SR'],
'part_prefixes': ['SP', 'P'],
'shape_markers': ['(SCHEDULED)'],
'not_ordered': [],
'family_class': {},
'nested_in': {},
'attached_to': {},
'order_attached_bars_separately': False,
}
def settings(know, get=None):
"""The `takeoff:` block, defaults filled in. `get` is `knowledge.get`.
`get` is passed in rather than imported so this module needs nothing but
the standard library, which is what keeps it testable off a model.
"""
out = dict(DEFAULTS)
for key in DEFAULTS:
if get is None:
value = ((know or {}).get('takeoff') or {}).get(key)
else:
value = get(know, 'takeoff.' + key)
if value is not None:
out[key] = value
return out
# ------------------------------------------------------------ classification
def _up(text):
return (text or '').upper()
def _prefixed(name, prefixes):
"""The longest `<PREFIX><digits>` token in `name`, or None.
Longest match wins so a part in an `SP` series is never read as the `P`
series inside it, and the lookbehind is what stops an embed family whose
name wraps a part number from resolving to anything but that number.
"""
up = _up(name)
best = None
for prefix in sorted([_up(p) for p in (prefixes or [])],
key=len, reverse=True):
if not prefix:
continue
found = re.search(r'(?<![A-Z0-9])' + re.escape(prefix) + r'(\d+)', up)
if found is None:
continue
token = prefix + found.group(1)
if best is None or len(token) > len(best):
best = token
return best
def classify(family, cfg, is_strand=None, is_mesh=None, is_cast=None):
"""(class, key, basis) for one family name.
`key` is what a person orders by: a part number, a bar mark, a bend
shape, or -- when nothing resolved -- the family name itself, so that an
unclassified row still names the thing it could not classify.
Order matters and is deliberate:
1. a family named outright in knowledge beats every pattern;
2. a declared nesting, so a child never reaches the order list;
3. a declared not-ordered family;
4. strand, mesh and cast concrete, which the existing `families:`
rules already decide and which this module must not re-decide;
5. a bend-shape marker, BEFORE the bar-prefix rule -- a shape family
has no mark in its name and must not borrow one;
6. bar prefixes, then part prefixes;
7. unclassified.
"""
up = _up(family)
declared = cfg.get('family_class') or {}
for key in declared:
if _up(key) == up:
return (str(declared[key]), family, 'declared')
for child in (cfg.get('nested_in') or {}):
if _up(child) == up:
return (NESTED, family, 'declared')
for token in (cfg.get('not_ordered') or []):
if _up(token) and _up(token) in up:
return (NOT_ORDERED, family, 'declared')
if is_strand is not None and is_strand(family):
return (STRAND, family, 'rule:strand')
if is_mesh is not None and is_mesh(family):
return (MESH, family, 'rule:mesh')
if is_cast is not None and is_cast(family):
return (CAST, family, 'rule:cast')
for marker in (cfg.get('shape_markers') or []):
if _up(marker) and _up(marker) in up:
return (BAR_SHAPE, family, 'rule:shape_marker')
mark = _prefixed(family, cfg.get('bar_prefixes'))
if mark:
return (BAR, mark, 'rule:bar_prefix')
part = _prefixed(family, cfg.get('part_prefixes'))
if part:
return (HARDWARE, part, 'rule:part_prefix')
return (UNCLASSIFIED, family, 'none')
# ------------------------------------------------------------- attached bars
def attached_parents(cfg, know, get=None):
"""{BAR MARK: [parent marks]} -- bars that ship welded to a part.
Read from `drawings.annotation_covered_by_parent`, which the drawing
checks already maintain, so a bar cannot be attached for the purpose of
one check and loose for another. `takeoff.attached_to` overrides it for a
job where the two genuinely differ.
"""
if get is None:
source = ((know or {}).get('drawings') or {}).get(
'annotation_covered_by_parent') or {}
else:
source = get(know, 'drawings.annotation_covered_by_parent', {}) or {}
override = cfg.get('attached_to')
if override:
source = override
out = {}
for child in source:
parents = source[child]
if isinstance(parents, (list, tuple)):
out[_up(child)] = [str(p) for p in parents]
elif parents:
out[_up(child)] = [str(parents)]
return out
# ----------------------------------------------------------------- one piece
def piece_lines(record, cfg, attached=None, is_strand=None, is_mesh=None,
pieces=1, is_cast=None):
"""Every takeoff line for one index record, per-mark and per-job.
`pieces` is the control-number multiplier: how many physical pieces this
one mark issues. `per_mark` is what one of them contains; `per_job` is
what the job needs.
"""
attached = attached or {}
lines = []
families = (record or {}).get('families') or {}
marks = (record or {}).get('marks')
skip = set()
if marks:
lines.extend(_bar_lines_from_marks(
record, marks, families, cfg, attached, pieces))
skip = set(_up(f) for f in _shape_families(families, cfg))
for family in sorted(families):
if _up(family) in skip:
continue
klass, key, basis = classify(family, cfg, is_strand, is_mesh, is_cast)
count = int(families[family] or 0)
lines.append(_line(record, klass, key, family, basis, count, pieces,
attached))
return sorted(lines, key=lambda r: (r['class'], r['key']))
def _shape_families(families, cfg):
"""Families that are bend shapes -- the ones a `marks` counter replaces."""
out = []
for family in families:
for marker in (cfg.get('shape_markers') or []):
if _up(marker) and _up(marker) in _up(family):
out.append(family)
break
return out
def _bar_lines_from_marks(record, marks, families, cfg, attached, pieces):
"""Bar lines built from a per-instance mark counter, when one exists.
THIS IS THE PATH THAT DOES NOT EXIST YET. `reinforceindex.extract` does
not record `marks`; the moment it does, every `bar_shape` row in the
output turns into a real bar mark and nothing else in this module
changes. Written now so that the index change is a one-field change and
not a rewrite, and exercised by the tests with a synthetic record.
A mark counter carries hardware marks too, because a hardware family and
its mark are the same string. Only marks that read as a BAR and are not
already a family we counted are taken from here; anything else would be
counted once as a family and once again as a mark.
"""
known = set(_up(f) for f in families)
lines = []
for mark in sorted(marks):
bar = _prefixed(mark, cfg.get('bar_prefixes'))
if not bar or _up(bar) in known:
continue
lines.append(_line(record, BAR, bar, None, 'index:marks',
int(marks[mark] or 0), pieces, attached))
return lines
def _line(record, klass, key, family, basis, count, pieces, attached):
parents = attached.get(_up(key)) if klass in (BAR, BAR_SHAPE) else None
return {
'piece': (record or {}).get('name'),
'design_number': (record or {}).get('design_number'),
'class': klass,
'key': key,
'family': family,
'basis': basis,
'per_mark': count,
'pieces': int(pieces),
'per_job': count * int(pieces),
'attached_to': list(parents) if parents else None,
}
# ------------------------------------------------------------------- the job
def build(cache, cfg, multipliers=None, attached=None, is_strand=None,
is_mesh=None, population=None, is_cast=None):
"""The whole takeoff. `cache` is a `reinforcecore` cache, read, not touched.
`multipliers` is {piece name: (count, basis)}. A piece missing from it is
1 with basis `assumed_one` and lands in `undercounted`, because a
multiplier nobody could source is not a multiplier of one.
"""
multipliers = multipliers or {}
attached = attached or {}
pieces = (cache or {}).get('pieces') or {}
by_piece = {}
unjudged = []
undercounted = []
all_lines = []
for name in sorted(pieces):
record = pieces[name] or {}
count, basis = multipliers.get(name, (1, 'assumed_one'))
count = int(count or 1)
if basis == 'assumed_one':
undercounted.append(name)
families = record.get('families') or {}
if not families:
unjudged.append({
'piece': name,
'reason': record.get('error') or 'no families recorded',
})
by_piece[name] = {'piece': name, 'pieces': count,
'multiplier_basis': basis, 'lines': [],
'judged': False,
'design_number': record.get('design_number')}
continue
lines = piece_lines(record, cfg, attached, is_strand, is_mesh,
pieces=count, is_cast=is_cast)
by_piece[name] = {
'piece': name,
'design_number': record.get('design_number'),
'pieces': count,
'multiplier_basis': basis,
'lines': lines,
'judged': True,
}
all_lines.extend(lines)
return {
'by_piece': by_piece,
'totals': roll_up(all_lines),
'unclassified': unclassified_rows(all_lines),
'unjudged': unjudged,
'undercounted': sorted(undercounted),
'nesting_suspects': suspected_nesting(pieces),
'attached': attached,
'order_attached_separately': bool(
cfg.get('order_attached_bars_separately')),
'coverage': coverage(cache, population, unjudged),
'bases': bases(all_lines),
'pieces_on_the_floor': sum(
by_piece[n]['pieces'] for n in by_piece),
}
def roll_up(lines):
"""Job totals, keyed (class, key). Both figures, always both."""
out = {}
for line in lines:
slot = (line['class'], line['key'])
row = out.get(slot)
if row is None:
row = {'class': line['class'], 'key': line['key'],
'per_mark_total': 0, 'per_job_total': 0,
'marks': 0, 'families': [], 'bases': [],
'attached_to': line.get('attached_to')}
out[slot] = row
row['per_mark_total'] += line['per_mark']
row['per_job_total'] += line['per_job']
row['marks'] += 1
if line.get('family') and line['family'] not in row['families']:
row['families'].append(line['family'])
if line['basis'] not in row['bases']:
row['bases'].append(line['basis'])
rows = list(out.values())
for row in rows:
row['families'].sort()
row['bases'].sort()
row['multiplier'] = (
round(row['per_job_total'] / float(row['per_mark_total']), 3)
if row['per_mark_total'] else None)
rows.sort(key=lambda r: (r['class'], -r['per_job_total'], r['key']))
return rows
def order_rows(totals, order_attached_separately=False):
"""(order, ships_with_parent, not_ordered) -- the split a buyer needs.
An attached bar is always counted. Whether it appears on the ORDER is the
switch; either way it appears, with its parent named.
"""
order, ships, other = [], [], []
for row in totals:
if row['class'] not in ORDERABLE:
other.append(row)
elif row.get('attached_to') and not order_attached_separately:
ships.append(row)
else:
order.append(row)
return order, ships, other
def unclassified_rows(lines):
"""Every family no rule could place, with what it would have cost.
Never merged into an 'other' row. An under-count that looks tidy is worse
than a messy honest one, so this is the section that has to be read.
"""
out = {}
for line in lines:
if line['class'] != UNCLASSIFIED:
continue
row = out.get(line['key'])
if row is None:
row = {'family': line['key'], 'per_mark_total': 0,
'per_job_total': 0, 'pieces': 0, 'examples': []}
out[line['key']] = row
row['per_mark_total'] += line['per_mark']
row['per_job_total'] += line['per_job']
row['pieces'] += 1
if line.get('piece') and len(row['examples']) < 5:
row['examples'].append(line['piece'])
rows = list(out.values())
rows.sort(key=lambda r: (-r['per_job_total'], r['family']))
return rows
def bases(lines):
"""How many rows each basis decided. The inference budget, on the page."""
out = {}
for line in lines:
out[line['basis']] = out.get(line['basis'], 0) + 1
return out
def coverage(cache, population, unjudged):
"""What the takeoff saw, against what the job has. Never implied.
`assemblies_in_model` is the caller's honest denominator -- the index
only ever builds reinforced, not-on-hold pieces, so `marks_not_indexed`
is a real hole in the job and not a rounding.
"""
pieces = (cache or {}).get('pieces') or {}
population = population or {}
indexed = len(pieces)
assemblies = population.get('assemblies')
return {
'indexed_marks': indexed,
'judged_marks': indexed - len(unjudged),
'unjudged_marks': len(unjudged),
'assemblies_in_model': assemblies,
'marks_not_indexed': (None if assemblies is None
else max(0, assemblies - indexed)),
'built_at': (cache or {}).get('built_at'),
'complete': bool(assemblies is not None and assemblies == indexed
and not unjudged),
}
# ---------------------------------------------------------- nesting suspects
def suspected_nesting(pieces, min_pieces=3, min_agreement=0.9,
min_distinct_counts=2):
"""Family pairs that look like child-inside-parent. A SUSPICION only.
Two families whose counts are EQUAL on the pieces where the first occurs
is the signature of a nested family: the child is counted once on its own
and once inside its parent's instance.
WHY A LOCKSTEP OF ONES IS NOT EVIDENCE. Two families that each appear
exactly once on the same forty pieces agree perfectly and mean nothing --
that is what a pair of unrelated parts on one panel type looks like. What
a nested family does is FOLLOW its parent's count as that count VARIES:
four of one, four of the other; eight and eight. So a pair only qualifies
when the child's count takes at least `min_distinct_counts` different
values across the pieces. Without that filter this list came back with
forty rows of coincidence and the three real pairs buried in them.
WHY THE AGREEMENT IS NOT REQUIRED TO BE PERFECT. The strongest declared
nesting on this job -- a bar that lives inside an embed whose family name
says "with bar" -- has 938 instances of the bar against 943 of the embed.
Five pieces disagree, for reasons that are somebody's to explain, and an
exact-equality test finds nothing at all. So agreement is a RATIO, it is
reported on every row, and `1.0` is visibly different from `0.95`.
DIRECTION IS OFTEN UNDECIDABLE FROM COUNTS ALONE, and saying so is the
honest part. When A and B occur on the same pieces in the same numbers,
the arithmetic cannot tell which sits inside which -- only that one of
the two is very probably being counted twice. Such a pair is reported
ONCE with `direction: undecided` rather than twice with a direction each
way, because two rows reads like two findings.
Nothing is subtracted on this evidence. It is printed so that a person
can move the pair into `takeoff.nested_in`, which IS acted on. An
automatic correction that is wrong is invisible in the total; a suspicion
that is wrong costs one sentence to dismiss.
"""
where = {}
for name in pieces:
record = pieces[name] or {}
for family in (record.get('families') or {}):
where.setdefault(family, {})[name] = int(
record['families'][family] or 0)
def agreement(child, parent):
child_on, parent_on = where[child], where[parent]
if not child_on:
return 0.0
same = 0
for piece in child_on:
if parent_on.get(piece) == child_on[piece]:
same += 1
return same / float(len(child_on))
names = sorted(where)
found = {}
for child in names:
if len(where[child]) < min_pieces:
continue
if len(set(where[child].values())) < min_distinct_counts:
continue
for parent in names:
if parent == child:
continue
if len(where[parent]) < len(where[child]) * min_agreement:
continue
forward = agreement(child, parent)
if forward < min_agreement:
continue
back = (len(where[parent]) >= min_pieces
and agreement(parent, child) >= min_agreement)
key = tuple(sorted((child, parent))) if back else (child, parent)
if key in found:
continue
first, second = (key if back else (child, parent))
found[key] = {
'child': first,
'parent': second,
'direction': 'undecided' if back else 'child_in_parent',
'agreement': round(forward if not back
else min(forward, agreement(parent, child)),
3),
'pieces': len(where[child]),
'instances': sum(where[child].values()),
'parent_pieces': len(where[parent]),
}
out = list(found.values())
out.sort(key=lambda r: (-r['agreement'], -r['instances'], r['child'],
r['parent']))
return out
@@ -0,0 +1,470 @@
# -*- coding: utf-8 -*-
"""How many physical pieces one mark is. The control-number multiplier. No Revit.
WHY THIS IS ITS OWN MODULE
---------------------------
It is the single number that decides whether the takeoff is right, and there
is more than one place it can come from. An assembly is a MARK, a mark is one
drawing, and the pieces on the floor are the pieces the mark issues.
Identical pieces share a mark, so counting marks under-orders every material
in the job.
How badly is measurable off an exported ticket set without opening Revit:
151 tickets, 71 distinct marks. The heaviest mark in that sample issued
eighteen tickets. A per-mark takeoff of that sample would have ordered 71
pieces' worth of hardware for 151 pieces -- IF one ticket is one piece there,
which is a declared fact and not this module's to assume. See the ticket-unit
section below; it is the reason a ticket set can be read to two different
numbers.
THREE SOURCES, RANKED, AND THE RANKING IS THE POINT
----------------------------------------------------
control_numbers the control numbers carried by the model's own
elements, grouped by mark. AUTHORITATIVE: a control
number is one physical piece, and this is where they
actually live. Measured on this job by the job
reconciliation: 2480 control-numbered elements ->
1776 pieces, against 755 assemblies. They sit on
STRUCTURAL FRAMING; walls, floors, columns,
foundations and generic models were swept -- 17,239
elements -- and carried none.
ticket_files the exported tickets, named <job>-<control>_<mark>.
Only covers marks that have been ticketed, so it is a
cross-check rather than a source -- and a very good
one, because it is ground truth for the marks it does
cover.
assembly_instances how many assembly instances share the mark's name.
MEASURED AND FOUND USELESS ON THIS JOB, and kept only
because it is useless in a way worth recording: 755
assembly instances, 755 distinct names, not one
duplicate. It therefore returned 1 for every mark,
which made every per-job figure silently equal its
per-mark figure -- the exact undercount this module
exists to prevent, wearing the costume of a sourced
number. It now REFUSES to emit a count of one (see
`from_assembly_names`), so on a job like this one it
contributes nothing at all and the marks it cannot
speak for stay visibly `assumed_one`.
Anything none of them covers is 1 with basis `assumed_one`, which the report
prints as an UNDERCOUNT rather than as a quantity. A multiplier nobody could
source is not a multiplier of one -- and the reason that rule was worth
writing is that it is what caught `assembly_instances` being wrong.
WHAT ONE TICKET IS, AND WHY THIS MODULE MAY NOT DECIDE IT
----------------------------------------------------------
`ticket_files` is a piece count only where ONE TICKET IS ONE PIECE. That is
not universal and it is not this module's to assume: some plants issue one
ticket per control number, and some issue one ticket covering a whole mark --
one drawing, one sheet, a QUANITY field and several control numbers printed
on it. The same export folder read under the second arrangement gives one
filename per mark, and counting those filenames as pieces would return a
confident 1 for a mark that is really seven. That is exactly the
`assembly_instances` failure in different clothes, and it is why the ranking
above stops being a constant here.
So the fact is DECLARED, in `nomenclature.ticket_unit`, and read out of the
knowledge layers rather than compiled in. `takeoffgather.run` does the read,
with the literal path, and hands the value to `counting_plan()` below, which
turns it into the ranking this run is allowed to use and one sentence saying
why. Three states, and all three are printed:
control_number one ticket per control number. A ticket IS a piece, so
all three sources rank exactly as `ORDER` says.
piece_mark one ticket covers the mark. A ticket filename is not a
piece count, so `ticket_files` is WITHDRAWN and the
ticket-against-model cross-check stands down with a
sentence rather than reporting every mark as a
disagreement.
not declared nobody has said, so this run does not know which of the
two it is looking at. `ticket_files` is withdrawn HERE
TOO, and the refusal names the key to declare.
That last one is the only judgement call in this file and it goes the way
this codebase's other unset switches go -- `rails.left_rail_is_fixed`,
`takeoff.not_ordered`, `notes.instruction_views`. Falling back to
"one ticket is one piece" would be a guess that can only ever UNDER-count,
and it would arrive wearing a source's name (`ticket_files`) rather than
`assumed_one`. Withdrawing the source instead costs nothing that was known:
the marks it covered fall to `assumed_one`, which the report already prints
as an undercount in those words. A number that says "could not judge" is a
different thing from a number that says "judged, found one", and the whole
point of this module is that the two must never look alike.
ONE PARSER FOR ONE RULE
------------------------
A control field may hold a range: `50120-50123` is four pieces, not one
token. `jobrecon.expand_control_field` already implements that expansion,
with its dash normalisation and its 500-wide sanity cap, and this module
CALLS IT rather than reimplementing it. Three implementations of one parsing
rule is how they come to disagree.
"""
# pylint: disable=invalid-name
import re
from vibe import jobrecon
# <job>-<control>_<mark>.pdf, with an optional revision suffix on the mark.
# The older export was named by mark alone, which is exactly how one file per
# mark came to be mistaken for one piece per mark; a name with no control
# number in it is therefore IGNORED rather than counted as one piece.
TICKET_RE = re.compile(r'^(?P<job>[^-_]+)-(?P<control>[^_]+)_(?P<mark>.+)$')
# Precedence, best first. A mark found by two sources takes the better one
# and the report can still show what the other said.
#
# It is the ranking FOR A JOB WHERE ONE TICKET IS ONE PIECE. Which sources a
# given job may actually use is `counting_plan()`, off the declared
# `nomenclature.ticket_unit`; this tuple is the full ranking those plans are
# drawn from, and no caller should reach for it instead of a plan.
ORDER = ('control_numbers', 'ticket_files', 'assembly_instances')
# `nomenclature.ticket_unit` -- what one exported ticket covers. A JOB fact:
# the same plant can do it either way and the manual shows both, so it is
# declared in the job layer and never inferred here.
TICKET_UNIT_KEY = 'nomenclature.ticket_unit'
CONTROL_NUMBER = 'control_number'
PIECE_MARK = 'piece_mark'
# Which sources are a PIECE COUNT under each declared unit. Only
# `ticket_files` turns on this: a control number off a model element is one
# physical piece however the plant tickets it, and a duplicated assembly name
# is a duplicated piece either way.
NO_TICKETS = tuple(b for b in ORDER if b != 'ticket_files')
UNIT_SOURCES = {
CONTROL_NUMBER: ORDER,
PIECE_MARK: NO_TICKETS,
}
def _stem(filename):
name = (filename or '').replace('\\', '/').rsplit('/', 1)[-1]
if '.' in name:
name = name.rsplit('.', 1)[0]
return name
def parse_ticket_name(filename):
"""(mark, control) from an exported ticket filename, or (None, None).
A revision suffix belongs to the ticket, not to the piece: two tickets
for the same control number are one piece, so the control number is what
is counted and the mark keeps its revision stripped.
A CONTROL NUMBER CONTAINS AT LEAST ONE DIGIT, and that one rule is the
difference between reading an export folder and reading a ticket set.
The folder also holds the detail sheets, whose names take the identical
three-part shape -- `<job>-SHEET_X4-1.pdf` parses out a "control number"
of SHEET and a "mark" of X4-1 -- and twelve of those were being counted
as marks. They cost nothing in quantity, because no real mark is named
after a detail sheet, but they inflated what the source claimed to know
and they were caught by `join_quality` rather than by anyone reading the
code. Letters in a control number are still fine; letters ALONE are not.
"""
found = TICKET_RE.match(_stem(filename))
if found is None:
return (None, None)
control = found.group('control').strip()
if not any(ch.isdigit() for ch in control):
return (None, None)
mark = found.group('mark').strip()
mark = re.sub(r'[_-]R\d+$', '', mark, flags=re.IGNORECASE)
return (mark or None, control or None)
def unparsed_ticket_files(filenames):
"""The filenames `from_ticket_files` could not read. Never silent.
A folder of exports is somebody's directory and holds whatever they put
in it. What was skipped is a fact about the source's coverage, so it is
returned rather than assumed to be rubbish.
"""
return sorted(name for name in (filenames or [])
if parse_ticket_name(name) == (None, None))
def from_ticket_files(filenames):
"""{mark: (distinct control numbers, 'ticket_files')}.
Distinct, not total: a mark reissued after a revision has two files and
one piece.
"""
seen = {}
for filename in filenames or []:
mark, control = parse_ticket_name(filename)
if mark and control:
seen.setdefault(mark, set()).add(control)
return dict((mark, (len(seen[mark]), 'ticket_files')) for mark in seen)
def from_assembly_names(names, min_instances=2):
"""{mark: (instances, 'assembly_instances')} -- DUPLICATED NAMES ONLY.
Wants the names WITH duplicates, because the duplication is the whole
signal. A name that appears once is NOT evidence that the mark is one
piece; it is the absence of evidence, and the two must not produce the
same output.
That distinction is not academic. Measured on this job through the
bridge: 755 assembly instances, 755 distinct names, zero duplicates. An
earlier version of this function returned 1 for each of them, every mark
got a confident `assembly_instances` basis, and every per_job figure
silently equalled its per_mark figure -- a whole-job undercount that
looked sourced. `min_instances=2` is what makes that impossible: with no
duplicates this returns {} and every mark falls through to
`assumed_one`, which the report prints as an undercount in those words.
"""
out = {}
for name in names or []:
if not name:
continue
out[name] = out.get(name, 0) + 1
return dict((name, (out[name], 'assembly_instances')) for name in out
if out[name] >= min_instances)
def from_piece_records(pieces):
"""({mark: (pieces, 'control_numbers')}, unreadable) from model elements.
`pieces` is the list `jobreconscan.gather` produces -- the same data
contract the job reconciliation eats, deliberately, so that the takeoff
and the reconciliation cannot end up disagreeing about how many pieces
the job has. Each record is {'control', 'mark', 'id', ...}.
Control numbers are counted DISTINCT per mark, because a control number
shared by several elements is still one piece: the reconciliation reports
2480 control-numbered elements resolving to 1776 pieces, and the
difference between those two numbers is exactly that sharing.
A control field is expanded through `jobrecon.expand_control_field`, so a
range is four pieces rather than one token and an unreadable slot is
RETURNED rather than dropped. An element whose mark is missing cannot be
attributed to a mark and is returned as unreadable too -- it is a real
piece this multiplier cannot place, and losing it silently would
under-count in exactly the way this module exists to prevent.
"""
by_mark = {}
unreadable = []
for piece in pieces or []:
mark = (piece.get('mark') or '').strip()
controls, bad = jobrecon.expand_control_field(piece.get('control'))
for token in bad:
unreadable.append({'id': piece.get('id'), 'mark': mark or None,
'token': token,
'why': 'not readable as a control number'})
if not controls:
continue
if not mark:
unreadable.append({'id': piece.get('id'), 'mark': None,
'token': ', '.join(controls),
'why': 'the element carries no mark, so its '
'pieces cannot be attributed'})
continue
by_mark.setdefault(mark, set()).update(controls)
return (dict((mark, (len(by_mark[mark]), 'control_numbers'))
for mark in by_mark),
unreadable)
def from_control_numbers(by_mark):
"""{mark: (distinct control numbers, 'control_numbers')}.
The already-grouped form, for a caller that has the mapping in hand.
`from_piece_records` is the one that takes the reconciliation's own
element records and does the grouping and the range expansion.
`by_mark` is {mark: [control numbers]} read off the model. Distinct
again, and an empty list is NOT a zero: a mark whose control numbers
could not be read is left out entirely so it falls through to the next
source rather than being multiplied by nothing.
"""
out = {}
for mark in by_mark or {}:
controls = set(str(c).strip() for c in (by_mark[mark] or [])
if str(c).strip())
if controls:
out[mark] = (len(controls), 'control_numbers')
return out
def counting_plan(unit, sources=None):
"""Which sources may count pieces, given the DECLARED `ticket_unit`.
`unit` is the value of `nomenclature.ticket_unit` as the knowledge layers
hold it -- read by the caller, so this module needs no knowledge access
and stays testable with a string. `sources` is {basis: {mark: (n, basis)}}
and is optional: it is used only to say what withdrawing a source
actually COST on this job, which is the difference between a rule and a
finding.
Returns a plan the report prints whole:
unit what was declared, or None
declared somebody said
recognised and this module knows what they said
order the bases that may count pieces, best first
withdrawn the bases that may not, and why is in `reason`
withdrawn_marks marks a withdrawn source knew
cost marks that lose their ONLY source to the withdrawal
reason one sentence, ALWAYS, including when nothing changed
refusal the same sentence when the run COULD NOT JUDGE --
undeclared or unrecognised. None when somebody
declared it, whichever way they declared it.
An unrecognised value is a refusal and not a fallback. A key set to
something nobody implemented is somebody's intention, misspelt; reading
it as the default would silently do the opposite of what they asked for
on exactly the jobs where it matters.
"""
unit = (str(unit).strip() or None) if unit is not None else None
order = UNIT_SOURCES.get(unit)
plan = {
'key': TICKET_UNIT_KEY,
'unit': unit,
'declared': unit is not None,
'recognised': order is not None,
'order': tuple(order if order is not None else NO_TICKETS),
'reason': None,
'refusal': None,
}
plan['withdrawn'] = tuple(b for b in ORDER if b not in plan['order'])
if unit == CONTROL_NUMBER:
plan['reason'] = (
'`{0}: {1}` -- this job issues one ticket per control number, so '
'one exported ticket is one piece and the ticket files count as '
'pieces, ranked behind the model\'s own control '
'numbers.'.format(TICKET_UNIT_KEY, CONTROL_NUMBER))
elif unit == PIECE_MARK:
plan['reason'] = (
'`{0}: {1}` -- one ticket covers a whole mark, so a ticket '
'filename names one control number and NOT one piece. The '
'exported tickets were therefore NOT counted as pieces, and the '
'ticket-against-model cross-check stands down rather than '
'reporting every mark as a disagreement. Marks that only the '
'tickets covered are counted once and shown as an '
'undercount.'.format(TICKET_UNIT_KEY, PIECE_MARK))
elif unit is None:
plan['refusal'] = (
'`{0}` is not declared, so this run does not know whether one '
'exported ticket is one piece or a whole mark of them. The '
'exported tickets were NOT used as a piece count: taking one '
'ticket as one piece would under-count a job ticketed the other '
'way, and it would do it wearing a source\'s name instead of '
'`assumed_one`. Declare `{0}: {1}` (one ticket per control '
'number) or `{0}: {2}` (one ticket covering the mark) in the job '
'knowledge file and press again.'.format(
TICKET_UNIT_KEY, CONTROL_NUMBER, PIECE_MARK))
else:
plan['refusal'] = (
'`{0}` is set to "{1}", which this takeoff does not recognise -- '
'it knows `{2}` and `{3}`. A value nobody can read is not a '
'declaration, so the exported tickets were NOT used as a piece '
'count and nothing was assumed on your behalf.'.format(
TICKET_UNIT_KEY, unit, CONTROL_NUMBER, PIECE_MARK))
if plan['reason'] is None:
plan['reason'] = plan['refusal']
known = {}
for basis in (sources or {}):
for mark in (sources[basis] or {}):
known.setdefault(mark, set()).add(basis)
plan['withdrawn_marks'] = len(
[m for m in known if known[m] & set(plan['withdrawn'])])
plan['cost'] = len([m for m in known
if not known[m] & set(plan['order'])])
return plan
def merge(*sources, **kwargs):
"""Fold the sources together, best basis first. Later never overrides.
`order` (keyword) is the ranking this job is allowed to use --
`counting_plan()['order']`. It defaults to `ORDER`, all three sources, so
a caller that has not asked the question gets exactly the behaviour this
function has always had.
A basis NOT in `order` is DROPPED, never ranked last: a source that is not
a piece count on this job must not become one merely because nothing
better covered the mark. Ranking it 99th would let it through on exactly
the marks where nothing can check it, which is where a wrong multiplier
does its damage.
"""
order = kwargs.pop('order', None) or ORDER
if kwargs:
raise TypeError('merge() got an unexpected keyword argument {0}'
.format(sorted(kwargs)[0]))
rank = dict((basis, i) for i, basis in enumerate(order))
out = {}
for source in sources:
for mark in source or {}:
count, basis = source[mark]
if basis not in rank:
continue
have = out.get(mark)
if have is None or rank[basis] < rank.get(have[1], 99):
out[mark] = (int(count), basis)
return out
def disagreements(a, b):
"""Marks where two sources give different counts. Worth printing.
A mismatch between the exported tickets and whatever the model says is
the cheapest possible audit of the multiplier, and it is the one number
in this takeoff that nothing else can check.
"""
out = []
for mark in sorted(set(a or {}) & set(b or {})):
if a[mark][0] != b[mark][0]:
out.append({'mark': mark,
'left': a[mark][0], 'left_basis': a[mark][1],
'right': b[mark][0], 'right_basis': b[mark][1]})
return out
def join_quality(multipliers, marks):
"""Do the multiplier's marks and the takeoff's marks name the same things?
The takeoff keys on the ASSEMBLY NAME; the multiplier keys on the piece
MARK read off a framing element. Measured, those are the same string --
an assembly named for its mark -- but "measured" is not "guaranteed", and
the failure if they ever diverge is silent and total: every mark falls
through to `assumed_one` and the job is under-ordered wholesale while the
report shows a source that found plenty.
So the join is counted, both ways, and printed. `unmatched_source` is the
tell: a source that found hundreds of marks the takeoff has never heard
of is not a source, it is a different naming scheme.
"""
marks = set(marks or [])
source = set(multipliers or {})
return {
'takeoff_marks': len(marks),
'source_marks': len(source),
'matched': len(marks & source),
'unsourced': sorted(marks - source),
'unmatched_source': sorted(source - marks),
}
def summarise(multipliers, marks=None):
"""The multiplier, as a reader has to be able to check it.
`marks` is every mark the takeoff covered, so that a mark with no
multiplier is counted into `assumed_one` rather than vanishing.
"""
marks = list(marks) if marks is not None else list(multipliers or {})
by_basis = {}
total = 0
for mark in marks:
count, basis = (multipliers or {}).get(mark, (1, 'assumed_one'))
by_basis[basis] = by_basis.get(basis, 0) + 1
total += int(count)
return {
'marks': len(marks),
'pieces': total,
'multiplier': (round(total / float(len(marks)), 3) if marks else None),
'by_basis': by_basis,
'assumed_one': by_basis.get('assumed_one', 0),
}
@@ -0,0 +1,364 @@
# -*- coding: utf-8 -*-
"""The Revit half of the takeoff. Gathering only, and READ ONLY.
The split is the one `reinforceindex` / `reinforcecore` already models:
everything decidable without a document is in `takeoffcore`, `takeoffcount`
and `takeoffreport`, all three unit-tested on host CPython. This file does
the gathering and nothing else. There is no Transaction here, no parameter
write, no delete, and no reason for that to change -- a takeoff reports what
a model contains and has no business changing it.
WHAT IT ACTUALLY READS, AND WHAT IT COSTS
------------------------------------------
Almost nothing, because the expensive work is already cached. The takeoff's
input is `reinforceindex.read(doc)` -- the index as it stands, measured at
1.4 s with no rebuild -- plus one cheap pass over the assembly instances for
the multiplier. No view is generated, no geometry is solved, and none of the
30-60 s per piece this project was warned about is paid.
`refresh` is deliberately NOT called from here. A takeoff off a stale index
is a takeoff of the model as it was, which is a knowable and reportable
thing: `coverage.built_at` says when. A takeoff that silently spends six
seconds rebuilding is a button that sometimes freezes Revit and never says
why. Refreshing the index is the reinforcing tool's job and its button.
WHERE THE MULTIPLIER COMES FROM, AFTER IT WAS MEASURED AND WAS WRONG
--------------------------------------------------------------------
The first version of this file counted how many assembly instances share a
name, on the reasonable-sounding claim that three identical pieces of one
mark are three assemblies. It was written down as UNVERIFIED and it was
verified, through the bridge, and it was WRONG: 755 assembly instances, 755
distinct names, zero duplicates. That source returns 1 for every mark, so
every per-job figure silently equalled its per-mark figure -- a whole-job
undercount wearing the costume of a sourced number.
Control numbers do not live on assemblies at all. They live on STRUCTURAL
FRAMING elements, and there are far more of them than there are assemblies:
2480 control-numbered elements resolving to 1776 pieces, against 755
assemblies. Walls, floors, columns, foundations and generic models were swept
-- 17,239 elements -- and carry none.
So the multiplier now comes from `vibe.jobreconscan.gather`, the job
reconciliation's own gatherer, reusing its data contract rather than sweeping
the model a second time with a second opinion about what a piece is. The
takeoff and the reconciliation cannot disagree about how many pieces the job
has, because they are reading the same records through the same expander.
WHAT THAT COSTS, AND THE ONE NARROWING MADE HERE
-------------------------------------------------
`jobreconscan` sweeps six categories at about 6 s. This asks it for
STRUCTURAL FRAMING ONLY, on the measured evidence above that the other five
carry no control numbers -- so the takeoff's multiplier costs roughly a
second rather than six.
That is a deliberate narrowing of somebody else's careful default and it is
therefore reported, not assumed: `extras['multiplier']['categories']` names
what was searched and the report prints it. If a piece is ever modelled as a
wall, this run will not see it, and the honest place for that sentence is on
the page rather than in this comment. Pass `categories=None` to sweep all six.
WHAT ONE EXPORTED TICKET IS, AND WHERE THAT IS READ
----------------------------------------------------
`run()` reads `nomenclature.ticket_unit` out of the knowledge layers and
hands it to `takeoffcount.counting_plan`. It decides one thing and it decides
it for a good reason: the exported tickets are a count of PIECES only where
one ticket is one control number. A plant that puts a whole mark on one
ticket -- one drawing, a QUANITY field, several control numbers listed --
exports one filename per mark, and reading that as one piece per mark is the
`assembly_instances` undercount again.
Declared `piece_mark`, or not declared at all, the ticket source is WITHDRAWN
rather than demoted, and the ticket-against-model cross-check stands down
with a sentence. Undeclared is a refusal that names the key: this run cannot
tell which kind of folder it is looking at, and assuming the generous reading
would under-count while wearing a source's name.
A ROLE NOBODY HAS MAPPED IS A REFUSAL, NOT AN EMPTY JOB
--------------------------------------------------------
`member.control_number` is a knowledge role and it is not mapped in the plant
layer yet. Unmapped, this returns no multipliers at all -- and that must read
as "the takeoff could not find out", never as "every mark is one piece". The
refusal is carried out in `extras['multiplier']['refusal']` and the report
leads the multiplier section with it.
"""
# pylint: disable=import-error,broad-except,invalid-name
import os
from vibe import takeoffcore as core
from vibe import takeoffcount as count
# Every control number in this model sits on structural framing. Measured by
# the job reconciliation across six categories and 17,239 elements; the other
# five carry none. See the module docstring for why this narrowing is
# reported rather than assumed.
FRAMING_ONLY = ('OST_StructuralFraming',)
def population(doc):
"""How many assemblies the model actually has. The honest denominator.
Cheap: `reinforceindex.assemblies` is a scoped collector over assembly
instances with no geometry, measured at 0.29 s over 755 of them.
"""
from vibe import reinforceindex
pairs = reinforceindex.assemblies(doc)
names = [name for name, _el in pairs]
return {
'assemblies': len(set(names)),
'assembly_instances': len(names),
'names': names,
}
def assembly_multipliers(names):
"""{mark: (instances, 'assembly_instances')} -- duplicated names only.
Kept as a last-resort source and expected to return {} on this job: 755
assemblies, 755 distinct names. It can no longer emit a count of one, so
it cannot turn "no source" into a confident 1. See `takeoffcount`.
"""
return count.from_assembly_names(names)
def control_multipliers(doc, know=None, categories=FRAMING_ONLY):
"""({mark: (pieces, 'control_numbers')}, info) off the model's elements.
Delegates the sweep to `vibe.jobreconscan.gather`, which is the module
that established where control numbers live and how a control field is
expanded. Nothing here re-derives either.
`info` always says what happened, including when nothing did: an unmapped
role produces an empty mapping AND a `refusal` string, and the two are
not the same output as a job with no control numbers in it.
"""
from vibe import jobreconscan
from vibe import knowledge as K
info = {'categories': list(categories) if categories else None,
'refusal': None, 'unreadable': [], 'elements': 0, 'notes': []}
if not K.param_names(know, 'member.control_number'):
info['refusal'] = (
'The knowledge role `member.control_number` is not mapped, so the '
'model was not asked how many pieces each mark is. Every mark '
'below is counted ONCE and every per-job figure equals its '
'per-mark figure. This is a refusal, not a finding: map '
'`parameters.member.control_number` in the plant knowledge file '
'(the same role Job Recon needs) and press again.')
return {}, info
try:
pieces, _sheets, notes, _timing = jobreconscan.gather(
doc, know=know, categories=categories)
except Exception as exc:
info['refusal'] = ('The model could not be read for control numbers: '
'{0}. Nothing was multiplied.'.format(exc))
return {}, info
info['elements'] = len(pieces)
info['notes'] = list(notes or [])
multipliers, unreadable = count.from_piece_records(pieces)
info['unreadable'] = unreadable
return multipliers, info
def ticket_multipliers(folder):
"""{mark: (controls, 'ticket_files')} from a folder of exported tickets.
Ground truth for the marks it covers and silent about the rest. Reads
filenames only -- it never opens a PDF, and the folder is a job path that
belongs in knowledge, never in this file.
"""
if not folder or not os.path.isdir(folder):
return {}
try:
names = os.listdir(folder)
except Exception:
return {}
return count.from_ticket_files(names)
# -------------------------------------------------------- the declared scope
def _apply_scope(the_scope, cache, pop):
"""(cache, population, info) narrowed to the declared scope.
Pure dict work, no document. Returns COPIES -- the index cache is read
from disk and shared, and quietly editing it would leave a narrowed index
behind for the next caller who never asked for one.
`info` always carries the line, including when the scope is undeclared or
set nothing aside. A takeoff that counted everything must say so just as
loudly as one that did not, because the reader cannot otherwise tell a
complete count from a filtered one.
"""
pieces = (cache or {}).get('pieces') or {}
names = sorted(pieces.keys())
kept, left = the_scope.split_names(names)
pop_kept, pop_left = the_scope.split_names((pop or {}).get('names') or [])
info = {
'declared': the_scope.is_declared,
'label': the_scope.label(),
'declaration': the_scope.declaration_lines(),
'left_alone': the_scope.tally(left),
'left_alone_marks': len(left),
'counted_marks': len(kept),
'line': the_scope.line(left, total=len(names), unit='indexed mark'),
'assemblies_line': the_scope.line(
pop_left, total=len(pop_kept) + len(pop_left), unit='assembly')
if the_scope.is_declared else None,
}
if not the_scope.is_declared or not left:
return cache, pop, info
new_cache = dict(cache or {})
new_cache['pieces'] = dict((n, pieces[n]) for n in kept)
new_pop = dict(pop or {})
new_pop['names'] = pop_kept
new_pop['assemblies'] = len(set(pop_kept))
new_pop['assembly_instances'] = len(pop_kept)
return new_cache, new_pop, info
# ------------------------------------------------------------------- run it
def run(doc, know=None, ticket_folder=None):
"""The whole takeoff for the open model. READ ONLY.
Returns (takeoff, counts, extras) where `extras` carries the multiplier
sources separately so the report can show them disagreeing. A
disagreement between the exported tickets and the model is the only audit
the multiplier has, and it costs a directory listing.
"""
from vibe import reinforceindex
if know is None:
from vibe import knowledge as K
know = K.load(doc)
from vibe import knowledge as K
cfg = core.settings(know, K.get)
attached = core.attached_parents(cfg, know, K.get)
cache = reinforceindex.read(doc)
pop = population(doc)
# ---- the declared scope ---------------------------------------------
#
# A takeoff is a job document and a job has several engineers on it. What
# is counted here is what the declared scope says is this engineer's, and
# what was left out is COUNTED AND NAMED rather than dropped -- `extras`
# carries the line and the report prints it above the quantities. No
# declaration means everything is counted, which is the honest default.
#
# The index, the assembly population and the multipliers are all narrowed
# together. Narrowing one of them would leave the report claiming a hole
# in the job ('marks in the model but NOT indexed') that is really just
# somebody else's work.
from vibe import scope as scopelib
the_scope = scopelib.of(know, K.get)
cache, pop, scope_info = _apply_scope(the_scope, cache, pop)
if ticket_folder is None:
ticket_folder = K.get(know, 'takeoff.ticket_folder')
# ---- what one exported ticket is ------------------------------------
#
# DECLARED, not decided here: `nomenclature.ticket_unit`. One ticket per
# control number makes the exported tickets a piece count; one ticket per
# mark makes them a count of drawings, and reading a folder of the second
# kind as the first returns a confident 1 for a mark that is really
# seven. The read is done HERE, with the literal path, because this is
# the file that already holds a document and a knowledge module;
# `takeoffcount.counting_plan` owns everything that follows from the
# value and is testable on a string.
unit = K.get(know, 'nomenclature.ticket_unit')
from_control, multiplier_info = control_multipliers(doc, know)
from_assembly = assembly_multipliers(pop['names'])
from_tickets = ticket_multipliers(ticket_folder)
# Order matters and is the plan's, drawn from `takeoffcount.ORDER`:
# control numbers off the model, then the exported tickets IF this job
# tickets one piece at a time, then -- last, and empty on a job whose
# assembly names are all distinct -- duplicated assembly names. A source
# the plan withdrew is dropped, not demoted.
plan = count.counting_plan(unit, {
'control_numbers': from_control,
'ticket_files': from_tickets,
'assembly_instances': from_assembly})
multipliers = count.merge(from_control, from_tickets, from_assembly,
order=plan['order'])
if the_scope.is_declared:
multipliers = dict((m, v) for m, v in multipliers.items()
if the_scope.includes(m))
takeoff = core.build(
cache, cfg,
multipliers=multipliers,
attached=attached,
is_strand=lambda fam: K.is_strand(know, fam),
is_mesh=lambda fam: _is_mesh(K, know, fam),
is_cast=lambda fam: K.matches(
fam, K.get(know, 'families.cast_include', []), []),
population=pop)
marks = sorted((cache.get('pieces') or {}).keys())
counts = count.summarise(multipliers, marks)
counts['refusal'] = multiplier_info.get('refusal')
counts['join'] = count.join_quality(multipliers, marks)
counts['sources'] = {
'control_numbers': len(from_control),
'ticket_files': len(from_tickets),
'assembly_instances': len(from_assembly),
}
counts['ticket_unit'] = plan
extras = {
'scope': scope_info,
'multiplier': multiplier_info,
# The cross-check compares two counts of PIECES. Where the tickets
# are not a piece count -- declared `piece_mark`, or nobody has said
# -- every mark would "disagree" for a reason that is not a finding,
# so the comparison is stood down and the plan's sentence says so
# instead. A cross-check nobody may believe is worse than none.
'disagreements': (count.disagreements(from_control, from_tickets)
if 'ticket_files' in plan['order'] else []),
'index_path': reinforceindex.index_path(doc),
}
return takeoff, counts, extras
def _is_mesh(K, know, family):
prefix = K.get(know, 'families.wwf_prefix', '')
return bool(prefix) and (family or '').upper().startswith(
str(prefix).upper())
def write_reports(doc, takeoff, counts, folder=None, stem='takeoff',
extras=None):
"""Write the markdown and both CSVs beside the model. Returns the paths.
Beside the MODEL, under the same `_ticketqc` root the index and the
ticket evidence already use -- never into the repository. A takeoff is
job data: it names marks, part numbers and quantities for one client's
job, and the repository must never see it.
"""
from vibe import designspec
from vibe import takeoffreport as report
if folder is None:
folder = designspec.sub_dir(doc, 'takeoff')
if not os.path.isdir(folder):
os.makedirs(folder)
paths = {}
for suffix, text in (
('.md', report.markdown(takeoff, counts, extras=extras)),
('-totals.csv', report.csv_totals(takeoff)),
('-by-piece.csv', report.csv_by_piece(takeoff))):
path = os.path.join(folder, stem + suffix)
handle = open(path, 'wb')
try:
handle.write(text.encode('utf-8'))
finally:
handle.close()
paths[suffix] = path
return paths
@@ -0,0 +1,777 @@
# -*- coding: utf-8 -*-
"""Rendering a takeoff: sections that fold, and CSV to order from. No Revit.
TWO OUTPUTS BECAUSE THERE ARE TWO READERS
------------------------------------------
The page is for the person deciding whether to believe the number. The CSV is
for the person placing the order, one row per part number or bar mark, with
both figures on every row so a spreadsheet can check one against the other.
THE SHAPE, AND WHOSE IT IS
---------------------------
The owner's standing rule for every report on this tab lives in
`vibe.foldreport`, and this file obeys it rather than restating it: the
answer is the headline, biggest and first and never folded; everything else
starts CLOSED; a section that carries a problem OPENS ITSELF and says on its
header why. The reader's contract is "if nothing below the headline is open,
nothing needs me".
WHAT THAT CHANGED HERE, AND WHY IT IS NOT A LOSS
--------------------------------------------------
This report used to lead with the uncertainty -- coverage, then the
multiplier, then the quantities -- on the reasoning that a caveat under a
total is a caveat nobody read. That reasoning was right about the danger and
wrong about the cure: a page whose first three screens are caveats is a page
whose caveats are scrolled past, every press, until they are furniture. What
actually gets a caveat read is that it is the ONLY thing open.
So the order is inverted and nothing is softened:
* the headline is the order, and it carries the multiplier's basis on it,
so the totals cannot be read without meeting what scaled them;
* a REFUSAL (nobody mapped the control-number role, so the model was never
asked how many pieces a mark is) is said in the headline AND opens its
own section below with the key somebody has to map. It is never folded;
* what one exported ticket covers -- `nomenclature.ticket_unit` -- is
stated in every state, on the multiplier section's folded header and in
its body, and the section OPENS when an undeclared one cost a mark its
only source. A source the declaration withdrew is shown as withdrawn in
the sources table rather than as a source that found nothing;
* an undercount, a source whose marks match nothing here, an unclassified
family, a nesting suspicion, a mark in the model that the index never
saw -- each opens itself the moment it is non-empty;
* `markdown()` still renders EVERY section in full, folded or not. It is
what the `.md` beside the model holds, so a fold that will not open in
pyRevit's IE-era panel costs a file open and never a sentence.
WHAT IS DELIBERATELY LEFT FOLDED
---------------------------------
The by-piece breakdown, the attached-bar section, the coverage table on a
clean job, where the multiplier swept, and the basis tallies. All of it is
still printed -- one header line each saying what it holds and how much --
because folded is not gone.
"""
# pylint: disable=invalid-name
from vibe import compat
from vibe import foldreport as fold
from vibe import takeoffcore as core
CLASS_TITLE = {
core.HARDWARE: 'Hardware, by part number',
core.BAR: 'Reinforcing, by bar mark',
core.BAR_SHAPE: 'Reinforcing, bend shape only -- MARK NOT RESOLVED',
core.STRAND: 'Strand',
core.MESH: 'Welded wire fabric',
core.CAST: 'Separately cast concrete',
core.NESTED: 'Declared nested inside another family -- not ordered',
core.NOT_ORDERED: 'Declared not orderable',
core.UNCLASSIFIED: 'UNCLASSIFIED -- counted, not classified',
}
def _n(value):
return '' if value is None else str(value)
def sections(takeoff, counts=None, title='Takeoff', extras=None):
"""The takeoff as `vibe.foldreport.Section`s, headline first. Pure.
The headline is the ORDER: hardware by part number and reinforcing by bar
mark, both figures on every line, with the line that says what the
per-job column rests on printed above them. Everything else is a section
and every section is folded unless it holds something the reader has to
act on.
`extras` is what `takeoffgather.run` returns third -- where the
multiplier came from, what it could not read, and where its sources
disagree. Optional, because the core is testable without a model; when it
is absent the page simply carries less about provenance, never a
confidence it has not earned.
"""
order, ships, other = core.order_rows(
takeoff.get('totals') or [],
takeoff.get('order_attached_separately'))
return ([_headline(takeoff, counts, title, order)]
+ _multiplier_sections(takeoff, counts, extras)
+ _scope_sections(extras)
+ _coverage_sections(takeoff)
+ _quantity_sections(takeoff, order, ships, other)
+ _detail_sections(takeoff))
# --------------------------------------------------------------- the answer
def _headline(takeoff, counts, title, order):
"""The order, and the one line saying what the per-job column rests on.
Never folded, so the multiplier's basis is stated HERE and not only in
the section that owns it: a reader who takes the totals off this page and
reads nothing else must still have met the thing that scaled them.
"""
head = fold.Section('headline', title, headline=True)
head.md('# ' + title)
head.md('_Built from the reinforcing index. No model sweep of its own, '
'and nothing here was written to the model._')
for line in _basis_lines(counts):
head.md(line)
head.md('## Quantities to order &mdash; `per_mark` is one of each mark, '
'`per_job` is what the job needs')
shown = 0
for klass in (core.HARDWARE, core.BAR):
rows = [r for r in order if r['class'] == klass]
if not rows:
continue
shown += 1
head.md('### ' + CLASS_TITLE.get(klass, klass))
head.table(*_totals_table(rows))
if not shown:
head.md('**Nothing was classified as hardware or as a bar mark.** '
'Whatever this job contains is in the sections below, not in '
'this table &mdash; an empty order is a finding, not a zero.')
return head
def _basis_lines(counts):
"""What multiplied `per_mark` into `per_job`, in one or two lines.
A REFUSAL IS NOT A FOOTNOTE. When nothing sourced the multiplier every
per-job figure equals its per-mark figure, which is a whole-job
undercount wearing the costume of a total -- so it is said in the
headline, above the numbers it governs, and said again in full (with the
key somebody has to map) in the section that opens itself below.
"""
if not counts:
return ['> **No multiplier source was available. Every mark was '
'counted once. The whole per_job column is an undercount.**']
out = []
if counts.get('refusal'):
out.append('> **The multiplier could not be sourced, so every mark '
'was counted ONCE and the whole `per_job` column is an '
'UNDERCOUNT.** Why, and what to map, is in the open '
'section below.')
line = ('**`per_job` = `per_mark` x control number.** {0} mark(s), {1} '
'piece(s) on the floor, average multiplier {2}.'.format(
_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier'))))
basis = _basis_summary(counts)
if basis:
line += ' Sourced from: {0}.'.format(basis)
out.append(line)
return out
def _basis_summary(counts):
"""`control_numbers 700, assumed_one 55` -- the multiplier basis, tallied.
On the headline because a multiplier that is half assumed is a different
number from one that is sourced, and the reader cannot see which from the
average alone.
"""
by_basis = (counts or {}).get('by_basis') or {}
return ', '.join('{0} {1}'.format(k, by_basis[k]) for k in sorted(by_basis))
# ------------------------------------------------------------ the multiplier
def _multiplier_sections(takeoff, counts, extras):
"""The multiplier, its provenance, and the three ways it goes wrong.
Separate sections rather than one, because each of the three failures
opens on its own condition and a fold header carries ONE sentence: a
refusal, an undercount and a source that matches nothing are three
different things to do and must not share a header that names one of
them.
"""
out = []
section = fold.Section(
'multiplier', 'The control-number multiplier',
summary=_multiplier_summary(counts))
section.md('An assembly is a MARK and a mark is one drawing. The pieces '
'on the floor are control numbers. Every quantity on this '
'page is given BOTH ways: `per_mark` is one of each mark, '
'`per_job` is what the job needs.')
for line in _ticket_unit_lines(counts):
section.md(line)
problems = [p for p in [_ticket_unit_problem(counts)] if p]
section.problem = '; '.join(problems) or None
if not counts:
# No counts at all is still a state the provenance sections describe
# -- WHERE the run looked and what it could not read is knowable even
# when nothing came back -- so this falls through rather than returns.
section.problem = ('nothing sourced the multiplier &mdash; every '
'per-job figure is its per-mark figure')
section.md('> **No multiplier source was available. Every mark was '
'counted once. The whole per_job column is an '
'undercount.**')
return [section] + _provenance_sections(extras)
if counts.get('refusal'):
# A source nobody mapped is a REFUSAL, and the sentence that names
# the key to map is printed whole. Folding must never turn that into
# an appendix, so it is the section's problem and the section opens.
problems.insert(0, 'the model was not asked how many pieces each '
'mark is &mdash; every per-job figure is an '
'UNDERCOUNT')
section.problem = '; '.join(problems)
section.md('> **The multiplier could not be sourced.** '
+ counts['refusal'])
section.table(['marks', 'pieces on the floor', 'average multiplier'],
[[_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier'))]])
section.md('Where each mark\'s multiplier came from:')
by_basis = counts.get('by_basis') or {}
section.table(['basis', 'marks'],
[[k, _n(by_basis[k])] for k in sorted(by_basis)])
sources = counts.get('sources')
if sources:
# `counted as pieces` is not decoration. A withdrawn source can know
# hundreds of marks and contribute none of them, and a table that
# showed only what it knew would read as if it had.
withdrawn = set((counts.get('ticket_unit') or {}).get('withdrawn')
or ())
section.md('What each source could speak for, before the ranking '
'chose between them:')
section.table(['source', 'marks it knew', 'counted as pieces'],
[[k, _n(sources[k]),
'NO' if k in withdrawn else 'yes']
for k in sorted(sources)])
out.append(section)
join = counts.get('join') or {}
if join.get('unmatched_source'):
# The silent-and-total failure: a source that found plenty of marks,
# none of which the takeoff has heard of.
strays = fold.Section(
'multiplier-join', 'The multiplier source and this takeoff '
'disagree about what a mark is called',
summary='{0} of {1} source mark(s) matched nothing here'.format(
len(join['unmatched_source']), _n(join.get('source_marks'))),
problem='{0} mark(s) the source knows match nothing in this '
'takeoff &mdash; the per-job column may be wrong '
'everywhere'.format(len(join['unmatched_source'])))
strays.md('**{0} of the {1} marks the multiplier source knows do not '
'match any mark in this takeoff.** The takeoff keys on the '
'assembly name and the source keys on the piece mark; if '
'those have diverged, the per-job column is wrong '
'everywhere and not just where it says `assumed_one`. '
'First few: {2}.'
.format(len(join['unmatched_source']),
_n(join.get('source_marks')),
', '.join(join['unmatched_source'][:10])))
out.append(strays)
if counts.get('assumed_one'):
under = fold.Section(
'multiplier-assumed', 'Marks counted once because nothing '
'sourced them',
summary='{0} mark(s) assumed one'.format(counts['assumed_one']),
problem='{0} mark(s) have no sourced multiplier and were counted '
'once'.format(counts['assumed_one']))
under.md('> **{0} marks have no sourced multiplier and were counted '
'once.** Those lines are an UNDERCOUNT, not a quantity.'
.format(counts['assumed_one']))
marks = takeoff.get('undercounted') or []
if marks:
under.md('Named rather than tallied, because the fix is per '
'mark: {0}{1}'.format(
', '.join(marks[:40]),
' ...' if len(marks) > 40 else ''))
out.append(under)
out.extend(_provenance_sections(extras))
return out
def _multiplier_summary(counts):
if not counts:
return 'no source &mdash; every mark counted once'
bits = ['{0} mark(s), {1} piece(s), average {2}'.format(
_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier')))]
basis = _basis_summary(counts)
if basis:
bits.append(basis)
plan = counts.get('ticket_unit')
if plan:
# On the FOLDED header, because what one ticket is decides whether
# the exported tickets counted at all. A reader who never opens this
# section still learns that the run knew, or did not know.
bits.append('ticket unit: {0}'.format(
plan.get('unit') if plan.get('declared') else 'NOT DECLARED'))
return ' &mdash; '.join(bits)
def _ticket_unit_lines(counts):
"""What one exported ticket is, said out loud in every state.
`nomenclature.ticket_unit` is the declared fact that decides whether a
folder of exported tickets is a count of PIECES or a count of DRAWINGS.
Declared either way it is one routine sentence; undeclared it is a
refusal printed whole, with the key somebody has to set -- because the
run could not tell which kind of folder it was reading and must not be
read as having checked.
"""
plan = (counts or {}).get('ticket_unit')
if not plan:
return []
out = []
if plan.get('refusal'):
out.append('> **What one exported ticket covers is not declared.** '
+ plan['refusal'])
elif plan.get('reason'):
out.append('_{0}_'.format(plan['reason']))
if plan.get('withdrawn'):
out.append('Not counted as a piece source on this job: {0}. Marks '
'that no remaining source covers are counted once and '
'listed as an undercount, never sourced from a withdrawn '
'one.'.format(', '.join('`{0}`'.format(b)
for b in plan['withdrawn'])))
return out
def _ticket_unit_problem(counts):
"""A sentence when the undeclared ticket unit actually COST marks.
Undeclared is always stated -- in the section body and on the folded
header. It OPENS the section when the withdrawal left marks with no
source at all, because that is the point at which the missing
declaration stopped being a formality and started moving numbers. An
undeclared key on a run with no exported tickets to withdraw costs
nothing, and opening a section for it would spend the folding contract
on a page where nothing needs the reader.
"""
plan = (counts or {}).get('ticket_unit') or {}
if not plan.get('refusal') or not plan.get('cost'):
return None
return ('{0} mark(s) lost their only multiplier source because `{1}` is '
'not declared'.format(plan['cost'], plan.get('key',
'ticket_unit')))
def _provenance_sections(extras):
"""Where the multiplier looked, what it could not read, and where its two
sources disagree. The first is routine; the other two are gaps in the
number and open themselves."""
out = []
info = (extras or {}).get('multiplier') or {}
if info.get('categories') or info.get('elements') or info.get('notes'):
where = fold.Section(
'multiplier-source', 'Where the control numbers were read from',
summary=_source_summary(info))
if info.get('categories'):
where.md('Control numbers were read from: {0}. A piece modelled '
'in any other category was not seen by this run.'
.format(', '.join(info['categories'])))
if info.get('elements'):
where.md('{0} element(s) carried a readable control field.'
.format(info['elements']))
for note in (info.get('notes') or [])[:20]:
where.md('- {0}'.format(note))
out.append(where)
unreadable = info.get('unreadable') or []
if unreadable:
section = fold.Section(
'multiplier-unreadable', 'Control fields this run could not read',
summary='{0} element(s)'.format(len(unreadable)),
problem='{0} element(s) carry a control field the multiplier '
'could not place'.format(len(unreadable)))
section.md('**{0} element(s) carry a control field this run could '
'not turn into pieces.** They are real pieces that the '
'multiplier could not place, so they are named rather '
'than dropped:'.format(len(unreadable)))
section.table(['element', 'mark', 'field', 'why'],
[[_n(r.get('id')), _n(r.get('mark')), _n(r.get('token')),
_n(r.get('why'))] for r in unreadable[:30]])
out.append(section)
rows = (extras or {}).get('disagreements') or []
if rows:
section = fold.Section(
'multiplier-disagreements', 'Where the multiplier sources '
'disagree',
summary='{0} mark(s)'.format(len(rows)),
problem='{0} mark(s) are counted differently by two sources '
'&mdash; the higher-ranked one was used'.format(len(rows)))
section.md('**The multiplier sources disagree on {0} mark(s).** The '
'higher-ranked source was used and nothing was silently '
'reconciled:'.format(len(rows)))
section.table(['mark', 'count', 'source', 'count', 'source'],
[[r['mark'], _n(r['left']), r['left_basis'],
_n(r['right']), r['right_basis']] for r in rows[:40]])
out.append(section)
return out
def _source_summary(info):
bits = []
if info.get('categories'):
bits.append(', '.join(info['categories']))
if info.get('elements'):
bits.append('{0} element(s) read'.format(info['elements']))
return ' &mdash; '.join(bits) or 'what the multiplier swept'
# ------------------------------------------------------- scope and coverage
def _scope_sections(extras):
"""What was left alone on purpose. Folded: somebody declared it.
Never merged with coverage. Work outside the declared scope was LEFT
ALONE deliberately; work the index could not judge was attempted and
failed. Neither is a quantity of zero and they are not the same sentence.
"""
info = (extras or {}).get('scope') or {}
if not info.get('line'):
return []
section = fold.Section(
'scope', 'What this takeoff did not count',
summary=_scope_summary(info))
section.md('> ' + info['line'])
if info.get('assemblies_line'):
section.md('> ' + info['assemblies_line'])
for note in (info.get('declaration') or []):
section.md('_{0}_'.format(note))
return [section]
def _scope_summary(info):
if not info.get('declared'):
return 'no scope declared &mdash; everything indexed was counted'
return '{0} mark(s) counted, {1} left alone{2}'.format(
_n(info.get('counted_marks')), _n(info.get('left_alone_marks')),
' ({0})'.format(info['label']) if info.get('label') else '')
def _coverage_sections(takeoff):
"""What the index could not see. Folded when it saw everything.
A mark that is in the model and not in the index is a HOLE IN THE JOB --
those pieces are missing from the order -- so this opens itself when
there is one, even though the section is routine on a clean job.
"""
cov = takeoff.get('coverage') or {}
unjudged = takeoff.get('unjudged') or []
problems = []
if cov.get('marks_not_indexed'):
problems.append('{0} mark(s) are in the model and NOT in these '
'quantities'.format(cov['marks_not_indexed']))
if unjudged:
problems.append('{0} indexed mark(s) could not be judged'
.format(len(unjudged)))
section = fold.Section(
'coverage', 'What this takeoff could not see',
summary='{0} mark(s) indexed, {1} judged, {2} not indexed'.format(
_n(cov.get('indexed_marks')), _n(cov.get('judged_marks')),
_n(cov.get('marks_not_indexed'))),
problem='; '.join(problems) or None)
section.table(
['', 'count'],
[['marks indexed', _n(cov.get('indexed_marks'))],
['marks judged', _n(cov.get('judged_marks'))],
['marks the index could not judge', _n(cov.get('unjudged_marks'))],
['assemblies in the model', _n(cov.get('assemblies_in_model'))],
['marks in the model but NOT indexed',
_n(cov.get('marks_not_indexed'))],
['index built at', _n(cov.get('built_at'))]])
if cov.get('marks_not_indexed'):
section.md('> The index builds only pieces marked reinforced and not '
'on hold. The marks above are in the model and are NOT in '
'these quantities. This is a hole in the job, not a '
'rounding.')
if unjudged:
section.md('Could not judge -- these are not pieces with no '
'hardware:')
section.table(['piece', 'reason'],
[[r['piece'], r['reason']] for r in unjudged[:50]])
return [section]
# ------------------------------------------------------------- the quantities
def _quantity_sections(takeoff, order, ships, other):
"""Everything countable that is not the order itself.
The order is the headline. What follows is the rest of what was counted:
the classes nobody orders by part number, the bars that arrive welded on,
and the two lists that say what the count would not swallow.
"""
out = []
rest = [r for r in order
if r['class'] not in (core.HARDWARE, core.BAR)]
if rest:
shapes = [r for r in rest if r['class'] == core.BAR_SHAPE]
section = fold.Section(
'quantities-other', 'Other quantities &mdash; strand, mesh, cast '
'and unresolved bend shapes',
summary=_class_summary(rest),
problem=('{0} reinforcing line(s) resolved to a bend shape and '
'not to a bar mark &mdash; the quantity is real and the '
'SKU is unknown'.format(len(shapes)) if shapes else None))
for klass in (core.BAR_SHAPE, core.STRAND, core.MESH, core.CAST):
rows = [r for r in rest if r['class'] == klass]
if not rows:
continue
section.md('### ' + CLASS_TITLE.get(klass, klass))
if klass == core.BAR_SHAPE:
section.md('These are reinforcing instances the index '
'counted but could not name. The family is a bend '
'shape, not a bar mark, so the quantity is real '
'and the SKU is unknown. See `docs/takeoff.md` '
'for the one index field that resolves them.')
section.table(*_totals_table(rows))
out.append(section)
attached = fold.Section(
'attached', 'Bars that arrive attached to a part',
summary=('{0} bar mark(s) counted with a parent'.format(len(ships))
if ships else 'none declared attached'))
if ships:
attached.md('Counted, and NOT on the order above: '
'`takeoff.order_attached_bars_separately` is false, so '
'these are taken to arrive welded to the parent named. '
'Flip that key if this plant buys them loose.')
attached.table(*_totals_table(ships, parent=True))
elif takeoff.get('order_attached_separately'):
attached.md('`takeoff.order_attached_bars_separately` is TRUE, so '
'attached bars are on the order above and are not '
'repeated here.')
else:
attached.md('None of the counted bar marks is declared attached to a '
'parent.')
out.append(attached)
unclassified = takeoff.get('unclassified') or []
refuses = fold.Section(
'unclassified', 'What this takeoff refuses to count',
summary=('{0} family(ies) counted but not classified'
.format(len(unclassified)) if unclassified
else 'nothing &mdash; every family matched a rule'),
problem=('{0} family(ies) were counted and none of them was '
'classified'.format(len(unclassified))
if unclassified else None))
if unclassified:
refuses.md('Every one of these was counted and none was classified. '
'They are listed by family name rather than bucketed into '
'"other", because a tidy under-count is worse than a '
'messy honest one. Classify a family by naming it in '
'`takeoff.family_class`, or exclude it in '
'`takeoff.not_ordered`.')
refuses.table(
['family', 'per_mark', 'per_job', 'marks', 'example pieces'],
[[r['family'], _n(r['per_mark_total']), _n(r['per_job_total']),
_n(r['pieces']), ', '.join(r['examples'])]
for r in unclassified])
else:
refuses.md('Nothing. Every family in the index matched a rule.')
out.append(refuses)
declared_out = [r for r in other if r['class'] != core.UNCLASSIFIED]
if declared_out:
section = fold.Section(
'declared-out', 'Declared out of the order by knowledge',
summary=_class_summary(declared_out))
section.md('Declared out of the order by knowledge, and shown so '
'that the declaration is visible rather than silent. '
'These are NOT the unclassified list above -- somebody '
'decided each of these:')
section.table(*_totals_table(declared_out))
out.append(section)
suspects = takeoff.get('nesting_suspects') or []
nesting = fold.Section(
'nesting', 'Suspected nesting &mdash; possible double counts',
summary=('{0} family pair(s) move in lockstep'.format(len(suspects))
if suspects else 'no pair shows the equal-count signature'),
problem=('{0} family pair(s) move in lockstep &mdash; one of each '
'pair is probably counted twice'.format(len(suspects))
if suspects else None))
if suspects:
nesting.md('A nested family still appears in `GetMemberIds()`, so it '
'is counted once on its own and once inside its parent. '
'The pairs below move in lockstep -- the same counts on '
'the same marks, and the count VARIES from mark to mark, '
'which is what separates a nested family from two '
'unrelated parts that happen to be one each. **Nothing '
'has been subtracted.** Confirm one and declare it in '
'`takeoff.nested_in`.')
nesting.table(
['family A', 'family B', 'direction', 'agreement', 'marks',
'instances'],
[[r['child'], r['parent'], r.get('direction', ''),
_n(r.get('agreement')), _n(r['pieces']), _n(r['instances'])]
for r in suspects[:40]])
nesting.md('`undecided` means A and B occur on the same marks in the '
'same numbers, so the arithmetic cannot say which sits '
'inside which -- only that one of the two is very '
'probably counted twice. `agreement` is the fraction of '
'marks on which the two counts match exactly: 1.0 is a '
'lockstep pair, anything less is a pair that mostly '
'agrees and disagrees somewhere worth asking about.')
else:
nesting.md('No family pair shows the equal-count signature.')
out.append(nesting)
return out
def _class_summary(rows):
"""`strand 2 line(s), welded wire fabric 1` -- what a folded header holds."""
tally = {}
for row in rows:
tally[row['class']] = tally.get(row['class'], 0) + 1
return ', '.join('{0} {1} line(s)'.format(k, tally[k])
for k in sorted(tally))
# ----------------------------------------------------------- the long tables
def _detail_sections(takeoff):
"""The two longest things on the page and the least often read: the piece
by piece breakdown and the tally of what decided each line."""
out = []
by_piece = takeoff.get('by_piece') or {}
if by_piece:
names = sorted(by_piece)
lines = sum(len(by_piece[n].get('lines') or []) for n in names)
section = fold.Section(
'by-piece', 'The takeoff piece by piece',
summary='{0} piece(s), {1} counted line(s)'.format(len(names),
lines))
section.md('One row per piece. The full per-line version -- every '
'part number on every piece -- is the `-by-piece.csv` '
'this run writes; it is a spreadsheet, not a page.')
section.table(
['piece', 'design', 'pieces', 'multiplier basis', 'lines'],
[[n, _n(by_piece[n].get('design_number')),
_n(by_piece[n].get('pieces')),
_n(by_piece[n].get('multiplier_basis')),
_n(len(by_piece[n].get('lines') or []))]
for n in names[:100]])
if len(names) > 100:
section.md('_First 100 of {0}. The rest are in the CSV; nothing '
'is dropped from the totals._'.format(len(names)))
out.append(section)
basis = takeoff.get('bases') or {}
judged = fold.Section(
'judgements', 'Every judgement this run made',
summary='{0} piece line(s) over {1} basis kind(s)'.format(
sum(basis.values()), len(basis)))
judged.md('`declared` means a knowledge key named the family outright. '
'`rule:*` means a pattern decided it. `none` means nothing did.')
judged.table(['basis', 'piece lines decided'],
[[k, _n(basis[k])] for k in sorted(basis)])
out.append(judged)
return out
# ----------------------------------------------------------- as one document
def markdown(takeoff, counts=None, title='Takeoff', extras=None):
"""The whole takeoff as one markdown document, NOTHING folded.
The same sections the button folds, flattened. This is what goes in the
`.md` beside the model and what a reader opens when a fold will not open,
so it may never say less than the page: `show_all=True` is the point of
it.
"""
out = []
for block in fold.blocks(
sections(takeoff, counts, title=title, extras=extras),
show_all=True):
if block[0] == fold.TABLE:
out.append(_table(block[1], block[2]))
else:
out.append(block[1])
out.append('')
return '\n'.join(out)
def _totals_table(rows, parent=False):
"""(columns, rows) for a quantities table. Both figures, always, and the
ratio between them so the reader can see which multiplied and which did
not."""
head = ['key', 'per_mark', 'per_job', 'x', 'marks', 'families']
if parent:
head.append('arrives with')
body = []
for row in rows:
line = [row['key'], _n(row['per_mark_total']),
_n(row['per_job_total']), _n(row.get('multiplier')),
_n(row['marks']), ', '.join(row['families'])]
if parent:
line.append(', '.join(row.get('attached_to') or []))
body.append(line)
return head, body
def _table(head, body):
out = ['| ' + ' | '.join(head) + ' |',
'| ' + ' | '.join(['---'] * len(head)) + ' |']
for row in body:
out.append('| ' + ' | '.join(
(r if isinstance(r, compat.string_types) else _n(r)).replace('|', '\\|')
for r in row) + ' |')
return '\n'.join(out)
# --------------------------------------------------------------------- csv
CSV_HEAD = ('class', 'key', 'per_mark_total', 'per_job_total', 'marks',
'multiplier', 'on_order', 'arrives_with', 'families', 'bases')
def csv_totals(takeoff):
"""One row per part number or bar mark. Both figures on every row."""
order, ships, other = core.order_rows(
takeoff.get('totals') or [],
takeoff.get('order_attached_separately'))
on_order = set(id(r) for r in order)
rows = [','.join(CSV_HEAD)]
for row in (takeoff.get('totals') or []):
rows.append(','.join([
_csv(row['class']), _csv(row['key']),
_csv(row['per_mark_total']), _csv(row['per_job_total']),
_csv(row['marks']), _csv(row.get('multiplier')),
'yes' if id(row) in on_order else 'no',
_csv('; '.join(row.get('attached_to') or [])),
_csv('; '.join(row.get('families') or [])),
_csv('; '.join(row.get('bases') or [])),
]))
return '\n'.join(rows)
PIECE_CSV_HEAD = ('piece', 'design_number', 'pieces', 'multiplier_basis',
'class', 'key', 'family', 'per_mark', 'per_job', 'basis',
'attached_to')
def csv_by_piece(takeoff):
"""One row per piece per line. The version somebody checks a piece against."""
rows = [','.join(PIECE_CSV_HEAD)]
by_piece = takeoff.get('by_piece') or {}
for name in sorted(by_piece):
entry = by_piece[name]
if not entry.get('lines'):
rows.append(','.join([
_csv(name), _csv(entry.get('design_number')),
_csv(entry.get('pieces')), _csv(entry.get('multiplier_basis')),
'COULD_NOT_JUDGE', '', '', '', '', '', '']))
continue
for line in entry['lines']:
rows.append(','.join([
_csv(name), _csv(entry.get('design_number')),
_csv(entry.get('pieces')), _csv(entry.get('multiplier_basis')),
_csv(line['class']), _csv(line['key']), _csv(line['family']),
_csv(line['per_mark']), _csv(line['per_job']),
_csv(line['basis']),
_csv('; '.join(line.get('attached_to') or [])),
]))
return '\n'.join(rows)
def _csv(value):
text = '' if value is None else str(value)
if ',' in text or '"' in text or '\n' in text:
return '"' + text.replace('"', '""') + '"'
return text
File diff suppressed because it is too large Load Diff
+861
View File
@@ -0,0 +1,861 @@
# -*- coding: utf-8 -*-
"""Compare two issues of the same shop ticket and say what changed.
WHY THIS MODULE EXISTS
A revised ticket that does not say what moved forces the plant to diff it by
eye, and they will not. Everything else in this repo answers "is this piece
right?"; this answers "what changed since the last issue?", which is the
question a consultancy is actually exposed to.
WHAT "CHANGED" MEANS HERE, AND WHAT IT DELIBERATELY DOES NOT
A pixel diff is useless -- every ticket is replotted, so every pixel moves. A
line diff is nearly as bad: it reports the plot timestamp on all twelve
tickets and buries the one anchor that moved. So the comparison is per REGION,
each with its own notion of sameness:
title block field by field, by printed label
bill of matl row by row, keyed on the part mark
handling dimension by dimension within DUNNAGE / PICKING / ERECTION
dimensions chain by chain, in printed order, with the chain total
notes paragraph by paragraph
callouts as a multiset of strings, located but not positioned
THE FAILURE MODE THIS IS BUILT AGAINST
False positives kill this class of tool. A diff that reports forty spurious
changes per ticket will not be used, and then the one real change goes unread.
Three specific defences:
* dimensions compare by VALUE, not by string -- the same length prints as
1' - 0" on the bed plan and 1'-0" in the handling diagrams, and a string
compare would fire on every ticket in the set;
* position-only movement is NOT a change -- a dimension whose text is
identical but whose witness line shifted two points is a replot, not a
revision;
* anything a hazard scan touched is reported as UNCERTAIN, never as a
change and never dropped. "Could not judge" is not "judged and found
nothing".
Severity is about who needs to act, not about how big the number is:
MATERIAL changes what gets cast or embedded -- geometry, quantities,
embed marks, handling stations
ADVISORY changes what the sheet says without changing the piece -- notes,
callout wording
HOUSEKEEP issue stamps, plot times, sheet-status blocks
UNCERTAIN a difference the extraction cannot stand behind
Pure Python, no pymupdf and no Revit, so it is testable on plain CPython.
"""
import difflib
import re
MATERIAL = 'MATERIAL'
ADVISORY = 'ADVISORY'
HOUSEKEEP = 'HOUSEKEEP'
UNCERTAIN = 'UNCERTAIN'
_ORDER = {MATERIAL: 0, UNCERTAIN: 1, ADVISORY: 2, HOUSEKEEP: 3}
def finding(kind, severity, where, what, old=None, new=None, note=None,
loc_old=None, loc_new=None):
"""One reported difference.
`loc_old` / `loc_new` are {'page','x','y'} on each issue when the finding
has a place on the sheet. They are what lets the runner render the two
crops a reviewer confirms the change on -- the text layer is not the
sheet, and every number this tool prints should be checkable by eye
without hunting for it.
"""
return {'kind': kind, 'severity': severity, 'where': where, 'what': what,
'old': old, 'new': new, 'note': note,
'loc_old': loc_old, 'loc_new': loc_new}
def _loc(s):
if not s:
return None
return {'page': s['page'], 'x': s['x'], 'y': s['y']}
# --------------------------------------------------------------------------
# pairing files across two issues
# --------------------------------------------------------------------------
_MARK_RE = re.compile(r'(?:^|[_\-\s])(WP\d+[A-Za-z]?)(?:[_\-\s]|\.|$)', re.I)
_CTRL_RE = re.compile(r'^(\d{4,6})[-_](\d{4,6})[_\-]')
def parse_name(filename):
"""Pull job / control / mark out of a ticket filename.
The two issues of this set are named differently -- the old by mark
(WP199.pdf), the new by job and control number (25881-20132_WP199.pdf).
That is itself a reportable fact: a set whose file naming changed between
issues cannot be paired by filename alone, and the pairing that IS possible
is one-to-many, because this plant issues one ticket per control number
and three identical WP215 panels means three files against one old file.
"""
base = filename
if base.lower().endswith('.pdf'):
base = base[:-4]
base = base.strip()
out = {'file': filename, 'job': None, 'control': None, 'mark': None,
'revision': None}
m = _CTRL_RE.match(base)
if m:
out['job'], out['control'] = m.group(1), m.group(2)
m = _MARK_RE.search(base)
if m:
out['mark'] = m.group(1).upper()
m = re.search(r'[_\-](R\d+)$', base, re.I)
if m:
out['revision'] = m.group(1).upper()
return out
def pair_sets(old_files, new_files):
"""Match old-issue files to new-issue files.
Returns {'pairs': [(old, [new, ...])], 'old_only': [...],
'new_only': [...], 'naming': {...}}.
Pairing is on the MARK, because it is the only identifier both namings
carry. Everything that pairing cannot do is returned rather than hidden:
a mark present in one issue only is an added or withdrawn ticket, which is
a bigger deal than any dimension inside one.
"""
o = [parse_name(f) for f in old_files]
n = [parse_name(f) for f in new_files]
old_by_mark = {}
for r in o:
old_by_mark.setdefault(r['mark'], []).append(r)
new_by_mark = {}
for r in n:
new_by_mark.setdefault(r['mark'], []).append(r)
pairs = []
for mark in sorted(k for k in old_by_mark if k in new_by_mark):
for orec in old_by_mark[mark]:
pairs.append((orec, sorted(new_by_mark[mark],
key=lambda r: (r['control'] or '', r['file']))))
naming = {
'old_has_control': sum(1 for r in o if r['control']),
'new_has_control': sum(1 for r in n if r['control']),
'old_count': len(o), 'new_count': len(n),
'changed': (bool([r for r in o if r['control']]) !=
bool([r for r in n if r['control']])),
'fan_out': {m: len(new_by_mark[m]) for m in sorted(new_by_mark)
if m in old_by_mark and len(new_by_mark[m]) > 1},
}
return {
'pairs': pairs,
'old_only': sorted(r['file'] for r in o if r['mark'] not in new_by_mark),
'new_only': sorted(r['file'] for r in n if r['mark'] not in old_by_mark),
'naming': naming,
}
# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
# region comparisons
# --------------------------------------------------------------------------
# Fields that only ever record when the sheet was plotted or issued. Their
# changing is expected on every reissue and says nothing about the piece.
_HOUSEKEEPING_FIELDS = ('DRAWN BY:', 'ENG BY:')
# Fields whose value changing means the piece or its paperwork identity
# changed. SEQUENCE #, PROJECT NAME and the like are identity, not geometry,
# but a changed one still needs a human.
_MATERIAL_FIELDS = (
'LENGTH:', 'WIDTH:', 'THICKNESS:', 'WEIGHT (lbs.):', 'QUANITY:',
'QUANTITY:', "CONCRETE (f'c):", "CONCRETE (f'ci):", 'FACE MIX #',
'BASE MIX#', 'FACE MIX (C.Y.)', 'BASE MIX (C.Y.)', 'PRODUCT CODE:',
"ADD'L PRODUCTION:", "ADD'L FINISH:",
)
def compare_title_block(a, b):
out = []
keys = sorted(set(a.get('title_block', {})) | set(b.get('title_block', {})))
for k in keys:
fa = a.get('title_block', {}).get(k)
fb = b.get('title_block', {}).get(k)
va = fa['value'] if fa else None
vb = fb['value'] if fb else None
if va == vb:
continue
if fa and not fa['ok'] or fb and not fb['ok']:
out.append(finding(
'TITLE', UNCERTAIN, 'title block, %s' % k.rstrip(':'),
'the value does not look like a %s, so this pair is not '
'compared' % k.rstrip(':').lower(), va, vb,
'extraction unreliable for this cell'))
continue
sev = HOUSEKEEP if k in _HOUSEKEEPING_FIELDS else (
MATERIAL if k in _MATERIAL_FIELDS else ADVISORY)
out.append(finding('TITLE', sev, 'title block, %s' % k.rstrip(':'),
'title-block value changed', va, vb))
return out
def _bom_is_tainted(doc):
"""True when a text-layer hazard lands inside this ticket's BOM table.
Measured on a real reissue in this job (a ticket and its R1): the earlier
file draws the strand row's mark and description ON TOP OF the bar row
above it, so get_text returns SR5602's mark for the strand row's quantity.
Diffed against a clean R1 that produced two phantom "item added / item
removed" pairs and a quantity going from 376 to 4.
Every one of those numbers looked completely plausible. So when the
hazard scan finds overlaid text inside the table, the table's findings are
reported as UNCERTAIN rather than as changes -- and they are still
reported. "Could not judge" is not "judged and found nothing".
"""
box = doc.get('bom_bbox')
if not box:
return None
for h in doc.get('hazards', []):
if h['mode'] not in ('overlaid-text', 'thin-text-layer'):
continue
if h['page'] != box['page']:
continue
if h.get('x') is None:
return h['mode']
if (box['x0'] <= h['x'] <= box['x1']
and box['y0'] <= h['y'] <= box['y1']):
return h['mode']
return None
def compare_bom(a, b):
"""Row-by-row on the part mark. A quantity change is a material change."""
out = []
taint = _bom_is_tainted(a) or _bom_is_tainted(b)
ra = {}
for r in a.get('bom', []):
ra.setdefault(r['mark'], []).append(r)
rb = {}
for r in b.get('bom', []):
rb.setdefault(r['mark'], []).append(r)
for mark in sorted(set(ra) | set(rb)):
la, lb = ra.get(mark, []), rb.get(mark, [])
if not la:
for r in lb:
out.append(finding('BOM', MATERIAL, 'bill of material, %s' % mark,
'item added to the bill of material',
None, '%s x %s -- %s' % (r['qty'], mark, r['desc'])))
continue
if not lb:
for r in la:
out.append(finding('BOM', MATERIAL, 'bill of material, %s' % mark,
'item removed from the bill of material',
'%s x %s -- %s' % (r['qty'], mark, r['desc']), None))
continue
for i in range(max(len(la), len(lb))):
x = la[i] if i < len(la) else None
y = lb[i] if i < len(lb) else None
if x is None or y is None:
out.append(finding('BOM', MATERIAL, 'bill of material, %s' % mark,
'the number of rows for this mark changed',
'%d row(s)' % len(la), '%d row(s)' % len(lb)))
break
if x['qty'] != y['qty']:
out.append(finding('BOM', MATERIAL, 'bill of material, %s' % mark,
'quantity changed', x['qty'], y['qty'],
x['desc'] or None,
loc_old=_loc(x), loc_new=_loc(y)))
if _norm(x['desc']) != _norm(y['desc']):
out.append(finding('BOM', ADVISORY, 'bill of material, %s' % mark,
'description changed', x['desc'], y['desc'],
loc_old=_loc(x), loc_new=_loc(y)))
return _downgrade(out, taint, 'bill of material')
def _downgrade(findings, taint, region):
if not taint:
return findings
for f in findings:
f['severity'] = UNCERTAIN
f['note'] = ('a text-layer hazard (%s) lands inside the %s, so this '
'difference may be an artefact of reading the PDF rather '
'than a change to the ticket -- check it against the '
'sheet before acting on it' % (taint, region))
return findings
def _norm(s):
return ' '.join((s or '').split())
def compare_handling(a, b, parse_dim, format_inches):
"""The three little diagrams, dimension by dimension.
Station order is printed order, left to right. It is not mirrored and not
sorted by value: reading the two insets the other way round was measured to
turn one engineer's CORRECTED dunnage ticket back into a FAIL, which is the
precise way a diff tool loses its reader.
"""
out = []
ha, hb = a.get('handling', {}), b.get('handling', {})
for name in sorted(set(ha) | set(hb)):
da = ha.get(name, [])
db = hb.get(name, [])
if name not in ha or name not in hb:
out.append(finding('HANDLING', MATERIAL, '%s diagram' % name.lower(),
'the diagram itself is present on one issue only',
'present' if name in ha else 'absent',
'present' if name in hb else 'absent'))
continue
if len(da) != len(db):
out.append(finding(
'HANDLING', MATERIAL, '%s diagram' % name.lower(),
'the number of printed dimensions changed',
' / '.join(s['text'] for s in da) or '(none)',
' / '.join(s['text'] for s in db) or '(none)',
loc_old=_loc(da[0] if da else None),
loc_new=_loc(db[0] if db else None)))
continue
for i, (x, y) in enumerate(zip(da, db)):
vx, vy = parse_dim(x['text']), parse_dim(y['text'])
if vx is None or vy is None or abs(vx - vy) > 1e-6:
out.append(finding(
'HANDLING', MATERIAL,
'%s diagram, dimension %d of %d (left to right)'
% (name.lower(), i + 1, len(da)),
'handling station moved', x['text'], y['text'],
loc_old=_loc(x), loc_new=_loc(y)))
return out
def _chain_values(chain, parse_dim):
return [parse_dim(s['text']) for s in chain['items']]
def _chain_key(chain, parse_dim):
return tuple(round(v, 4) if v is not None else None
for v in _chain_values(chain, parse_dim))
def _similarity(ka, kb):
"""How much two chains share, as a ratio."""
return difflib.SequenceMatcher(a=ka, b=kb).ratio()
def match_chains(ca, cb, parse_dim):
"""Pair old chains to new chains across a REPLOT, not just a revision.
The hard fact this is built around, measured on WP194: adding one note to
the sheet pushed the whole bed plan 89 points up the page, so every
dimension chain on it changed absolute position while none of them changed
value. An absolute-position matcher reported 40 material changes on a
ticket whose only real change was that note. That is exactly the output
that gets a diff tool switched off, and then the one real change on some
other ticket goes unread.
So matching is content-first:
1. chains whose value sequences are IDENTICAL are paired, nearest first;
2. the median position offset of those pairs is the sheet's replot
shift -- measured on this pair of files, not assumed;
3. remaining chains are paired on content similarity, with the measured
shift applied to the position tolerance.
Returns (pairs, unmatched_old_indices, unmatched_new_indices).
"""
keys_a = [_chain_key(g, parse_dim) for g in ca]
keys_b = [_chain_key(h, parse_dim) for h in cb]
used_a, used_b = set(), set()
pairs = []
def bucket(g):
return (g['page'], g['vertical'])
cands = []
for i, g in enumerate(ca):
for j, h in enumerate(cb):
if bucket(g) != bucket(h) or keys_a[i] != keys_b[j]:
continue
cands.append((abs(g['pos'] - h['pos']), i, j))
for _, i, j in sorted(cands):
if i in used_a or j in used_b:
continue
used_a.add(i)
used_b.add(j)
pairs.append((i, j))
shifts, alongs = {}, {}
for i, j in pairs:
shifts.setdefault(bucket(ca[i]), []).append(cb[j]['pos'] - ca[i]['pos'])
alongs.setdefault(bucket(ca[i]), []).append(
cb[j].get('along', 0.0) - ca[i].get('along', 0.0))
shift, along_shift = {}, {}
for k, v in shifts.items():
v.sort()
shift[k] = v[len(v) // 2]
for k, v in alongs.items():
v.sort()
along_shift[k] = v[len(v) // 2]
cands = []
for i, g in enumerate(ca):
if i in used_a:
continue
for j, h in enumerate(cb):
if j in used_b or bucket(g) != bucket(h):
continue
if 'along' not in g or 'along' not in h:
g.setdefault('along', 0.0)
h.setdefault('along', 0.0)
sim = _similarity(keys_a[i], keys_b[j])
if sim < 0.34:
continue
off = abs((h['pos'] - g['pos']) - shift.get(bucket(g), 0.0))
if off > 25.0:
continue
off2 = abs((h['along'] - g['along'])
- along_shift.get(bucket(g), 0.0))
if off2 > 40.0:
continue
cands.append((-sim, off, i, j))
for _, _, i, j in sorted(cands):
if i in used_a or j in used_b:
continue
used_a.add(i)
used_b.add(j)
pairs.append((i, j))
# 4 -- a chain where MOST of the segments changed shares too little
# content to pass step 3, and a short one can share none at all: a
# three-segment run with two segments edited scores 0.33. So a last pass
# pairs what is left on position alone, but only at the measured replot
# shift, within a few points, and with the same number of segments. That
# is a much stronger claim than similarity -- it says the two chains are
# drawn in the same place on the same run -- and the page-level value gate
# downstream still has to agree before anything is reported.
cands = []
for i, g in enumerate(ca):
if i in used_a:
continue
for j, h in enumerate(cb):
if j in used_b or bucket(g) != bucket(h):
continue
if len(g['items']) != len(h['items']):
continue
off = abs((h['pos'] - g['pos']) - shift.get(bucket(g), 0.0))
off2 = abs((h.get('along', 0.0) - g.get('along', 0.0))
- along_shift.get(bucket(g), 0.0))
if off > 6.0 or off2 > 10.0:
continue
cands.append((off + off2, i, j))
for _, i, j in sorted(cands):
if i in used_a or j in used_b:
continue
used_a.add(i)
used_b.add(j)
pairs.append((i, j))
return (pairs,
[i for i in range(len(ca)) if i not in used_a],
[j for j in range(len(cb)) if j not in used_b])
def _page_net(ca, cb, parse_dim):
"""Per page, the dimension values one issue prints and the other does not.
Returns (net_old, net_new) as {page: {value: surplus count}}. A value
printed the same number of times on both sides appears in neither, which
is exactly the point: it did not change.
"""
def count(chains):
d = {}
for g in chains:
for sp in g['items']:
v = parse_dim(sp['text'])
v = round(v, 4) if v is not None else None
d.setdefault(g['page'], {})
d[g['page']][v] = d[g['page']].get(v, 0) + 1
return d
da, db = count(ca), count(cb)
net_a, net_b = {}, {}
for page in set(da) | set(db):
ra, rb = da.get(page, {}), db.get(page, {})
net_a[page] = {}
net_b[page] = {}
for v in set(ra) | set(rb):
diff = ra.get(v, 0) - rb.get(v, 0)
if diff > 0:
net_a[page][v] = diff
elif diff < 0:
net_b[page][v] = -diff
return net_a, net_b
def compare_chains(a, b, parse_dim, format_inches, locate):
"""Dimension chains on the drawing views.
A dimension chain is what a detailer actually edits: a run of segments
along one line adding up to an overall. Comparing the run IN ORDER, and
reporting the chain total alongside, is the difference between "two numbers
changed" and "the anchors were redistributed, the panel did not get
longer" -- the second is what a precast engineer would say, and it is what
tells him whether he has to look further.
Two tiers, and the second exists purely to keep false positives down:
tier 1 matched chains, compared segment by segment with difflib, so a
chain that gained or lost a segment still reports the segments
that actually moved rather than "the chain changed";
tier 2 chains that could not be matched at all are NOT reported as
chains. Their dimensions are pooled per page and compared as a
multiset of VALUES. Measured reason: the same run of dimensions
is sometimes grouped as one chain on one issue and two on the
other, purely because the text sat a point further apart. That
is a grouping artefact of this reader, not a revision, and
reporting it as a revision would be the tool lying about its own
limits.
"""
out = []
ca = a.get('chains', [])
cb = b.get('chains', [])
titles_a = a.get('view_titles', [])
titles_b = b.get('view_titles', [])
pairs, only_a, only_b = match_chains(ca, cb, parse_dim)
leftover_a, leftover_b = [], []
# THE GATE. Before any chain is compared, count every dimension VALUE
# printed on each page of each issue and take the net difference.
#
# If a page prints the same multiset of dimension values on both issues,
# then no dimension on that page changed value -- whatever happened to the
# chains is a rearrangement, and this reader's chain grouping is not stable
# enough across a replot to be trusted to describe it. Measured: WP196 and
# WP412 carry byte-identical dimension text on page 2, and the section
# details were nonetheless re-laid-out far enough that chain matching
# paired a dimension in SECTION A against one in SECTION B. Without this
# gate that was five confident, wrong, material findings on two tickets
# that had not changed at all.
#
# So nothing is reported unless the page's set of printed values actually
# moved. The cost is stated rather than hidden: a dimension that swaps
# places with another dimension of the SAME value is invisible here.
net_a, net_b = _page_net(ca, cb, parse_dim)
for i, j in sorted(pairs):
g, h = ca[i], cb[j]
ta = [s['text'] for s in g['items']]
tb_ = [s['text'] for s in h['items']]
va = _chain_key(g, parse_dim)
vb = _chain_key(h, parse_dim)
if va == vb:
continue
where = locate(titles_b, h['page'], h['items'][0]['x'], h['items'][0]['y'])
sum_a = sum(v for v in va if v is not None)
sum_b = sum(v for v in vb if v is not None)
if len(va) == len(vb) and abs(sum_a - sum_b) < 1e-6:
note = ('chain total unchanged at %s -- a redistribution along the '
'same run, not a size change' % format_inches(sum_a))
else:
note = ('chain total %s -> %s'
% (format_inches(sum_a), format_inches(sum_b)))
sm = difflib.SequenceMatcher(a=va, b=vb)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == 'equal':
continue
if tag == 'replace' and (i2 - i1) == (j2 - j1):
for k in range(i2 - i1):
pg = g['items'][i1 + k]['page']
ov, nv = va[i1 + k], vb[j1 + k]
if (net_a.get(pg, {}).get(ov, 0) <= 0
and net_b.get(pg, {}).get(nv, 0) <= 0):
# Both values are still printed elsewhere on the page
# in the same numbers: the chain grouping moved, the
# dimension did not.
leftover_a.append(g['items'][i1 + k])
leftover_b.append(h['items'][j1 + k])
continue
if net_a.get(pg, {}).get(ov, 0) > 0:
net_a[pg][ov] -= 1
if net_b.get(pg, {}).get(nv, 0) > 0:
net_b[pg][nv] -= 1
out.append(finding(
'DIM', MATERIAL,
'%s, segment %d of %d (%s chain)'
% (where, i1 + k + 1, len(ta),
'vertical' if g['vertical'] else 'horizontal'),
'dimension changed', ta[i1 + k], tb_[j1 + k], note,
loc_old=_loc(g['items'][i1 + k]),
loc_new=_loc(h['items'][j1 + k])))
else:
# A chain that gained or lost segments is NOT reported as a
# chain. Its odd segments go into the leftover pool below and
# are only reported if they do not cancel against the other
# issue. See the pool's comment for the measurement.
leftover_a.extend(g['items'][i1:i2])
leftover_b.extend(h['items'][j1:j2])
for i in only_a:
leftover_a.extend(ca[i]['items'])
for j in only_b:
leftover_b.extend(cb[j]['items'])
# THE POOL, and why it is the whole defence against grouping noise.
#
# Chains are grouped by the printed line the dimensions sit on. That
# grouping is not stable across a replot: on WP196 and WP412 the section
# details on page 2 carry byte-identical dimension text on both issues,
# and the reader still split them into different chains because one string
# sat a point further from its neighbour. Reported as chains, that was 11
# material findings across two tickets that had not changed at all.
#
# So every dimension a chain comparison could not account for is pooled by
# page and compared as a MULTISET OF VALUES. Regrouping cancels exactly.
# A dimension that genuinely appears or disappears does not, and is still
# reported -- without chain context, which is the honest price.
def pool(items):
d = {}
for sp in items:
v = parse_dim(sp['text'])
d.setdefault((sp['page'], round(v, 4) if v is not None else None),
[]).append(sp)
return d
# Whatever the chain comparison could not account for is reported against
# what is LEFT of the page's net difference, so a regrouping cancels
# exactly and a genuine appearance or disappearance still surfaces --
# without chain context, which is the honest price of not having been able
# to match its chain.
pa, pb = pool(leftover_a), pool(leftover_b)
for k in sorted(set(pa) | set(pb), key=lambda k: (k[0], k[1] or 0)):
page, val = k
tail = ('this reader could not match the dimension chain it belongs to '
'across the two issues, so it is reported on its own, without '
'its neighbours')
n_new = min(len(pb.get(k, [])), net_b.get(page, {}).get(val, 0))
n_old = min(len(pa.get(k, [])), net_a.get(page, {}).get(val, 0))
if n_new > 0:
sp = pb[k][0]
net_b[page][val] -= n_new
out.append(finding(
'DIM', MATERIAL,
locate(titles_b, sp['page'], sp['x'], sp['y']),
'a dimension appears in the new issue that is not in the old'
+ ('' if n_new == 1 else ' (%d of them)' % n_new),
None, sp['text'], tail, loc_new=_loc(sp)))
if n_old > 0:
sp = pa[k][0]
net_a[page][val] -= n_old
out.append(finding(
'DIM', MATERIAL,
locate(titles_a, sp['page'], sp['x'], sp['y']),
'a dimension in the old issue is not in the new one'
+ ('' if n_old == 1 else ' (%d of them)' % n_old),
sp['text'], None, tail, loc_old=_loc(sp)))
return out
def compare_notes(a, b, locate):
"""Paragraph-level. A note is added, removed, or reworded -- never 'line 3'."""
out = []
na = {_norm(n['text']): n for n in a.get('notes', [])}
nb = {_norm(n['text']): n for n in b.get('notes', [])}
titles_b = b.get('view_titles', [])
titles_a = a.get('view_titles', [])
only_a = [k for k in na if k not in nb]
only_b = [k for k in nb if k not in na]
# A reworded note is a removal and an addition that share a heading; pair
# them so the reader sees one finding with two versions, not two findings.
paired = []
for ka in list(only_a):
head_a = ka.split(':')[0]
for kb in list(only_b):
if kb.split(':')[0] == head_a and _similar(ka, kb):
paired.append((ka, kb))
only_a.remove(ka)
only_b.remove(kb)
break
for ka, kb in paired:
n = nb[kb]
out.append(finding('NOTE', ADVISORY,
locate(titles_b, n['page'], n['x'], n['y']),
'note reworded', na[ka]['text'], nb[kb]['text'],
loc_old=_loc(na[ka]), loc_new=_loc(n)))
for k in sorted(only_a):
n = na[k]
out.append(finding('NOTE', ADVISORY,
locate(titles_a, n['page'], n['x'], n['y']),
'note removed', n['text'], None, loc_old=_loc(n)))
for k in sorted(only_b):
n = nb[k]
out.append(finding('NOTE', ADVISORY,
locate(titles_b, n['page'], n['x'], n['y']),
'note added', None, n['text'], loc_new=_loc(n)))
return out
def _similar(x, y):
xs, ys = set(x.upper().split()), set(y.upper().split())
if not xs or not ys:
return False
return len(xs & ys) / float(len(xs | ys)) >= 0.4
_TS_RE = re.compile(r'^\d{1,2}/\d{1,2}/\d{2,4}(\s+\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?)?$',
re.I)
# Plot stamps and page numbers change on every reissue and mean nothing about
# the piece. They are still reported -- under --all -- because "the sheet was
# replotted and nothing else changed" is a useful thing for a reviewer to be
# able to confirm.
_FURNITURE_RE = re.compile(r'^PAGE\s+\d+\s+OF\s+\d+$', re.I)
def _is_housekeeping_text(t):
return bool(_TS_RE.match(t) or _FURNITURE_RE.match(t))
_PART_RE = re.compile(r'\b((?:P|SR|SP|S)\d+)\b')
def compare_callouts(a, b, locate):
"""Loose annotation text, as a multiset.
Position is a locator here, not a comparison key. Callouts drift by a point
or two on every replot; comparing positions would fire on all of them,
while the multiset fires only when a string genuinely appears or
disappears.
Added and removed strings that name the SAME PART are then paired into one
finding and raised to MATERIAL. That pairing is the difference between
removed P106 (S.I.F.)(E.S.)
added P106 (S.I.F.)
-- two advisory lines a reader skims past -- and "the P106 callout lost its
(E.S.)", which is an instruction about where an embed goes and is the kind
of thing this whole tool exists to surface.
"""
out = []
ca, cb = a.get('callouts', []), b.get('callouts', [])
titles_a, titles_b = a.get('view_titles', []), b.get('view_titles', [])
def bag(items):
d = {}
for s in items:
d.setdefault(s['text'], []).append(s)
return d
ba, bb = bag(ca), bag(cb)
removed, added = [], []
for t in sorted(set(ba) | set(bb)):
na, nb = len(ba.get(t, [])), len(bb.get(t, []))
if na == nb:
continue
if nb > na:
added.append((t, nb - na, bb[t][0]))
else:
removed.append((t, na - nb, ba[t][0]))
def parts(t):
return tuple(_PART_RE.findall(t))
used_add = set()
for i, (ta, na, sa) in enumerate(removed):
pa = parts(ta)
if not pa:
continue
for j, (tb_, nb, sb) in enumerate(added):
if j in used_add or parts(tb_) != pa:
continue
used_add.add(j)
removed[i] = None
out.append(finding(
'CALLOUT', MATERIAL,
locate(titles_b, sb['page'], sb['x'], sb['y']),
'the callout for %s was reworded' % ', '.join(pa),
ta, tb_, loc_old=_loc(sa), loc_new=_loc(sb)))
break
for entry in removed:
if entry is None:
continue
t, n, s = entry
sev = HOUSEKEEP if _is_housekeeping_text(t) else ADVISORY
out.append(finding(
'CALLOUT', sev, locate(titles_a, s['page'], s['x'], s['y']),
'text removed from the sheet%s'
% ('' if n == 1 else ' (%d times)' % n),
t, None, loc_old=_loc(s)))
for j, (t, n, s) in enumerate(added):
if j in used_add:
continue
sev = HOUSEKEEP if _is_housekeeping_text(t) else ADVISORY
out.append(finding(
'CALLOUT', sev, locate(titles_b, s['page'], s['x'], s['y']),
'text added to the sheet%s'
% ('' if n == 1 else ' (%d times)' % n),
None, t, loc_new=_loc(s)))
return out
def compare_hazards(a, b):
"""Every hazard on either side, reported as uncertainty, never as silence."""
out = []
for doc, side in ((a, 'old'), (b, 'new')):
for h in doc.get('hazards', []):
loc = 'page %d' % (h['page'] + 1)
if h.get('x') is not None:
loc += ' near x=%.0f y=%.0f' % (h['x'], h['y'])
out.append(finding(
'HAZARD', UNCERTAIN, '%s issue, %s' % (side, loc),
'text-layer hazard (%s): %s' % (h['mode'], h['detail']),
None, ' | '.join(h.get('texts') or []) or None))
return out
# --------------------------------------------------------------------------
# top level
# --------------------------------------------------------------------------
def compare(a, b, parse_dim, format_inches, locate):
"""Two extracted tickets -> a list of findings, most material first.
parse_dim / format_inches / locate are injected rather than imported so
this module stays free of pymupdf and testable on plain dicts.
"""
out = []
out += compare_title_block(a, b)
out += compare_bom(a, b)
out += compare_handling(a, b, parse_dim, format_inches)
out += compare_chains(a, b, parse_dim, format_inches, locate)
out += compare_notes(a, b, locate)
out += compare_callouts(a, b, locate)
out += compare_hazards(a, b)
if a.get('pages') != b.get('pages'):
out.append(finding('SHEET', MATERIAL, 'the ticket as a whole',
'page count changed', a.get('pages'), b.get('pages')))
out.sort(key=lambda f: (_ORDER[f['severity']], f['kind'], f['where'] or ''))
return out
def summarise(findings):
"""Counts per severity, for a one-line verdict."""
d = {MATERIAL: 0, UNCERTAIN: 0, ADVISORY: 0, HOUSEKEEP: 0}
for f in findings:
d[f['severity']] += 1
return d
@@ -0,0 +1,714 @@
# -*- coding: utf-8 -*-
"""Read one shop-ticket PDF into a structure a revision diff can reason about.
WHY THIS MODULE EXISTS
A ticket is not a page of prose. It is a title block, a bill of material, three
handling diagrams, a set of note blocks, and a drawing carrying dimension
chains and part callouts. Those are the things a precast engineer names when
he says what changed. A diff that works on lines of text says "line 47
differs"; a diff that works on these says "the dunnage diagram's near-end
inset went from 4'-9" to 5'-0"". Only the second one gets read.
So extraction is region-aware, and every region is found by an ANCHOR, never
by an absolute coordinate. Measured reason: WP199 puts its handling diagrams
at x=819 and WP219 puts them at x=910. A coordinate-keyed extractor would have
silently returned nothing for half the set and reported "no changes".
WHAT THIS MODULE REFUSES TO DO
It does not guess. Four PDF text-layer failure modes were measured on this
project's other source documents, and each one produces a CONFIDENT WRONG
NUMBER rather than an error:
1. stacked fractions collapsing ("1316"" for both 13/16" and 1 3/16")
2. cipher-shifted subset fonts (characters mapped 29 code points down)
3. stamps invisible to text search
4. retired text sitting underneath current text, returned first
A diff is uniquely exposed to these, because a misread on ONE SIDE ONLY
invents a change that never happened. So `hazards()` scans for all four and
the diff downgrades or drops anything they touch. Measured on this ticket set:
modes 1 and 2 are absent (fractions come through as "1/2", no non-ASCII byte
in 46,079 spans), mode 4 is present (345 exactly-colocated duplicate spans,
2 colocated pairs with DIFFERENT text). That measurement is what licenses
reading these particular numbers off the text layer; it is not a general
licence, which is why the scan runs on every file every time.
CPython only -- pymupdf is not available to IronPython, so the Revit-side code
must not import this. The comparison half lives in ticketdelta.py and is pure.
"""
import os
import re
# --------------------------------------------------------------------------
# dimension parsing
# --------------------------------------------------------------------------
# A ticket writes the same length two ways depending on which family drew it:
# the bed plan prints 1' - 0", the handling diagrams print 1'-0". Comparing the
# strings would report a change on every ticket. Compare the VALUE, show the
# RAW TEXT.
_DIM_RE = re.compile(
r'^(?:(\d+)\'\s*-?\s*)?' # feet
r'(?:(\d+)(?!/))?' # whole inches (not the numerator of a fraction)
r'\s*(?:(\d+)/(\d+))?' # fractional inches
r'"$'
)
def parse_dim(text):
"""Feet-and-inches string -> inches as a float, or None if not a dimension.
Accepts 22'-11", 22' - 11", 2' - 5 1/2", 11 1/2", 8", 3/4".
Returns None for anything else -- including things that merely contain a
dimension, such as (5) @ 1'-6" = 7'-6", which is a note, not a dimension.
"""
if not text:
return None
t = text.strip().replace(u'’', "'").replace(u'”', '"')
m = _DIM_RE.match(t)
if not m:
return None
ft, inch, num, den = m.groups()
if ft is None and inch is None and num is None:
return None
total = 0.0
if ft:
total += float(ft) * 12.0
if inch:
total += float(inch)
if num and den and float(den):
total += float(num) / float(den)
return total
def format_inches(v):
"""Inches -> a feet-and-inches string, for reporting derived totals only."""
if v is None:
return '?'
neg = v < 0
v = abs(v)
ft = int(v // 12)
rem = v - ft * 12
inch = int(rem)
frac = rem - inch
sixteenths = int(round(frac * 16))
if sixteenths == 16:
inch += 1
sixteenths = 0
out = ''
if ft:
out += "%d'-" % ft
out += '%d' % inch
if sixteenths:
n, d = sixteenths, 16
while n % 2 == 0:
n //= 2
d //= 2
out += ' %d/%d' % (n, d)
out += '"'
return ('-' if neg else '') + out
# --------------------------------------------------------------------------
# raw span reading
# --------------------------------------------------------------------------
def read_spans(path):
"""Every text span in the PDF as dicts, deduplicated.
Deduplication is not tidying. This set contains 345 spans that are drawn
twice at the same point -- a dimension double-placed in the source view.
Left in, a ticket that happens to double-place a different dimension in
the new issue reports a phantom "added" string.
"""
import pymupdf
doc = pymupdf.open(path)
spans = []
seen = set()
for pno, page in enumerate(doc):
for block in page.get_text('dict')['blocks']:
if block.get('type') != 0:
continue
for line in block['lines']:
d = line.get('dir', (1.0, 0.0))
vertical = abs(d[1]) > abs(d[0])
for sp in line['spans']:
text = sp['text'].strip()
if not text:
continue
bb = sp['bbox']
key = (pno, text, round(bb[0], 1), round(bb[1], 1))
if key in seen:
continue
seen.add(key)
spans.append({
'page': pno,
'text': text,
'x': bb[0], 'y': bb[1],
'x1': bb[2], 'y1': bb[3],
'size': round(sp['size'], 1),
'font': sp['font'],
'vertical': vertical,
'block': id(block),
})
npages = doc.page_count
doc.close()
return spans, npages
# --------------------------------------------------------------------------
# hazards -- the four measured text-layer failure modes
# --------------------------------------------------------------------------
_COLLAPSED_RE = re.compile(r'^\d{3,}"$')
# Sheet furniture: printed on every ticket, meaning nothing about the piece,
# and grouped into whatever text block happens to be near it. Left in, it
# makes a note look reworded because the page number moved.
SHEET_FURNITURE_RE = re.compile(r'^PAGE\s+\d+\s+OF\s+\d+$', re.I)
def hazards(spans, npages):
"""Text-layer conditions that make a value untrustworthy.
Returns a list of dicts {mode, detail, page, x, y, texts}. An empty list is
a measurement, not an assumption: it means all four scans ran and found
nothing on this file.
"""
found = []
# Mode 1 -- a stacked fraction that lost its slash reads as one long
# integer. 13/16" and 1 3/16" both collapse to 1316".
for s in spans:
if _COLLAPSED_RE.match(s['text']):
found.append({'mode': 'collapsed-fraction', 'page': s['page'],
'x': s['x'], 'y': s['y'], 'texts': [s['text']],
'detail': 'reads as a single integer; may be a '
'stacked fraction that lost its slash'})
# Mode 2 -- a cipher-shifted subset font puts digits on control characters.
for s in spans:
bad = [c for c in s['text'] if ord(c) < 32 or ord(c) > 126]
if bad:
found.append({'mode': 'cipher-font', 'page': s['page'],
'x': s['x'], 'y': s['y'], 'texts': [s['text']],
'detail': 'non-printable or non-ASCII characters in '
'font %s' % s['font']})
# Mode 3 -- a page whose text layer is far thinner than its siblings has
# probably been flattened to vectors, and its stamps and callouts will be
# invisible to any text-based diff. Judged per file against its own pages,
# since a two-page ticket's pages carry comparable amounts of text.
per_page = {}
for s in spans:
per_page[s['page']] = per_page.get(s['page'], 0) + 1
for p in range(npages):
n = per_page.get(p, 0)
if n < 20:
found.append({'mode': 'thin-text-layer', 'page': p,
'x': None, 'y': None, 'texts': [],
'detail': 'only %d text spans on this page; the page '
'may be flattened to vectors, so anything '
'not listed here is not "unchanged", it is '
'unread' % n})
# Mode 4 -- retired text underneath current text. Colocated spans with
# DIFFERENT content mean get_text() is returning at least one string that
# is not what the page shows.
cells = {}
for s in spans:
k = (s['page'], round(s['x'] / 2.0), round(s['y'] / 2.0))
cells.setdefault(k, []).append(s['text'])
for k, v in sorted(cells.items()):
if len(set(v)) > 1:
found.append({'mode': 'overlaid-text', 'page': k[0],
'x': k[1] * 2.0, 'y': k[2] * 2.0, 'texts': sorted(set(v)),
'detail': 'two different strings drawn at the same '
'point; one of them is not what the sheet '
'shows and the text layer does not say which'})
return found
# --------------------------------------------------------------------------
# region 1 -- title block
# --------------------------------------------------------------------------
# Anchored on the printed labels, not on coordinates. A label sits above-left
# of its value in a cell; the cell's right edge is the next label on the row.
TITLE_LABELS = [
'MARK #:', 'PROJECT NAME:', 'PROJECT #:', 'SEQUENCE #', 'LENGTH:',
'WIDTH:', 'QUANITY:', 'QUANTITY:', "CONCRETE (f'c):", "CONCRETE (f'ci):",
'THICKNESS:', 'WEIGHT (lbs.):', "ADD'L PRODUCTION:", "ADD'L FINISH:",
'PRODUCT CODE:', 'FACE MIX #', 'FACE MIX (C.Y.)', 'BASE MIX#',
'BASE MIX (C.Y.)', 'DRAWN BY:', 'ENG BY:',
]
# What a field's value is allowed to look like. A value that fails its own
# pattern is reported as unreadable rather than diffed -- a title block cell
# read from the wrong side of a boundary produces a plausible wrong string,
# and a diff of two plausible wrong strings is the worst output this tool
# could emit.
_FIELD_SHAPE = {
'LENGTH:': re.compile(r'^\d+\'[-\s]*\d+(\s\d+/\d+)?"$'),
'WIDTH:': re.compile(r'^\d+\'[-\s]*\d+(\s\d+/\d+)?"$'),
'THICKNESS:': re.compile(r'^\d+(\s\d+/\d+)?"$'),
'WEIGHT (lbs.):': re.compile(r'^[\d,]+$'),
'QUANITY:': re.compile(r'^\d+$'),
'QUANTITY:': re.compile(r'^\d+$'),
"CONCRETE (f'c):": re.compile(r'^\d+$'),
"CONCRETE (f'ci):": re.compile(r'^\d+$'),
'FACE MIX (C.Y.)': re.compile(r'^[\d.]+\s*CY$'),
'BASE MIX (C.Y.)': re.compile(r'^[\d.]+\s*CY$'),
}
_ROW_TOL = 1.5
_ROW_PITCH = 11.5
def title_block(spans, claim=None):
"""{label: {'value':..., 'ok':bool, 'x':, 'y':, 'page':}} keyed by label.
`claim`, if given, is a set that collects the id() of every span consumed
here, so the leftover-callout pass does not report title-block values a
second time under a different name.
"""
all_labels = [s for s in spans if s['text'] in TITLE_LABELS]
if not all_labels:
return {}
# Every page of a ticket carries the title block. Only the first page's
# values are reported as fields -- they are the same values -- but the
# LATER pages' cells must still be claimed, or the project name reappears
# on page 2 as a sheet callout that was "added to the drawing".
pages = sorted(set(s['page'] for s in all_labels))
out = {}
for page in pages:
_title_block_page(spans, all_labels, page, out if page == pages[0] else {},
claim)
_control_numbers(spans, out, claim)
return out
def _title_block_page(spans, all_labels, page, out, claim):
labels = [s for s in all_labels if s['page'] == page]
others = [s for s in spans
if s['page'] == page and s['text'] not in TITLE_LABELS]
rows = {}
for s in labels:
for ry in rows:
if abs(ry - s['y']) <= _ROW_TOL:
rows[ry].append(s)
break
else:
rows[s['y']] = [s]
row_ys = sorted(rows)
out = {}
for i, ry in enumerate(row_ys):
cells = sorted(rows[ry], key=lambda s: s['x'])
nxt_row = row_ys[i + 1] if i + 1 < len(row_ys) else None
y_hi = ry + _ROW_PITCH
if nxt_row is not None:
y_hi = min(y_hi, nxt_row - _ROW_TOL)
for j, lab in enumerate(cells):
x_lo = lab['x'] - 5.0
x_hi = cells[j + 1]['x'] - 1.0 if j + 1 < len(cells) else 1e9
vals = [s for s in others
if x_lo <= s['x'] < x_hi and ry - _ROW_TOL <= s['y'] <= y_hi]
vals.sort(key=lambda s: (s['y'], s['x']))
text = ' '.join(v['text'] for v in vals).strip()
shape = _FIELD_SHAPE.get(lab['text'])
ok = True
if text and shape and not shape.match(text):
ok = False
out[lab['text']] = {'value': text, 'ok': ok, 'page': page,
'x': lab['x'], 'y': lab['y']}
if claim is not None:
claim.add(id(lab))
for v in vals:
claim.add(id(v))
return out
def _control_numbers(spans, out, claim):
# Control numbers are the ticket's identity under this plant's rule (one
# ticket per control number, not per mark), and they are printed outside
# the title-block grid.
for s in spans:
if s['text'].upper().startswith('CONTROL NUMBER'):
near = [o for o in spans
if o['page'] == s['page'] and o['x'] > s['x']
and abs(o['y'] - s['y']) < 12 and re.match(r'^[\d,\s]+$', o['text'])]
near.sort(key=lambda o: o['x'])
out['CONTROL NUMBERS:'] = {
'value': ' '.join(o['text'] for o in near).strip(),
'ok': True, 'page': s['page'], 'x': s['x'], 'y': s['y']}
if claim is not None:
claim.add(id(s))
for o in near:
claim.add(id(o))
break
return out
# --------------------------------------------------------------------------
# region 2 -- bill of material
# --------------------------------------------------------------------------
def bom(spans, claim=None):
"""[{'qty','mark','desc','page','x','y'}] from the QTY/MARK/DESCRIPTION table.
Anchored on the header row. Description continuation lines carry no qty and
no mark, so rows are opened by a qty cell and closed by the next one.
"""
hdr_q = hdr_m = hdr_d = None
for s in spans:
if s['text'] == 'QTY':
cand_m = [o for o in spans if o['page'] == s['page']
and o['text'] == 'MARK' and abs(o['y'] - s['y']) < 2
and o['x'] > s['x']]
cand_d = [o for o in spans if o['page'] == s['page']
and o['text'] == 'DESCRIPTION' and abs(o['y'] - s['y']) < 2
and o['x'] > s['x']]
if cand_m:
hdr_q, hdr_m = s, cand_m[0]
hdr_d = cand_d[0] if cand_d else None
break
if hdr_q is None:
return []
page = hdr_q['page']
x_mark = hdr_m['x'] - 8.0
x_desc = (hdr_d['x'] - 4.0) if hdr_d else (x_mark + 40.0)
# The mark column is narrow. Widening it to "everything before the
# DESCRIPTION column" was measured to swallow a handling-diagram label
# sitting at x=1055 and report the strand row's mark as ERECTION -- a
# phantom "item added / item removed" pair on a ticket whose bill did not
# change at all.
x_mark_hi = min(x_desc, hdr_m['x'] + 35.0)
body = [s for s in spans if s['page'] == page and s['y'] > hdr_q['y'] + 2
and s['x'] >= hdr_q['x'] - 8.0
and s['text'] not in HANDLING_LABELS]
# A row is opened by a bare integer in the QTY column. Requiring the
# integer matters: a note block printed below the table sits in the same
# x-band, and without this it opens phantom rows and swallows the strand
# row's description.
qtys = sorted([s for s in body if s['x'] < x_mark and re.match(r'^\d+$', s['text'])],
key=lambda s: s['y'])
if not qtys:
return []
marks = sorted([s for s in body if x_mark <= s['x'] < x_mark_hi],
key=lambda s: (s['y'], s['x']))
descs = [s for s in body if s['x'] >= x_desc]
# Row pitch, measured on this ticket rather than assumed, bounds the last
# row so it cannot absorb whatever is printed under the table.
gaps = sorted(qtys[i + 1]['y'] - qtys[i]['y'] for i in range(len(qtys) - 1))
pitch = gaps[len(gaps) // 2] if gaps else 11.0
if claim is not None:
claim.add(id(hdr_q))
claim.add(id(hdr_m))
if hdr_d is not None:
claim.add(id(hdr_d))
rows = []
bbox = [hdr_q['x'] - 10.0, hdr_q['y'] - 4.0,
max(sp['x1'] for sp in body) if body else hdr_q['x'] + 250.0,
max(q['y'] for q in qtys) + pitch * 1.8]
for i, q in enumerate(qtys):
y_hi = q['y'] + pitch * 1.8
if i + 1 < len(qtys):
y_hi = min(y_hi, qtys[i + 1]['y'] - 2.0)
mk = [m for m in marks if q['y'] - 2.0 <= m['y'] < y_hi]
ds = sorted([d for d in descs if q['y'] - 2.0 <= d['y'] < y_hi],
key=lambda s: (s['y'], s['x']))
rows.append({
'qty': q['text'],
'mark': mk[0]['text'] if mk else '',
'desc': ' '.join(d['text'] for d in ds),
'page': page, 'x': q['x'], 'y': q['y'],
})
if claim is not None:
claim.add(id(q))
for s in mk + ds:
claim.add(id(s))
if claim is not None:
claim.add(('bom_bbox', page) + tuple(bbox))
return rows
# --------------------------------------------------------------------------
# region 3 -- handling diagrams
# --------------------------------------------------------------------------
HANDLING_LABELS = ('DUNNAGE', 'PICKING/STRIPPING', 'PICKING', 'STRIPPING',
'ERECTION', 'SHIPPING', 'TRANSPORTATION')
# A handling dimension is printed a fixed distance BELOW its diagram's label
# and starting at the label's own x. Both numbers are measurements, not
# guesses: across all 26 tickets in the two issues the offset is 43-46 points
# and never anything else, and the rule reproduces every diagram on every file.
#
# The rule this replaced -- "everything in the label's column, between this
# label and the next one down" -- was wrong because the diagrams are not always
# in one column. Half the set stacks all three at one x; the other half puts
# PICKING/STRIPPING at x=910 and ERECTION and DUNNAGE side by side at x=1055.
# WP194, WP195 and WP200 CHANGE LAYOUT between the two issues, so a
# column-based reader compared a four-dimension picking diagram against a
# two-dimension one and called it a material change on three tickets where
# nothing had moved.
_HANDLING_DY_MIN = 33.0
_HANDLING_DY_MAX = 62.0
_HANDLING_DX_MIN = -18.0
_HANDLING_DX_MAX = 210.0
def handling(spans):
"""{diagram label: [dim spans left-to-right]} for the little stage diagrams.
Order is PRINTED order, left to right. It is not mirrored and it is not
sorted by value. That matters more than it looks: reading a pair of dunnage
insets the other way round was measured, on this very ticket set, to turn
an engineer's CORRECTED sheet back into a failure. A diff that disagrees
with a fix the reader has already made is a diff he stops opening.
"""
labels = [s for s in spans if s['text'] in HANDLING_LABELS]
out = {}
for lab in sorted(labels, key=lambda s: (s['page'], s['y'], s['x'])):
dims = [s for s in spans
if s['page'] == lab['page']
and lab['y'] + _HANDLING_DY_MIN <= s['y'] <= lab['y'] + _HANDLING_DY_MAX
and lab['x'] + _HANDLING_DX_MIN <= s['x'] <= lab['x'] + _HANDLING_DX_MAX
and parse_dim(s['text']) is not None]
dims.sort(key=lambda s: s['x'])
key = lab['text']
n = 2
while key in out:
key = '%s (%d)' % (lab['text'], n)
n += 1
out[key] = dims
return out
# --------------------------------------------------------------------------
# region 4 -- note blocks
# --------------------------------------------------------------------------
_NOTE_HEAD_RE = re.compile(r'^(PRODUCTION\s+)?NOTES?\s*:?$', re.I)
def notes(spans, exclude=None):
"""[{'text','page','x','y'}] -- each note, from its heading down.
A note is its HEADING plus the lines under it, not "whatever pymupdf put
in the same block". Measured reason: on page 2 of one ticket the block
containing PRODUCTION NOTE also contains a 6" dimension and an SR567 bar
tag drawn nearby, and taking the block whole reported the note as removed
on one issue and added on the other because a bar tag next to it had
moved. Anchoring on the heading makes the note the paragraph an engineer
would read out, so an added or reworded note surfaces once, as a
paragraph, rather than as four orphan lines.
"""
by_block = {}
for s in spans:
by_block.setdefault(s['block'], []).append(s)
out = []
for _, ss in by_block.items():
keep = [x for x in sorted(ss, key=lambda x: (x['y'], x['x']))
if x['text'] not in TITLE_LABELS
and not SHEET_FURNITURE_RE.match(x['text'])
and not (exclude and id(x) in exclude)]
heads = [i for i, x in enumerate(keep) if _NOTE_HEAD_RE.match(x['text'])]
for n, i in enumerate(heads):
j = heads[n + 1] if n + 1 < len(heads) else len(keep)
body = keep[i:j]
out.append({'text': ' '.join(' '.join(x['text'] for x in body).split()),
'page': body[0]['page'], 'x': body[0]['x'],
'y': body[0]['y'],
'ids': [id(x) for x in body]})
out.sort(key=lambda n: (n['page'], n['y'], n['x']))
return out
# --------------------------------------------------------------------------
# region 5 -- dimension chains on the drawing
# --------------------------------------------------------------------------
_CHAIN_TOL = 2.5
def chains(spans, exclude):
"""Dimension strings grouped into the chains they are printed in.
A dimension chain is what a detailer actually edits: a run of segments
along one line, adding up to an overall. Grouping by the printed line and
comparing the run in order is what turns "two numbers differ somewhere on
a D-size sheet" into "the second and third segments of the top chain on the
bed plan swapped 3'-3"/1'-0" for 2'-9"/1'-6", total unchanged".
`exclude` is the set of span ids already claimed by another region, so a
handling-diagram dimension is not also reported as a loose chain.
"""
dims = [s for s in spans
if id(s) not in exclude and parse_dim(s['text']) is not None]
groups = []
for vert in (False, True):
pool = [s for s in dims if s['vertical'] == vert]
used = set()
for s in sorted(pool, key=lambda s: (s['page'], s['x'], s['y'])):
if id(s) in used:
continue
axis = 'x' if vert else 'y'
run = [o for o in pool if o['page'] == s['page']
and id(o) not in used and abs(o[axis] - s[axis]) <= _CHAIN_TOL]
for o in run:
used.add(id(o))
run.sort(key=lambda o: o['y'] if vert else o['x'])
other = 'y' if vert else 'x'
groups.append({
'page': s['page'],
'vertical': vert,
# `pos` is across the run (which line the chain sits on);
# `along` is where along that line it starts. Both are needed:
# two section details on the same page share an orientation and
# can share a `pos`, and matching on `pos` alone paired a
# dimension in SECTION A with one in SECTION B and called it a
# change on a ticket whose dimension text was byte-identical.
'pos': round(sum(o[axis] for o in run) / float(len(run)), 1),
'along': round(min(o[other] for o in run), 1),
'items': run,
})
groups.sort(key=lambda g: (g['page'], g['vertical'], g['pos']))
return groups
# --------------------------------------------------------------------------
# region 6 -- callouts and view titles
# --------------------------------------------------------------------------
_TITLE_WORDS = ('PLAN', 'SECTION', 'ELEVATION', 'DETAIL', 'VIEW', 'SCHEDULE')
def view_titles(spans):
"""Large-font strings that name a view, used as human-readable locators.
The size test alone is not enough: a section bubble's "A", the "MK" tag on
the bed plan and the scale string "1/4" = 1'-0"" are all set at the title
size, and naming a finding's location "MK, page 1" tells a reader nothing.
A view title on these sheets always contains one of the drawing words.
"""
out = []
for s in spans:
if s['size'] < 12.0:
continue
t = s['text'].upper()
if any(w in t for w in _TITLE_WORDS):
out.append(s)
return out
def locate(spans_titles, page, x, y, page_rect=(1224.0, 792.0)):
"""Name a point on the sheet the way somebody hunting for it would.
View title plus a coarse zone. "BED PLAN, page 1" on a D-size sheet is
still a lot of paper to search; "BED PLAN, page 1, lower left" is not.
"""
w, h = page_rect
col = 'left' if x < w / 3.0 else ('centre' if x < 2 * w / 3.0 else 'right')
row = 'upper' if y < h / 3.0 else ('middle' if y < 2 * h / 3.0 else 'lower')
zone = '%s %s' % (row, col)
cands = [t for t in spans_titles if t['page'] == page]
if not cands:
return 'page %d, %s' % (page + 1, zone)
best = min(cands, key=lambda t: (t['x'] - x) ** 2 + (t['y'] - y) ** 2)
return '%s, page %d, %s' % (best['text'], page + 1, zone)
def callouts(spans, claimed):
"""Everything left over that is a real annotation rather than a dimension.
Deliberately excludes anything already claimed by a region and anything
that parses as a dimension. What is left is part callouts (P76 W/ SR5602
(S.I.F.)), bar tags, spacing strings, section bubbles.
"""
out = []
for s in spans:
if id(s) in claimed:
continue
if parse_dim(s['text']) is not None:
continue
if len(s['text']) < 2:
continue
out.append(s)
return out
# --------------------------------------------------------------------------
# top level
# --------------------------------------------------------------------------
_TS_RE = re.compile(r'^\d{1,2}/\d{1,2}/\d{2,4}(\s+\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?)?$',
re.I)
def extract(path):
"""One ticket PDF -> the structure ticketdelta.compare() consumes."""
spans, npages = read_spans(path)
haz = hazards(spans, npages)
claimed = set()
tb = title_block(spans, claimed)
bm = bom(spans, claimed)
bom_box = None
for c in claimed:
if isinstance(c, tuple) and c and c[0] == 'bom_bbox':
bom_box = {'page': c[1], 'x0': c[2], 'y0': c[3],
'x1': c[4], 'y1': c[5]}
claimed = set(c for c in claimed if not isinstance(c, tuple))
hl = handling(spans)
nt = notes(spans, claimed)
titles = view_titles(spans)
for _, dims in hl.items():
for d in dims:
claimed.add(id(d))
for s in spans:
if s['text'] in HANDLING_LABELS:
claimed.add(id(s))
# Note blocks are compared as paragraphs, so their lines must not also
# surface as loose callouts.
for n in nt:
for i in n['ids']:
claimed.add(i)
ch = chains(spans, claimed)
for g in ch:
for it in g['items']:
claimed.add(id(it))
stamps = [s['text'] for s in spans if _TS_RE.match(s['text'])]
return {
'path': path,
'file': os.path.basename(path),
'pages': npages,
'hazards': haz,
'title_block': tb,
'bom': bm,
'bom_bbox': bom_box,
'handling': hl,
'notes': nt,
'chains': ch,
'view_titles': titles,
'callouts': callouts(spans, claimed),
'timestamps': sorted(set(stamps)),
'span_count': len(spans),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+791
View File
@@ -0,0 +1,791 @@
# -*- coding: utf-8 -*-
"""A small, hand-rolled YAML subset: write it, read it back, nothing else.
Two things this is not: a general YAML library, and a wrapper around one.
Neither exists for free here -- PyYAML is a C extension, not available under
IronPython 2.7, and the one YAML reader this extension already had
(`pyrevit.coreutils.yaml`) only exists inside a running Revit/pyRevit process,
which is exactly the environment a unit test does not have.
So this module writes ONE well-defined, restricted shape of YAML -- block
mappings, block and flow lists, quoted/plain scalars, full-line comments --
and reads back exactly that shape. It has no Revit dependency and no
third-party dependency, so it is the same code under IronPython 2.7 inside
Revit and under CPython in a test. `configdump.py` (the project-config dump)
and `designspec.py` / `specformat.py` (the design spec) both write through
`emit()` and read through `parse()`; neither has its own YAML code.
The contract `parse()` relies on: every document `emit()` produces is a tree
of dicts, lists and scalars, indented two spaces per level, with a list
item's dash occupying exactly one indent unit (`'- '` is two characters, the
same width as `' '`) so a list item's own fields line up under it. That is
not a guess about YAML in general -- it is what `emit()` itself does, checked
against its own output below.
"""
# pylint: disable=invalid-name
import re
from vibe.compat import string_types, text_type
_PLAIN = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_ ./+-]*$')
_RESERVED = ('true', 'false', 'null', 'yes', 'no', 'on', 'off', '~')
_NUMBER_LOOKING = re.compile(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$')
def scalar(value):
"""One value as it is written on the right of a `key:`."""
if value is None:
return 'null'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, float):
text = repr(value)
return text[:-2] if text.endswith('.0') else text
if not isinstance(value, string_types):
return str(value)
text = text_type(value)
if not text:
return "''"
if text.lower() in _RESERVED or not _PLAIN.match(text) \
or text != text.strip() or text.endswith(':'):
return "'" + text.replace("'", "''") + "'"
# A bare number-looking string has to be quoted or it reads back as a
# number, and a part number like 4.10 would come back as 4.1.
if _NUMBER_LOOKING.match(text):
return "'" + text + "'"
return text
def is_flat(node):
"""A list of plain scalars, short enough to sit on one line."""
if not isinstance(node, list) or not node:
return False
if any(isinstance(v, (dict, list)) for v in node):
return False
return sum(len(scalar(v)) + 2 for v in node) <= 66
def emit(node, indent=0):
"""A dict/list/scalar tree as YAML lines. Deterministic key order."""
pad = ' ' * indent
out = []
if isinstance(node, dict):
if not node:
return [pad + '{}']
for key in sorted(node):
value = node[key]
label = pad + scalar(key) + ':'
if isinstance(value, dict) and value:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, list) and value:
if is_flat(value):
out.append('{0} [{1}]'.format(
label, ', '.join(scalar(v) for v in value)))
else:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, (dict, list)):
out.append('{0} {1}'.format(label,
'{}' if isinstance(value, dict)
else '[]'))
else:
out.append('{0} {1}'.format(label, scalar(value)))
return out
if isinstance(node, list):
for item in node:
if isinstance(item, dict) and item:
lines = emit(item, indent + 1)
# The first key rides on the dash, the rest line up under it.
out.append(pad + '- ' + lines[0].strip())
out.extend(lines[1:])
elif isinstance(item, list) and item:
lines = emit(item, indent + 1)
out.append(pad + '-')
out.extend(lines)
else:
out.append(pad + '- ' + scalar(item))
return out
return [pad + scalar(node)]
# ------------------------------------------------------------------- parse
class YamlKitError(ValueError):
"""A document this parser cannot read, said out loud instead of cut short.
A `ValueError` subclass so a caller that already guards a parse with
`except ValueError` keeps working, and its own class so one that wants
to name this failure can.
WHY THIS EXISTS AT ALL -- the lesson, kept where the code is.
A parser that stops early without saying so is worse than one that
raises, and this module has now proved it repeatedly. Every one of
these is ordinary YAML, none of them is anything `emit()` writes, and
each of them silently swallowed the rest of a real file:
* `knowledge/catalogue.yaml` carried one `note: >-` two thirds of the
way down, and every line under it -- the whole rebar branch -- was
invisible while the file "parsed fine" (fixed: `_is_block_scalar`);
* a job knowledge file carried `parts: [P76, P89,` wrapping onto the
next line, and the three sections below it were simply absent;
* the same file wrote a block sequence at its key's own indentation
(`notes:` then `- ...` in the same column);
* it wrapped a long list item's text onto a second, indented line;
* a plant file put a `# comment` on the end of a `key:` line.
16 top-level keys in that job file, 13 in what `parse()` returned.
The second one cost a real press. The config editor cross-checks its
tree against the one the Revit-side reader builds, key by key, and the
key it happened to be writing was `snapshot.detached_dir` -- so a
WHOLE-FILE failure was reported as "the two YAML readers disagree about
snapshot.detached_dir", a sentence about an innocent key that sent the
reader to the wrong file entirely. Every key past the cut would have
produced the same message and the same wrong destination.
So the rule for anything added to this parser: a construct it cannot
read raises. It never returns the part it managed to get through.
"""
def parse(text):
"""The inverse of `emit()`. Reads back exactly the shape it produces.
Comments (any line whose stripped text starts with '#') and blank lines
are ignored wherever they appear -- a spec file is meant to carry a
comment above every section, and this is what makes that free. The
document is assumed to be one mapping at the top, which is true of every
file this module writes (a knowledge dump, a design spec).
Beyond what `emit()` writes, `_logical_lines` folds the hand-written
shapes a knowledge file really contains -- a wrapped flow sequence, a
trailing comment, a plain scalar continued on the next line -- before
any of the block grammar runs. Every one of them used to truncate the
document in silence; see `YamlKitError`.
Raises `YamlKitError` on a flow sequence that is never closed.
"""
lines = _logical_lines(text)
if not lines:
return {}
top_indent = lines[0][0]
value, _idx = _parse_dict(lines, 0, top_indent)
return value
def _logical_lines(text):
"""Physical lines folded into the (level, text) pairs the grammar reads.
Blank lines and whole-line comments are dropped wherever they appear --
a spec file is meant to carry a comment above every section, and this
is what makes that free. What is left is one entry per LOGICAL line,
which is not always one physical line: see `_join_flow` and
`_fold_plain`.
The one place none of this applies is the body of a block scalar, where
a '#' is text and an indented line is content rather than structure.
Those lines are passed through verbatim for `_parse_dict` to gather.
"""
raw = (text or '').split('\n')
total = len(raw)
out = []
index = 0
block_level = None
while index < total:
line = raw[index]
number = index + 1
index += 1
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
# emit() always indents two spaces per level (a list item's dash is
# the same width as one level, see the module docstring), so integer
# division recovers the level exactly -- no half-levels exist here.
level = (len(line) - len(line.lstrip(' '))) // 2
if block_level is not None:
if level > block_level:
out.append((level, stripped))
continue
block_level = None
body = _strip_comment(stripped)
if not body:
continue
offset = _flow_value_offset(body)
if offset >= 0:
body, index = _join_flow(raw, index, body, offset, number)
elif _plain_value(body):
body, index = _fold_plain(raw, index, body, level)
if _is_block_scalar(_line_value(body) or ''):
block_level = level
out.append((level, body))
return out
def _line_value(text):
"""The value half of a line, read the way the block grammar reads it.
`None` when the line has no value half at all (a bare `-`, or a line
with no colon). Mirrors `_parse_dict`'s `partition(':')` and
`_parse_list`'s `_is_mapping_line` exactly, so the pre-pass can never
disagree with the parse that follows about what a line's value is.
"""
if text == '-':
return None
if text[:2] == '- ':
remainder = text[2:]
if _is_mapping_line(remainder):
return remainder.partition(':')[2].strip()
return remainder.strip()
_key, sep, rest = text.partition(':')
if not sep:
return None
return rest.strip()
def _plain_value(text):
"""True when this line's value is a plain scalar that could continue.
Not a block-scalar indicator (`_parse_dict` gathers those itself), not
a flow collection (`_join_flow` has already dealt with those), and not
empty (an empty value means the value is the indented block below).
"""
value = _line_value(text)
return bool(value) and not _is_block_scalar(value)
def _fold_plain(raw, index, body, level):
"""A plain scalar continued on the following, more-indented lines.
YAML folds those into the one scalar with a space, and a hand-written
note that ran long is exactly where it happens. This parser used to do
something far worse than misread it: an indented line after a `key:
value` matches no branch of `_parse_dict`'s loop, so it BROKE the loop
and every enclosing one, and the document ended there.
A continuation stops at the first line that is not more indented, at a
blank or comment line, and at any line that is itself a list item or a
`key: value` -- that last guard is what keeps the second field of a
`- key: value` list item a field instead of swallowing it into the
first one's text.
"""
total = len(raw)
while index < total:
line = raw[index]
stripped = line.strip()
if not stripped or stripped.startswith('#'):
break
if (len(line) - len(line.lstrip(' '))) // 2 <= level:
break
text = _strip_comment(stripped)
if not text or _is_list_line(text) or _is_mapping_line(text):
break
body = body + ' ' + text
index += 1
return body, index
def _strip_comment(text):
"""One line with an unquoted trailing `# comment` removed.
A '#' only starts a comment where YAML says it does -- at the start of
the line or after whitespace, and never inside a quoted scalar -- so
`sheet_re: ^WP#\\d+` keeps its hash and `dims: [3", 3"]` keeps its inch
marks. `emit()` quotes any value containing a '#' (the plain-scalar
pattern excludes it), so nothing this module writes is touched here;
this is entirely for the files people edit by hand, where a comment on
the end of a `key:` line used to end the document.
"""
return _flow_scan(text, 0, 0, '')[0]
def _join_flow(raw_lines, index, first, offset, number):
"""One logical line from a flow sequence that wraps. Returns (text, idx).
`emit()` never writes one -- `is_flat` keeps a flow list to 66
characters on a single line -- so this shape only ever arrives by a
human hand-editing the file, which is exactly why it has to be read.
The continuation lines are joined with a single space, the way YAML
folds them, and their own indentation is discarded: the level of the
logical line is the level of the line the `[` opened on.
Raises `YamlKitError` when the bracket is never closed. THAT IS THE
DELIBERATE CHOICE, not an oversight: running off the end of the file
and returning the mapping built so far is precisely the silent
truncation this whole path exists to end. An unclosed `[` is a broken
file and the caller has to be told, not handed two thirds of a tree.
"""
body, depth, quote = _flow_scan(first, offset, 0, '')
parts = [body]
total = len(raw_lines)
while depth > 0:
if index >= total:
raise YamlKitError(
'unterminated flow sequence: the "[" opened on line {0} is '
'never closed, so the rest of the document cannot be read. '
'Nothing was returned -- a partial tree is how this bug hid '
'the last time. The line: {1!r}'.format(number, first[:70]))
nxt = raw_lines[index].strip()
index += 1
# A blank line or a whole-line comment inside a flow sequence is
# skipped, the same as anywhere else -- unless a quoted scalar is
# still open across the line break, where a '#' is just text.
if not quote and (not nxt or nxt.startswith('#')):
continue
body, depth, quote = _flow_scan(nxt, 0, depth, quote)
parts.append(body)
return ' '.join([p for p in parts if p]), index
def _flow_value_offset(text):
"""Where a flow collection opens on this line, or -1 for none.
Only a value that BEGINS with `[` (or `{`) opens one. YAML lets a plain
scalar carry a bracket in the middle of itself -- `note: see [1` is the
string "see [1" and nothing is unclosed about it -- so counting every
bracket on every line would swallow the rest of the document hunting a
`]` that was never a bracket at all.
`{` counts as an opener the same way `[` does -- see `_parse_flow_map`.
Joining it matters for the same reason: an unjoined one that wrapped
would end the enclosing mapping and lose the rest of the file, and
losing one value beats losing the document.
"""
body = text
offset = 0
if body[:2] == '- ':
rest = body[2:].lstrip()
offset = len(body) - len(rest)
body = rest
elif body == '-':
return -1
# `x[:1] in '[{'` is True for the empty string -- guard the head first.
if body[:1] and body[0] in '[{':
return offset
# `key: [...]`. Partitioned exactly the way _parse_dict partitions it,
# so this can never disagree with the parse that follows.
key, sep, rest = body.partition(':')
if not sep:
return -1
value = rest.lstrip()
if not value or value[0] not in '[{':
return -1
return offset + len(key) + 1 + (len(rest) - len(value))
# A quote character only OPENS a quoted scalar where a scalar can start:
# at the head of the value, or after a separator. Anywhere else it is
# ordinary text, which YAML agrees with and this project needs -- the
# shipped catalogue writes `dims: [3", 3", 3"]`, where every `"` is an inch
# mark. Toggling on those turns half the line into "a quoted string" and
# hides the closing bracket, which is the truncation bug again wearing a
# different hat. `it's` in an unquoted scalar is the same case.
_QUOTE_OPENS = ' \t[{,:'
def _opens_quote(ch, prev):
return (ch == "'" or ch == '"') and (prev == '' or prev in _QUOTE_OPENS)
def _flow_scan(text, start, depth, quote):
"""Walk one physical line of a flow collection.
Returns (body, depth, quote): `body` is the line with an unquoted
trailing comment removed, `depth` the running bracket depth, and
`quote` the quote character still open at the end of the line ('' when
none). Everything before `start` is left untouched -- that is the
`key:` half of the line, which the block grammar still has to see.
Quotes are tracked so a value that contains a bracket or a comma
("'P76, [spare]'") neither closes the sequence early nor splits, and so
a '#' inside a quoted scalar stays part of the scalar instead of
starting a comment. A '#' only starts a comment where YAML says it
does: at the start of the line or after whitespace.
"""
i = start
n = len(text)
while i < n:
ch = text[i]
if quote:
if ch == quote:
if quote == "'" and text[i + 1:i + 2] == "'":
i += 2
continue
quote = ''
elif quote == '"' and ch == '\\':
i += 2
continue
i += 1
continue
if _opens_quote(ch, text[i - 1] if i else ''):
quote = ch
i += 1
continue
if ch == '#' and (i == 0 or text[i - 1] in ' \t'):
return text[:i].rstrip(), depth, ''
if ch == '[' or ch == '{':
depth += 1
elif ch == ']' or ch == '}':
if depth > 0:
depth -= 1
i += 1
return text.rstrip(), depth, quote
def loads(text):
"""Alias for parse(), read as "load a string" the way json.loads reads."""
return parse(text)
def _parse_dict(lines, idx, indent, first=None):
result = {}
n = len(lines)
pending = [first] if first is not None else []
while True:
if pending:
text = pending.pop(0)
consume = False
else:
if idx >= n:
break
cur_indent, text = lines[idx]
if cur_indent != indent or _is_list_line(text):
break
consume = True
key, sep, rest = text.partition(':')
if not sep:
# Malformed line for this grammar -- skip rather than raise, so
# one stray line does not take the whole file down.
if consume:
idx += 1
continue
key = _parse_key(key)
rest = rest.strip()
if consume:
idx += 1
if _is_block_scalar(rest):
# `key: >-` and friends. emit() never writes one, so for a long
# time this parser did not read one either -- and a file with a
# block scalar in it did not fail, it TRUNCATED: the indicator
# was taken as the value and every more-indented line after it
# ended the enclosing mapping. `knowledge/catalogue.yaml` had one
# `note: >-` two thirds of the way down, and everything below it
# -- the whole rebar branch -- was invisible to every host test
# that read the shipped file, while the file itself parsed
# "fine". Hand-written knowledge files are exactly where a folded
# scalar gets used, so reading them is not optional.
#
# The subset: folded (`>`) joins with spaces, literal (`|`) with
# newlines. Chomping is read, because the default is NOT the
# obvious one and the difference is a whole reader
# disagreement: a bare `>` CLIPS, which keeps exactly one
# trailing newline, and only `>-` strips it. A `+` (keep all)
# is read as clip -- parse() has already dropped the blank
# lines that would tell us how many, and one is the nearest
# true answer available. What this cannot see, for the same
# reason, is a blank line or a `#` line INSIDE a block, so a
# literal block loses its paragraph breaks. Fine for a sentence
# in a knowledge file, which is all anyone writes here.
text_lines = []
while idx < n and lines[idx][0] > indent:
text_lines.append(lines[idx][1])
idx += 1
joiner = u'\n' if rest[:1] == '|' else u' '
body = joiner.join(text_lines)
if body and '-' not in rest[1:]:
body += u'\n'
result[key] = body
elif rest == '':
if idx < n and lines[idx][0] > indent:
child_indent = lines[idx][0]
if _is_list_line(lines[idx][1]):
child, idx = _parse_list(lines, idx, child_indent)
else:
child, idx = _parse_dict(lines, idx, child_indent)
result[key] = child
elif idx < n and lines[idx][0] == indent \
and _is_list_line(lines[idx][1]):
# A block sequence written at its KEY'S OWN indentation:
#
# notes:
# - the first note
# - the second
#
# Valid YAML, never what emit() writes (it indents a list
# one level in), and the THIRD construct found truncating a
# hand-edited file: the key took None, then the `- ` line at
# the mapping's own indent broke the loop and every enclosing
# one with it, so the rest of the document vanished. There is
# no ambiguity to weigh -- a bare `- ` item cannot be a
# sibling of mapping keys, so a list here belongs to the key
# immediately above it and to nothing else.
child, idx = _parse_list(lines, idx, indent)
result[key] = child
else:
result[key] = None
elif rest == '{}':
result[key] = {}
elif rest == '[]':
result[key] = []
elif _is_flow(rest):
result[key] = _parse_flow(rest)
else:
result[key] = _parse_scalar(rest)
return result, idx
def _parse_list(lines, idx, indent):
result = []
n = len(lines)
while idx < n:
cur_indent, text = lines[idx]
if cur_indent != indent or not _is_list_line(text):
break
if text == '-':
idx += 1
if idx < n and lines[idx][0] > indent:
child_indent = lines[idx][0]
if _is_list_line(lines[idx][1]):
child, idx = _parse_list(lines, idx, child_indent)
else:
child, idx = _parse_dict(lines, idx, child_indent)
result.append(child)
else:
result.append(None)
continue
remainder = text[2:]
idx += 1
if remainder == '{}':
result.append({})
elif _is_flow(remainder):
result.append(_parse_flow(remainder))
elif _is_mapping_line(remainder):
item, idx = _parse_dict(lines, idx, indent + 1, first=remainder)
result.append(item)
else:
result.append(_parse_scalar(remainder))
return result, idx
_BLOCK_SCALAR = re.compile(r'^[|>][+-]?\d*[+-]?$')
def _is_block_scalar(text):
"""`>`, `>-`, `|`, `|+`, `|2-` -- the whole of the indicator grammar."""
return bool(text) and bool(_BLOCK_SCALAR.match(text))
def _is_list_line(text):
return text == '-' or text.startswith('- ')
def _is_mapping_line(text):
"""'key: value' vs a scalar that happens to contain a colon.
Every key `emit()` writes is a plain field name -- the scalar() rules
quote anything else, including a string ending in ':' -- so a colon
following an identifier, unquoted, is a key/value pair and nothing else
reaching this parser can look like one.
"""
if text[:1] == "'":
return False
if ':' not in text:
return False
key = text.split(':', 1)[0]
return bool(re.match(r'^[A-Za-z0-9_]+$', key))
def _is_flow(text):
"""A complete `[...]` or `{...}` -- one line, by the time it gets here."""
return ((text[:1] == '[' and text[-1:] == ']') or
(text[:1] == '{' and text[-1:] == '}')) and len(text) >= 2
def _parse_flow(text):
"""A flow collection: `[a, b]` as a list, `{a: 1, b: 2}` as a dict."""
if text[:1] == '{':
return _parse_flow_map(text)
return _parse_flow_list(text)
def _split_flow(inner):
"""The top-level items of a flow collection's interior.
Splits on commas that are neither inside a quoted scalar nor inside a
nested collection: a comma in `[[1, 2], [3]]` or in `'P76, spare'` is
not a separator of the outer collection, and a bracket inside a quoted
scalar does not open one.
"""
parts = []
current = []
quote = ''
depth = 0
i = 0
while i < len(inner):
ch = inner[i]
if quote:
if ch == quote:
if quote == "'" and inner[i + 1:i + 2] == "'":
current.append("'")
i += 2
continue
quote = ''
current.append(ch)
i += 1
continue
if _opens_quote(ch, inner[i - 1] if i else ''):
quote = ch
current.append(ch)
i += 1
continue
if ch == '[' or ch == '{':
depth += 1
elif ch == ']' or ch == '}':
if depth > 0:
depth -= 1
elif ch == ',' and not depth:
parts.append(''.join(current).strip())
current = []
i += 1
continue
current.append(ch)
i += 1
tail = ''.join(current).strip()
if tail:
parts.append(tail)
return parts
def _parse_flow_list(text):
"""`[a, b, 'c, d']` as a list. Complete on one line by now.
A wrapped sequence has already been folded onto one line by
`_join_flow`, so this only ever sees a closed `[...]`.
"""
inner = text[1:-1].strip()
if not inner:
return []
return [_flow_item(p) for p in _split_flow(inner)]
def _parse_flow_map(text):
"""`{a: 1, b: 2}` as a dict.
THIS USED TO COME BACK AS THE RAW STRING, which is the module's other
silent-wrong-answer shape: nothing failed, the value simply was not a
mapping, and every key under it was missing from the tree without a
word. A real job knowledge file writes 29 of them
(`bar_notes: {SR356: OPPOSING PAIRS}`), and the config editor's
whole-file coverage check counts paths -- so those 29 read as 41
settings the editor could not see and refused to write beside.
An item with no colon is not a mapping entry and is dropped rather than
guessed at; `_parse_dict` treats a colon-less line the same way.
"""
inner = text[1:-1].strip()
result = {}
if not inner:
return result
for part in _split_flow(inner):
key, sep, rest = _split_flow_pair(part)
if not sep:
continue
result[_parse_key(key)] = _flow_item(rest.strip())
return result
def _split_flow_pair(part):
"""One `key: value` of a flow mapping, split at the first free colon."""
quote = ''
i = 0
while i < len(part):
ch = part[i]
if quote:
if ch == quote:
if quote == "'" and part[i + 1:i + 2] == "'":
i += 2
continue
quote = ''
i += 1
continue
if _opens_quote(ch, part[i - 1] if i else ''):
quote = ch
i += 1
continue
if ch == ':':
return part[:i], ':', part[i + 1:]
i += 1
return part, '', ''
def _flow_item(text):
"""One item of a flow collection: nested collection, or scalar."""
if text == '{}':
return {}
if text == '[]':
return []
if _is_flow(text):
return _parse_flow(text)
return _parse_scalar(text)
def _unquote(text):
"""A quoted scalar's content, or None when the text is not quoted.
Single quotes are what `emit()` writes and `''` is the escape. DOUBLE
quotes are read too, for the same reason everything else in this
pre-pass is: a person writing a knowledge file by hand uses them, and
a value the two readers disagree about is a value the config editor
refuses to write beside. The escape handled is the one that matters
for this subset -- a backslash before a quote or a backslash -- not the
whole of YAML's double-quoted grammar (\\u, \\n and friends are left
alone rather than half-implemented).
Inch marks are safe: `3"` neither starts nor ends the way this needs.
"""
if len(text) < 2:
return None
if text[0] == "'" and text[-1] == "'":
return text[1:-1].replace("''", "'")
if text[0] == '"' and text[-1] == '"':
return text[1:-1].replace('\\"', '"').replace('\\\\', '\\')
return None
def _parse_key(text):
text = text.strip()
inner = _unquote(text)
return text if inner is None else inner
def _parse_scalar(text):
text = text.strip()
inner = _unquote(text)
if inner is not None:
return inner
if text == 'null':
return None
if text == 'true':
return True
if text == 'false':
return False
if re.match(r'^[+-]?\d+$', text):
return int(text)
if re.match(r'^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$', text) and \
('.' in text or 'e' in text or 'E' in text):
try:
return float(text)
except ValueError:
pass
return text