WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Line endings. Issue #18: on a Windows clone with core.autocrlf=true the
# served static files came out of the checkout with CRLF, while the test that
# proves /app.js is PA's single copy read the same file in text mode (which
# folds CRLF to LF) -- so the bytes the server sent and the bytes the test
# expected differed on every Windows clone and on no Linux one.
#
# Everything text is normalised to LF in the repository (the index already
# was: `git ls-files --eol` showed 381 i/lf and 21 i/-text the day this was
# added, so `text=auto` renormalised nothing). The files pa_web / ba_web hand
# to a browser byte-for-byte are pinned to LF in the WORKING TREE too, so a
# served asset is the same bytes on every machine.
* text=auto
tools/pa_web/static/** text eol=lf
tools/ba_web/static/** text eol=lf
+38
View File
@@ -0,0 +1,38 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install
# requirements.txt is the single source of truth for host-side deps.
# PyYAML fixes collection (ticketpage._read_yaml falls back to it off
# IronPython; without it eight test files die before running). pymupdf
# is also required, or test_libindex.py's 77 tests silently SKIP via
# importorskip instead of running -- a green run that tested less.
run: pip install -r requirements.txt
- name: panellift tests
run: python -m pytest panellift -q -p no:cacheprovider
- name: vibe tests (the Revit-free half of the extension)
run: python -m pytest WillTools.extension -q -p no:cacheprovider
- name: Syntax smoke (extension + tools + panellift)
run: python -m compileall -q WillTools.extension tools panellift/src
- name: Scrub worktree with the committed generic sample terms
# Real client names never leave the local machine: .scrub-terms is
# gitignored, so CI runs the generic .scrub-terms.example instead.
run: python tools/scrub.py --worktree --terms .scrub-terms.example
+96
View File
@@ -0,0 +1,96 @@
# local Revit/bridge state
.bridge-sessions.log
# one per port: "the last request on this bridge never came back", written by
# tools/rvt.py so the next call drains the channel before it trusts an answer.
.bridge-timeout-*.json
_ticketqc/
# build output
dist/
build/
*.egg-info/
# python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# editors / local tool settings
.vscode/
.idea/
.claude/settings.local.json
# scratch
scratch/*.py
!scratch/scratch.py
# _scratch is where the agents work: probe scripts, bridge responses, run logs.
# It is not the same directory as scratch/ above, and the rule there never
# covered it -- 745 untracked files accumulated in it carrying client names,
# job names and absolute user paths, one `git add -A` from the history. Work
# that is meant to be kept gets moved OUT of here into its real home.
_scratch/
# Real exported tickets, kept locally to check against. They carry the job
# name and the client's name in the title block, and scrub.py cannot read a
# PDF -- the gate would pass them straight through. Never commit them.
sample data/
*.pdf
*.log
# client-name denylist, must never be committed
.scrub-terms
# QNA: 约定进仓库,问答内容不进
QNA/*
!QNA/README.md
# The knowledge folder is the OTHER repository's territory. It sits inside this
# working tree for convenience during development and is tracked by KNOWLEDGE,
# never by this one -- that split is the whole reason there are two repos: this
# one can be shown to anybody, that one cannot.
#
# catalogue.yaml is the exception and is deliberately tracked here: it is the
# public layer, published manufacturer data, and it says so in its own header.
knowledge/plant/
knowledge/job/
knowledge/_archive/
# ...and the SYMLINK spellings: on the host machine these three are links into
# the private KNOWLEDGE checkout (git matches a trailing-slash rule against
# directories only, so the link itself would show up untracked -- same lesson
# as knowledge/library below, 2026-09-05).
knowledge/plant
knowledge/job
knowledge/_archive
# knowledge/library/ is the PDF drop and its ledger -- manuals, manufacturer
# catalogues, datasheets, plus the record of what has been mined out of each
# and where every fact landed. Same territory as the three lines above and
# excluded for the same reason: it is KNOWLEDGE's, never this one's.
#
# The PDFs themselves are already caught by `*.pdf`, and that rule exists
# because scrub.py cannot read a PDF -- a title block carrying the job and the
# client goes straight through the gate. The ledger and the extraction notes
# beside it are worse, not better: quoting the source is their whole job, so
# a name in them is certain rather than possible. The FOLDER is what is
# excluded, not the extension, so a note, an index or a scratch transcript
# dropped in there is covered the day it is written and not the day somebody
# remembers to add a rule for it. `scrub.py --private` is what reads it.
# Both spellings on purpose: with the slash git matches only a real directory,
# and on this machine `knowledge/library` is a SYMLINK to the private repo's
# checkout -- the link itself showed up untracked (2026-09-05) and one `git add`
# would have committed a path into the owner's home directory.
knowledge/library/
knowledge/library
# knowledge/MC/ is a second producer's document drop, same territory as
# library/ above and excluded whole-folder for the same reason: the files
# carry the producer's name in their own filenames, which *.pdf alone does
# not cover (there is a spreadsheet in there today).
knowledge/MC/
# machine-local install choices (tools/setup_machine.py); .setup-opts.example is the tracked template
.setup-opts
# CPython virtualenv on the Debian dev box
.venv/
.gitea-issue-handler.lock
+30
View File
@@ -0,0 +1,30 @@
# Terms that must never appear in the repo, one per line. Case-insensitive.
#
# This is the GENERIC sample file, committed so CI can run the scrub without
# ever seeing a real client name. It is also the copy-you-fill-in for local
# use: copy it to .scrub-terms (gitignored) and REPLACE the sample parameters
# below with your real client / job / plant names.
#
# .scrub-terms is gitignored on purpose: the real list of owner/job names must
# not itself become the thing that publishes them.
#
# List anything that identifies an owner or a job: client name, building
# owner, project name, site address, job number, plant name, Windows
# usernames that appear in scraped output.
#
# When you need to refer to one in code or a comment, write a placeholder
# instead of deleting the sentence (e.g. a client -> sample_client, a job ->
# sample_job, a plant -> sample_plant). Different real clients change only the
# parameters below -- one file per parallel job, never hard-coded in code.
#
# Product codes are not client information and stay as they are: design
# numbers (WPA2.0), piece marks (WPA170), and hardware part numbers
# (LPA4T512) are naming conventions, not identities.
# ---- GENERIC SAMPLE TERMS (CI-safe). Replace in local .scrub-terms. ----
SAMPLE_CLIENT_MOTORS
SAMPLE_JOB_PROJECT_2026
SAMPLE_PLANT_WEST
SAMPLE_USER_ABC123
CLIENT1_SAMPLE
CLIENT2_SAMPLE
+31
View File
@@ -0,0 +1,31 @@
# tools/setup_machine.py opt-file -- TEMPLATE, not read.
#
# This is `.setup-opts.example`. Only `.setup-opts` (no `.example`) is ever
# read, and setup_machine.py never creates that file itself -- declaring an
# install choice is the owner's to make, not a default this script picks for
# you. Copy this file to `.setup-opts` beside the repo root and edit it if
# you actually want to declare one.
#
# FORMAT -- one `<step>: <mode>` per line. `#` comments and blank lines are
# ignored. A step name this script does not know, an unstated value
# (`panellift:` with nothing after the colon), or a mode a known step does
# not accept are all reported as FAILURES by `--check` and by a full run --
# an opt file only stays trustworthy if a line nobody is honouring any more
# is loud, not quiet.
#
# THE ONLY STEP OPT-ABLE TODAY:
#
# panellift: dev-mode
#
# Meaning: "panellift is not `pip install -e`d on purpose, while the package
# is still being shaped. PYTHONPATH=src is the supported route on this
# checkout, not a workaround." With this declared:
# - `python tools/setup_machine.py --check` reports the installed-CLI step
# SKIP ("dev mode, per .setup-opts") instead of a FAIL that never clears.
# - `python tools/setup_machine.py` (full run) skips `pip install -e
# panellift` and instead runs `PYTHONPATH=src python -m panellift --help`
# to prove the route this checkout actually uses still works, and says so.
#
# Delete the line (or the whole file) once panellift is pip-installed for
# real -- then the ordinary "installed, no PYTHONPATH" check verifies that
# directly again, which is the stronger proof.
+32
View File
@@ -0,0 +1,32 @@
# AGENTS.md
**Read [CLAUDE.md](CLAUDE.md) first and treat it as binding.** It is written
for whichever agent is driving this repo, not for one vendor: the live-Revit
bridge, the IronPython 2.7 rules, the unit conventions, and the rules that
exist because breaking them killed Revit. Nothing in it is restated here,
because two copies of a hard rule drift and the copy that drifts is the one
that takes the model down.
Then read [docs/handoff.md](docs/handoff.md) for the state of the work: what
is done, what is knowingly approximate, and what to pick up first.
## Harness notes
Instruction files are loaded by the harness's `agent-instructions` plugin,
which reads `AGENTS.md` and `CLAUDE.md` in each directory from the project
root down. Two files with the same content collapse to one; two with
different content both load. That is why this file is a pointer and not a
copy — it costs a few lines and leaves CLAUDE.md as the single source.
**The plugin ships disabled in the default `web` profile.** If neither file
appears in the session's context, enable it in
`~/.dsh/profiles/web/cordis.patch.yml`:
```yaml
- id: agent-instructions
disabled: false
```
Check what is actually composed with `dsh --profile web --dump-config`. An
agent working here without CLAUDE.md loaded will reload pyRevit sooner or
later, and that ends the Revit session.
+899
View File
@@ -0,0 +1,899 @@
# pyrevit-dev — you are wired to a live Revit session
You were launched from a button inside Revit. **Revit is running right now**,
with a model open, and you can read and modify it directly. Use that — do not
write Revit code you have not run.
Engine: **IronPython 2.7.12**. The Revit and pyRevit versions differ between
the machines this is developed on (2025.4 / pyRevit 6.5.3 on one, 2024.3 /
pyRevit 5.0.1 on the other), so ask rather than assume — `rvt.py status`
answers it — and guard any API name that moved between versions. Two already
known: `BrowserOrganization.GetFolderFields()` does not exist on the older
binding, and `Dimension.Curve` throws on drafting views.
---
## 1. Talking to the running Revit
```bash
python tools/rvt.py status # which model is open
python tools/rvt.py exec -c "print(doc.Title)" # run a line inside Revit
python tools/rvt.py exec somefile.py # run a file inside Revit
python tools/rvt.py exec somefile.py --tx "名字" # wrap in a transaction
python tools/rvt.py sel # what the user has selected
python tools/rvt.py collect OST_Walls --limit 20
python tools/rvt.py params 123456 # every parameter of one element
```
`exec` returns `{ok, stdout, result, error, traceback}` — real stack traces, so
iterate until it works. Snippets get `doc`, `uidoc`, `uiapp`, `view`, `DB`,
`UI`, `vibe`, and the common Revit types pre-imported. Assign to `result` (or
make the snippet a single expression) to send a value back.
If `status` cannot connect: Revit is closed, or a **modal dialog** is open in
Revit — handlers run in Revit's API context and cannot fire behind a dialog.
Say so rather than guessing.
### Every answer is checked against the request that asked for it
Since 2026-09-01 `rvt.py` stamps every request with an id (in the JSON body,
GET routes included — request headers never reach handler code either),
`startup.py` echoes it back, and an answer carrying a **different** id is
refused outright: both ids named, nothing returned. The reason is that pyRevit
serves every route through **one shared response slot** —
`routes/server/server.py`'s module-level `REQUEST_HNDLR` / `EVENT_HNDLR`, whose
single `_response`/`_done` pair every connection thread resets, waits on and
reads — so a client-side timeout cancels nothing, and the abandoned request's
late answer is handed to whoever asks next. Measured that day, twice: one sweep
call timed out at 300s, and the `doc.Title` calls after it came back with the
sweep's progress string, which the sweep printed as the document title and an
agent read as the model having changed underneath it. The files on disk were
right; only the attribution was wrong, and **a response from the wrong request
is indistinguishable from a valid answer**. So a timeout is now an explicit
could-not-judge (exit 1, `COULD NOT JUDGE`, no value) that latches the channel
in `.bridge-timeout-<port>.json`; the next call drains the stale answer out of
that slot with a self-identifying probe before it sends anything real, and
refuses to send at all if the channel cannot be proven clean. An in-Revit half
that echoes **no** id — which is every session until Revit is restarted after
this lands — is warned about on stderr and flagged `_unverified` in the body
rather than refused, because a restart is the only way to load the new half and
refusing would take the bridge down for every tool in the meantime; pass
`--strict` (or set `WILLTOOLS_BRIDGE_STRICT=1`) to refuse instead.
### The bridge can now REFUSE, and a refusal is not a fault
Since 2026-09-01 every route — `/status` included — refuses outright when the
open document's title is on `bridge.refuse_titles` in the job or plant layer.
That is the owner's *不要碰我的live* written as code. The answer comes back
`{"ok": false, "refused": true, "doc_title": ..., "pid": ..., "error": "REFUSED: …"}`
and it means **you reached the live session, not the sandbox**. Do not retry
it, do not work around it, and do not go looking for another way in.
**The owner reversed that policy the same day, for the model it protects.**
A title on `bridge.readonly_titles` instead is served normally through the
bridge — including `/exec` — but only for reads: the iron rule is *铁律是在
live里面只读不写*, and `/exec` refuses outright when the request carries a
transaction or the snippet matches known write-capable Revit API surface,
coming back the same `refused: true` shape as above with a one-line reason
naming the title and the rule, never a silent no-op.
Why it can happen when you did nothing wrong: pyRevit walks the routes port
**up** from the configured one past every Revit already holding one, so 48884
is "whichever Revit started first" rather than the session you meant. With two
sessions open the sandbox is usually on **48885** — and its `doc.Title` is
identical to the live model's, because the snapshot deliberately keeps the
title so the job folder does not move. `rvt.py` reads `WILLTOOLS_BRIDGE_PORT`;
set it, or let `tools/snapshot_cycle.py` find the right session for you. The
whole mechanism, including how a snapshot proves it is a snapshot, is in
`docs/snapshot-cycle.md`.
### Never reload pyRevit. Restart Revit instead.
This is the single most important rule here, and it is not a style preference —
it kills Revit outright.
pyRevit's routes request handler is an external event handler owned by the
scripting engine that first loaded it. `reload_pyrevit()` tears that engine
down but leaves the handler and the HTTP server pointing at it. **The next
bridge request after any reload executes against a dead engine and takes Revit
down** — an unhandled exception on a non-main thread, which Revit cannot catch.
Measured on 2026-08-14: 11 clean requests, one reload, the 12th request fatal.
It happened twice.
Nothing inside Revit can defend against this — our code never gets to run. So:
- There is no `/reload` endpoint, and `rvt.py reload` refuses.
- `startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load.
- `rvt.py` reads that log before **every** request and hard-refuses if it sees
two loads under the same Revit process.
If `rvt.py` tells you the bridge is unsafe, do not work around it, do not curl
the endpoint directly. Ask the user to **restart Revit**. A restart picks up new
buttons exactly as well as a reload does, and a fresh process is safe.
If `rvt.py` warns that there is no session log while Revit is clearly running,
the tripwire itself is broken — say so and do not reload.
---
## 2. Layout
```
pyrevit-dev/
├── CLAUDE.md ← you are here
├── INSTALL.md deploying to another machine / Revit version
├── tools/rvt.py client for the live bridge
├── tools/package.py builds dist/willtools-<ver>-<date>.zip
├── tools/sweep.py check every WP assembly in the open model
├── tools/snapshot_cycle.py make a timestamped detached copy, cycle a
│ Revit onto it, run a batch, close it
├── docs/snapshot-cycle.md …and why the detach is not done in-session
├── docs/revit-api-cheatsheet.md working Revit 2025 patterns — read when stuck
├── docs/interference.md why the clash check is shaped the way it is
├── docs/lift-check.md the handling check, and what it will not answer
├── docs/handoff.md open engineering questions, the last sweep,
│ what to pick up first
├── knowledge/catalogue.yaml published hardware ratings (ships with the tool)
├── panellift/ PCI Ch.8 handling checks — CPython, own suite
│ ├── src/panellift/cli.py `python -m panellift job.json -o report.html`
│ └── src/panellift/layout.py where the inserts should go instead
└── WillTools.extension/
├── startup.py registers the bridge (no UI)
├── lib/vibe/
│ ├── helpers.py q() p() set_p() tx() ft() mm() selected() ...
│ ├── compat.py text_type / string_types — py2+py3 shim
│ ├── designspec.py evidence scrape, spec schema, spec io
│ ├── aiextract.py the AI reader — NOT the extractor any more
│ ├── knowledge.py knowledge.yaml → defaults, family rules
│ ├── configdump.py all five layers flattened into one file
│ ├── configmodel.py what may be edited, its provenance, the splice
│ ├── liftexport.py assembly → panellift job file, and runs it
│ ├── ticketqc.py reads tickets, the comparison checks
│ ├── ticketchecks.py the checks that need arithmetic
│ └── ticketqc_run.py the shared engine behind TicketQC and
│ ReinforcingQC (a `mode` parameter apart)
└── WillTools.tab/ the ribbon — "Folder names are the UI"
│ below is the whole of how it is built
├── Tools.panel/ Modeling.pulldown/ — ReinforceSuggest,
│ ReinforcingQC, TicketQC — plus
│ SnapshotClose as the one remaining large
│ icon (reorganised 2026-09-05; this panel
│ used to hold all four as large icons)
├── Setup.panel/ Config.pulldown/ — one large button with a
│ dropdown arrow: ProjectConfig,
│ ExtractDesign, SelfCheck, JobRecon,
│ Takeoff
├── Design.panel/ DesignHub.pulldown/ — PanelLiftCheck and
│ PanelAnalysis (both finished, both grouped
│ here on the owner's own instruction), then
│ BeamAnalysis, ConnectionAnalysis,
│ GravityLoadChase and LateralExtract, all
│ four unfinished and titled "(WIP)"
└── Lab.panel/ GodMod — the button that launched you.
```
**`aiextract` no longer extracts anything.** Design Extract was rewritten to
read the scrape in-process with `ticketqc`'s regex/geometry parser — its own
header says "no AI call, no subprocess, nothing that leaves this process" —
and it imports `designspec`, `ticketqc`, `knowledge` and `specsave`, not this.
The module is kept, and its only live callers are `find_claude()`: Self Check
probes with it to say whether the CLI is on this machine, and the GOD_MOD
button uses it to find the CLI it launches. The prompt, the `claude -p` spawn
and the cross-check are all still there and nothing calls them. Spec files
written before the rewrite can still carry `source.extractor: 'opus'` (or the
older `'claude-code'`); the mechanical reader stamps `'regex'`. Nothing
branches on which of the three it finds — the field is displayed, in Design
Extract's own spec table, and never acted on — so anything touching it has to
tolerate all three. Do not describe this module as the extractor.
**`panellift` is not importable from Revit and that is deliberate.** It is
CPython — dataclasses, typing syntax — and the extension is IronPython 2.7.
The two talk through a JSON job file, so the engineering can be tested without
a licence, a model, or Windows. `vibe.liftexport` is the whole Revit side of
that boundary: it measures the piece, writes the job, shells out to CPython,
and reads the summary back. Anything about *mechanics* belongs on the far
side of that line; anything about *this model* belongs on this side.
Most of `vibe` does not touch Revit at module scope either — only at the top
of individual functions that need a live `doc` (`knowledge.load()`,
`liftexport.export()`, and anything that reaches for `vibe.designspec` /
`vibe.ticketqc`, which import the Revit API directly). That leaves real logic
testable with no model and no licence, the same way `panellift` is.
`WillTools.extension/tests/` is 34 files covering about thirty of the `vibe`
modules — `ticketpage`, `knowledge`, the host-side half of `ticketchecks`, the
`configmodel` / `configform` / `configpage` set, the `reinforce*` and
`takeoff*` sets, the part of `liftexport.py` below `export()`, and more — run
straight on host CPython:
```bash
cd WillTools.extension && python -m pytest
python -m pytest # from the repo root: this suite + panellift
```
**Run it; do not quote a count from here.** The count in this paragraph has
been wrong every time anybody has checked it — the suite grows daily, and on
2026-08-31 the root total moved twice in one afternoon while several agents
were working. For scale only, and only that: it was roughly 1,800 under
`WillTools.extension` and roughly 2,100 from the repo root that day. The root
run also sweeps `_scratch/`, which is gitignored agent working space, so its
total moves for reasons that have nothing to do with the tool.
**One test fails on a configured machine and it is the test that is wrong.**
`tests/test_reinforceoffline.py::test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one`
calls `resolve_index(None, None, None)` expecting the "no jobs root" refusal,
but `resolve_index` falls back to the ambient `WILLTOOLS_JOBS`, so on any
machine that actually has the tool set up it gets the "which model?" refusal
instead. It passes under `env -u WILLTOOLS_JOBS`. Nothing in the tool is
broken; the test needs to clear the variable. Measured 2026-08-31, not yet
fixed — so a green suite here is everything passing **except that one**, and
one failure is not automatically your change.
`WillTools.extension/tests/conftest.py` is how: it loads a module by path
instead of `import vibe.whatever`, because `vibe/__init__.py` pulls in the
Revit API the moment it runs. Read the docstring there before adding a test
for a new module — it explains exactly which shape of code this can and
cannot reach, and `ticketqc.py` / `ticketchecks.py` are the standing example
of code that cannot: they import `Autodesk.Revit.DB` at module scope, not
just inside a function, so freeing them needs more than moving an import.
Ticket-QC data lives beside the **model**, not here:
`<model folder>/_ticketqc/{evidence,specs,log}/` + `knowledge.yaml`.
Knowledge is read in **five layers**, each overriding the one before:
| | |
| --- | --- |
| `knowledge.DEFAULTS` | what the code assumes — in this repo |
| `knowledge/catalogue.yaml` | published hardware ratings — in this repo, identifies nobody |
| `%APPDATA%\WillTools\knowledge.yaml` | what this **plant** always does — naming conventions, casting-bed rails |
| `<model folder>/_ticketqc/knowledge.yaml` | what this **job** does — parameter names, tolerances, per-job overrides |
| the **project config file** | what the engineer set, wherever they put it — opt-in, and it wins |
Only the first two are in the repository, and that is the point: between them
the other three are a full description of one office's template. Set
`WILLTOOLS_KNOWLEDGE` to put the plant layer on a share.
`knowledge.sources(doc)` lists the layers; Self Check prints them.
**Where the layers get their facts from: `knowledge/library/`.** It is a drop
folder for source PDFs — manuals, manufacturer catalogues, datasheets — plus a
ledger with one row per document, keyed by content hash, recording what was
extracted from it and **which file and key path each fact landed in**. The
folder is gitignored as a folder (not merely by `*.pdf`), because the ledger
and the extraction notes quote their sources verbatim and `scrub.py` cannot
read a PDF; like `knowledge/plant/` it is the other repository's territory, and
`scrub.py --private` is what reads it. **Idle capacity drains it by
`knowledge/library/RUNBOOK.md`** — inventory, extract with a page cite and a
confidence mark on every fact, **land**, verify, report. The landing step is
the point and it is the one that gets skipped: a 929-page manual was once mined
into ten notes and a 25-fact audit of which almost nothing became YAML, and the
owner ruled on it — *"提取出来以后没有写入。今天不要再犯这个错误"*. A document
read but not landed is a debt, not progress, and the ledger says so in the
status column. `python tools/library.py` lists the folder against the ledger.
**Extraction notes are clues, never sources — a number lands only off the
page itself.** Measured 2026-08-31: a cheap-model extraction pass over one
document drop carried a **42% page-error rate** on independently sampled pages
— inverted inequalities, misidentified parts, specs blurred into vagueness —
and the landing pass then caught three more errors the audit itself had
missed, including a one-row table shift and a column read under the wrong
heading. Three stages, none may collapse into another:
1. **Extract** with a page cite on every fact (cheap model is fine — speed is
what it buys).
2. **Audit independently** — a different agent samples pages against the note.
A sub checking its own family's work reported zero errors on the same
material where the independent auditor found the 42%; self-audit is not
audit.
3. **Land off the page** — any NUMBER moving from a note into a YAML layer is
re-read against the rendered source page by the landing agent, cited
`# SOURCE: <doc> p.N, verified <date>`. A number that cannot be confirmed
lands as `null` with the reason and an open question — never as the note's
value, never as a plausible reconstruction.
**Then run `python tools/knowledgelint.py`.** Landing is not finished until it
passes. pyRevit's YAML reader is strict — one mapping key stated twice raises
`Duplicate key` and it returns *nothing* — and a layer that fails to load looks
exactly like a layer that agrees with the defaults. Measured 2026-09-01: one
nested duplicate in a plant file discarded that whole 255KB layer inside Revit,
every parameter role came back None, the sweep enumerated 0 assemblies, and
every report rendered normally. The lint strict-parses every layer on this
machine and exits 1 on any that will not load; `knowledge.config_problems()`
and Self Check now say so too, but only after the file has already been
written.
Where a table carries its own internal identity (a recommended value derived
from two measured columns, a series that must be monotonic), assert the
identity across the whole table at landing — it is the cheapest error
detector there is, and it is what turned "one value off by one" into "the
whole block is shifted a row".
**`knowledge.load()` reads the job file twice, and that is not a bug.** The
job layer holds `job.plant`, which names *which plant folder* the plant layer
is read from — but the plant layer merges **before** the job layer, so by the
time the name is in the merged tree the plant file has already been read from
the wrong path. The dependency runs backwards through the merge order and no
reordering fixes it. So `plant_id()` reads the job file (and the config file
above it, which can override the same key) once on its own, before anything
merges, purely to answer "which plant" — then the ordinary five-layer merge
runs. **Do not collapse this into one pass.** Deleting the pre-pass makes the
tool read the machine-wide plant file while the job file plainly says which
plant it is for, and every report still looks completely normal.
`_plant_layer_stale` is the tripwire for exactly that, comparing the plant the
file was chosen by against the plant the merged tree ends up naming. Same
discipline as `_config_missing`: a named plant whose file is absent sets
`_plant_missing` and never falls back to the machine-wide file.
The last layer is what the **Project Config** button writes. A model has one
only once somebody has pressed that button, and the file goes wherever they
choose — so a one-line pointer at `<model>/_ticketqc/active-config.txt` records
where it is. It is deliberately last: it is the layer a human edited most
recently and by hand.
Two rules around it, both learned the hard way in other tools:
- A pointer to a file that is **gone** must never fall back quietly. The run
would use a configuration nobody chose and look completely normal.
`load()` marks it `_config_missing` and `knowledge.config_problems(doc, know)`
turns that into a sentence a button can refuse on.
- The file opens with `decisions:` — every setting that is the engineer's to
make, gathered from wherever it is really read and put above everything
else, each under its own comment. Where the reports go, whether they open,
the prestress loss, the transfer length, and the per-stage rigging mode.
`knowledge._merge_decisions` maps every one of them back on load.
**A file where editing the obvious place does nothing is a trap**, so that
mapping has to exist in both directions or the section is decoration.
**Write for both Pythons.** pyRevit picks the engine and this extension ships
to machines whose engine we do not control, so on top of the IronPython 2.7
rules below: no `unicode` / `basestring` (use `vibe.compat`), and guard any
Revit API name that moved between versions. `python tools/package.py` and
`INSTALL.md` cover the rest.
Folder names are the UI: `Foo.tab / Bar.panel / Baz.pushbutton / script.py`,
plus `Bar.panel / Thing.pulldown / Baz.pushbutton` for a pulldown. Copy
`GodMod.pushbutton` as a structural template (not its contents).
`*.stack` also exists and this tab used two of them until 2026-08-30. Both are
gone: a stack is up to three *small* buttons in one panel slot, and the owner
wanted the set-up commands off the panel entirely rather than shrunk on it.
Prefer a `.pulldown` — one large icon plus an arrow, unlimited rows, and the
rows are free to carry readable titles because a row is text beside a 16px
icon rather than a ribbon slot.
**Which name renders, and from where** — this is not uniform and guessing it
wastes a Revit restart:
| | |
| --- | --- |
| tab | the **folder** name. `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)`; a `title:` in a tab bundle is ignored. |
| panel | the **folder** name, same reason (`create_ribbon_panel(panel.name)`). Rename the folder to rename the panel — spaces are fine, pyRevit ships panels with them. |
| stack | nothing renders. The folder name is only what the panel's `layout:` refers to it by. |
| pulldown | `bundle.yaml`'s **`title:`** — unlike a stack, a pulldown container really does read one. `genericcomps.GenericUIComponent._read_bundle_metadata` sets `_ui_title` from it and `uimaker._produce_ui_pulldown` passes that to `create_pulldown_button`. With no `title:` it falls back to the folder name. |
| button | `bundle.yaml`'s **`title:`**, and nothing else. `__title__` in the script is the fallback pyRevit reads only `if not self.meta`, i.e. only for a button with no `bundle.yaml` at all — keep it in step anyway, or the old name comes back the day a bundle file is trimmed. |
**Order is `layout:` in the container's own `bundle.yaml`**, a list of child
folder names (minus their `.panel` / `.stack` / `.pushbutton` suffix), read by
`genericcomps.GenericUIContainer.parse_layout_metadata`. The old `_layout`
file is gone in pyRevit 5.0.1 — do not write one, nothing reads it. Two things
about that list are worth knowing before it bites:
- **A child not listed is not created.** Not misordered — absent. Add a
button, add it to the layout, or it silently never appears.
- `"---"` inserts a separator and `">>>"` a **slideout** (everything after it
hides behind the arrow at the panel's bottom-right). Both must be quoted —
a bare `>` starts a folded block scalar and the panel fails to parse — and
neither may be the last entry, because `__iter__` only honours a marker
whose index is below the last one.
**A `.pulldown` needs its own `icon.png` or it renders blank.** A pulldown is a
component like any other — `genericcomps` resolves an `icon.png` for it, and
`_produce_ui_pulldown` hands that to `create_pulldown_button`. When there
isn't one, `ribbon._create_button_group` catches the icon error and only
`mlogger.debug`s it, so you get an empty ribbon slot and no message anywhere.
`python tools/make_icons.py` draws both of the tab's pulldown icons; run it
and **read its output**, because it prints "skipped, no such component" for a
path that has moved rather than failing.
**Unfinished tools never sit on a panel with finished ones.** Standing owner
instruction, 2026-08-30. `Design.panel` is where they go — a panel of its own
whose single button is the `DesignHub` pulldown, so nothing half-built is on
screen until somebody opens it; the `(WIP)` suffix on the title is what names
them once they do. (Until 2026-08-30 this was the slideout on `Lab.panel`.
Same rule, better door: a slideout arrow is easy to miss and shares its panel
with a working button, which is the thing the rule is against.) `DesignHub`
also holds two FINISHED rows today, PanelLiftCheck and PanelAnalysis — the
owner grouped them here on 2026-09-05 because he wants the analysis tools
together, not because either stopped working; see that pulldown's own bundle
comment.
A button that is finished but not wanted on screen used to move to
`WillTools.extension/parked/`, outside every `*.tab`. **That folder is gone**
as of 2026-08-30 — JobRecon and Takeoff, its only residents, came back onto
the ribbon inside `Config.pulldown`. A pulldown row costs no panel space, so
there is now somewhere on the ribbon to put a button nobody wants underfoot,
and parking has no job left. Do not recreate `parked/` without asking; if a
button really must leave the ribbon, taking it out of the parent's `layout:`
already stops it being built.
Titles are **short and two lines**: a large ribbon button is as wide as its
widest line, which is how "Reinforcing Suggestion" came to dwarf everything
next to it. `title: "Ticket\nQC"` — double quotes, because YAML only honours
`\n` inside them. Keep each line under ~9 characters. Rows inside a pulldown
are the opposite case — text beside a 16px icon, in a dropdown that grows to
fit its longest row — so those stay **single-line and may say what they
mean** (`Project Config`, `Design Extract`). A `\n` in a pulldown row has
nowhere to go.
**No button carries hover text.** The owner asked for it twice, so it is now
a test, not a convention: `bundle.yaml` gets no `tooltip:` and no `author:`,
and `script.py` gets **no module docstring at all**. Document a button in `#`
comments under the coding cookie instead — pyRevit cannot render those, and
nothing is lost. `tests/test_button_tooltips.py` fails and names the button
if any of the three comes back. The trap it exists for: pyRevit reads the
docstring only `if not self.meta` (no `bundle.yaml`), so a docstring can sit
harmless for months and become hover text the day a bundle file is trimmed
or a button ships without one.
pyRevit still appends `Bundle Name:\n<Name> (pushbutton)` to every tooltip in
`uimaker._make_button_tooltip`, with no setting to suppress it. Those two
lines are the floor; do not try to beat them by editing the pyRevit install.
**Editing an existing `script.py` needs no reload** — pyRevit re-reads it on
every click, so most iteration costs nothing. **Adding or renaming** a button
needs a **Revit restart** (see the rule above — not a pyRevit reload).
---
## 3. Hard rules
**Dry-run and self-test before the owner touches it.** Owner, 2026-09-02:
"以后写完代码先测试,做dry-run,能修的bug修完以后再让我来测试." Finishing a
change is not "the code is written" -- it is "I ran it, on a real input,
saw no garbage, and fixed every bug I could before handing it over." For a
tool that reads the live model, that means: export a real assembly to a
deck through the bridge (read-only), run the offline solver on it
(`panellift`), read the output, and fix what breaks -- BEFORE asking the
owner to press the button. His time is the last check, not the first. A
handoff that says "try it" without a clean dry-run behind it wastes the one
reviewer who cannot be replaced. This sits beside "report outcomes
faithfully": a dry-run that still errors is reported as such, not smoothed
over.
**A green host suite is not a live dry-run, and a clean merge is not one
either.** Added 2026-09-03, after Panel Analysis reached the owner broken twice
in a day. The form passed all 127 `panelanalysis` host tests and pre-filled
*nothing* live: it read mark and f'c only through plant `knowledge` roles, and
on that job the plant mapped those roles to nothing, so every field came up
blank,
the header said "unnamed panel", and the project / client / date fields the
owner expected did not exist at all. Host tests check the schema; they cannot
see that a form reads no real value from a real model. So anything that READS A
MODEL or renders a FORM is dry-run **through the live bridge, read-only**, with
the actual values it recovered confirmed present, before it is handed over --
the button press is the owner's last check, never the first. The same holds for
code that arrives by MERGE from the other machine: a merge that applied cleanly
is UNVERIFIED, not validated. The `\xe2` crash and the blank form both shipped
in merged front-end code and both first failed when the owner ran them -- run
every gate AND a live dry-run on merged code exactly as on your own.
**A WPF button gets a bridge smoke-import before it reaches the owner.** The
host cannot import `System.Windows` at all, so a wrong WPF namespace is
invisible to every host gate -- 2026-09-03, `StretchDirection` imported from
`System.Windows.Media` (it lives in `System.Windows.Controls`) and the button
died at import the first time the owner clicked it, the day after the rule
above was written. The bridge runs IronPython INSIDE Revit, so the check is
cheap: exec the button's `from System.Windows...` import block through it
(`python tools/wpf_smoke.py <script.py>`) and require zero import errors.
Owner, the same day: "写的东西测试过了再给我,别老让我报错." A button handed
over without that run is a button the owner is testing for you.
**A failure class that is invisible on the host earns a guard test.** The Panel
Analysis crash on 2026-09-03 was a non-ASCII byte in an IronPython 2.7 file with
no `# -*- coding: utf-8 -*-` cookie: a hard `SyntaxError` inside Revit, and
completely silent on host CPython 3, which defaults to UTF-8. A bug that only
bites in the engine we cannot run here, and passes every host check, recurs
unless a host test reproduces its shape. `tests/test_coding_cookie.py` now walks
every extension `.py` and fails on a non-ASCII byte with no cookie, the way
`test_button_tooltips.py` guards hover text. When a class of failure is
invisible on the host but real in Revit, add the guard rather than trusting the
next person to remember.
**Reading a model must be GENERIC -- geometry and built-ins first, a mapped role
only as an accelerator.** Owner, 2026-09-03: "很多的预填充应该是通用的代码,避免
换一个活儿就得重来." A pre-fill or scrape keyed to one job's parameter names is
rework waiting for the next job. Dimensions and weight come from GEOMETRY
(bounding box, solid volume) -- job-independent, the floor. Names come from
Revit BUILT-INS (`Mark`; ProjectInformation's Project Name / Client Name).
Strengths and the like, when the plant role is unmapped, fall back to
case-insensitive parameter-NAME PATTERN matching (f'c: "f'c" / "fc" / "28 day";
release: "fci" / "release"; unit weight: "density" / "pcf"), never one job's
literal name. The plant `knowledge` role is the accelerator where it exists,
never the only path. This is "no client/job information in the repo" seen from
the code side, and the same law as the five knowledge layers: switching to
another job must pre-fill out of the box with no edit.
**Delegation is EXPLORE (me) -> WRITE (DSV4F, to a settled spec) -> REVIEW (me),
and the tooling is what makes it safe.** DSV4F's code quality is not the risk --
on 2026-09-03 its `tools/rvt.py` work was correct and passed review plus 16 new
tests; every DSV4F failure that day was INFRASTRUCTURE, and each has a standing
fix. (a) The dsh headless CLI times out and the dsh sandbox denies pytest's
`tmp_path`, so GATES RUN OUTSIDE the sandbox: I run pytest / scrub on my side,
DSV4F only writes. (b) A dsh sub that stalls or hits an approval prompt does not
report back, so a dispatched dsh sub is WATCHED with `tools/dsh_watch.py
<session-id>` in the background -- its exit is the wake signal, instead of the
owner having to tell me it stopped. And do NOT `fork` for execution work: a fork
inherits the conversation and on 2026-09-03 continued in reply-to-the-owner mode
with zero tool calls instead of doing the job. For "go build / change X", spawn
a FRESH agent with an imperative, self-contained brief -- the JUDGEMENT about
what to build is still mine; the reading that informs it goes to a Sonnet
sub-agent, not to me and not to DSV4F (PROJECT.md §3.9). And SPLIT the settled spec before it reaches DSV4F (owner,
2026-09-03: "确认了边界的活儿,拆细了再让 DS 干"): one bounded piece per task
-- a function, a section, one file's change -- each sized to finish inside a
SINGLE reply. A whole-feature spec handed to DSV4F in one turn truncates at
max-tokens (the PanelAnalysis write stopped after the easy schema edits,
`stopReason: length`), which reads as "it skipped the hard parts" when it
simply ran out of output. Exploration never goes to DSV4F -- but it does not
stay with the expensive coordinator either: it goes to a Sonnet sub-agent
(owner, 2026-09-05: "探索你带着sub sonnet…DSV4F别做探索。DSV4F就干点儿体力活儿").
DSV4F gets labour only: extraction, mining, translation, bounded pre-split
writes. PROJECT.md §3.9 carries the incident, the `cmd -p` Command Code route
when the DeepSeek platform balance is empty, and the `systemd-run --user` rule
for any worker that outlives a turn.
**IronPython 2.7 syntax** for everything under `WillTools.extension/` and every
`exec` snippet:
- No f-strings. Use `'{0}'.format(x)`.
- `print('a:', x)` prints a **tuple** `('a:', 5)`. Build one string instead.
- No `pathlib`, `dataclasses`, typing syntax, or walrus.
- Only `tools/rvt.py` runs on host CPython 3.11 — modern syntax is fine there.
**Any change to the model needs a Transaction.** Reading never does.
```python
from vibe import tx
with tx('Name the user will see in Undo'):
...
```
No nested plain `Transaction` (use `SubTransaction` / `TransactionGroup`).
**Internal units are decimal FEET.** Always — areas in square feet, angles in
radians. Convert at the boundary with `vibe.ft(mm)` / `vibe.mm(feet)`. Use
`UnitTypeId.*`; `DisplayUnitType` was removed in Revit 2022+.
**`ElementId` in 2024+**: `.IntegerValue` is deprecated for `.Value`. Read it
defensively (`try IntegerValue / except .Value`) as the existing code does.
**Don't block Revit's UI thread.** No sleeps or polling loops inside handlers.
**"Could not judge" and "judged, found nothing" are different answers, and
nothing here may collapse them.** This is the discipline the whole tool is
built on, and it shows up in three places that each look like a local detail:
- **A knowledge lookup that cannot resolve returns `None`, never `False`.**
`knowledge.same_face()` returns `None` when either token is an abbreviation
it does not know — because "I cannot read that" and "those are different
faces" are opposite answers, and `False` would assert the second.
`rebar_stock()` carries `basis = None` for "nothing knows a stock length"
alongside `'by_size'` and `'generic'`, and `stocked` is a separate `None`
again. `rebar_fy()` returns `basis = None` and `fy = None` when no grade is
defensible — A615 names no strength, so its `default_fy_ksi` is deliberately
null. Each of them also returns a `why` sentence: standing down without
saying why is the same failure one step later. Follow this for any new
lookup.
- **An unrecognised state word renders red, never grey.**
`ticketpage.state_color()` matches GREEN / YELLOW / GREY states by name and
everything else falls through to red. That fallthrough is load-bearing, not
a default: it is what makes it safe to add a state like `MUTED` at all,
because a typo in a state word becomes loud instead of quietly grey. Grey is
reachable only through a word the table knows — a *declared* mute. Do not
"tidy" the fallthrough into grey or into an exception.
- **A check that returns nothing owes an explanation.** See §5 below; this is
the same rule, and it is the one a newcomer trips first.
**Never run an unfiltered collector, and never guess at an API enum.** This
crashed Revit on 2026-08-18 — a single exploratory snippet that walked the
whole document with `FilteredElementCollector(doc).WhereElementIsNotElementType()`,
touched `.Symbol` on every result, *and* set `Options.DetailLevel` from a value
reached by `__import__('Autodesk').Revit.DB...`. Revit died with no exception
and no journal entry, because a bad value handed to a native API is not a
Python error. So:
- Always scope a collector — `OfClass`, `OfCategory`, a view id, or just
`asm.GetMemberIds()`. This model has ~377 assemblies and six figures of
elements.
- Import enums by name (`from Autodesk.Revit.DB import ViewDetailLevel`).
If the import fails you get a traceback; if a bogus object reaches the
native layer you get a dead Revit.
- One narrow question per `exec`. Geometry extraction goes in its own request,
never bundled with a model-wide scan.
**A contradiction hit mid-refactor is the owner's to settle, not yours.** A
restructure on 2026-08-31 (private repo `pyrevit-knowledge`, commit
`767281e`, "总表三层化、厂层收割落库") walked into `lifters.capacity_lb` in a
plant's `knowledge.yaml` and found an override the owner had entered by hand —
`P76 = 16,000 lb`, the correct ALP with-tension-bar rating — disagreeing with
`knowledge/catalogue.yaml`'s published 7,690 lb for the same part. The
restructure emitted the key EMPTY and wrote a new comment reading "Direct
override, checked BEFORE the catalogue. One-offs only," phrased as though the
key had always been blank. `lifter_capacity()` checks that override before the
catalogue, so every export after that quietly fell back to 7,690. Five real
panels (WP242, WP283, WP318, WP322, WP326) each carried a `FAIL / PLUMB`
finding — 8,660 lb each of a 34,638 lb piece against a 7,690 lb tension limit,
113% — before the owner asked on 2026-09-01 where 7,690 came from, and
answering that took a git-history dig across two repositories. The sibling key
in the same block, `lifters.min_edge_distance_in: 9`, survived the identical
restructure untouched, which is what makes the dropped key a mistake and not a
policy. Deleting the losing side of a contradiction, silently preferring the
"published" value, and writing a comment that makes the surviving value look
uncontested are all the same move: each retires the question instead of
asking it. When a refactor meets a contradiction, stop and surface it to him
instead.
**No client or job information in the repo. Ever.**
Not in code, not in comments, not in docstrings, not in a commit message, not
in an example. When you need to refer to one, write the placeholder rather
than deleting the sentence:
| | |
| --- | --- |
| a client / owner | `sample_client` |
| a job / project / model | `sample_job` |
Product codes are **not** client information and stay as they are: design
numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers
(`ANCHOR_FACE_4T`) are naming conventions, not identities.
`python tools/scrub.py` checks the working tree **and the whole git history**
against `.scrub-terms` (gitignored, so the list of names is not itself the
leak) plus structural patterns — model filenames, absolute user paths, email
addresses. It runs as a pre-commit hook and inside `tools/package.py`.
The working-tree scan covers **untracked files too**. A scratch script nobody
has added is exactly where a model path gets pasted, and it is one `git add -A`
from being in the commit. `--worktree` is the audit and a hit anywhere fails;
`--staged` is what the hook runs, where only staged files block and untracked
hits are printed as a warning — a gate that blocks work unrelated to the commit
is a gate that gets passed with `--no-verify`.
**But every scan above honours `.gitignore`, so none of them has ever opened
`QNA/`.** Nor `knowledge/plant/`, `knowledge/job/` or `_scratch/`. That is the
largest body of prose in the checkout and the only part of it whose job is to
quote source material verbatim, so a name is not merely possible there, it is
certain. `--private` is the mode that looks at it, and it is **an inventory,
not a gate: it always exits 0** and the hook never runs it. It asks a
different question, because "does this contain a name" is answered yes by
design — it asks whether a name is sitting in a shape that **travels**: inside
a fenced block, a proposed `key: value`, a quoted literal, a path, or glued
into a longer identifier (`<name>_EMBED_P76`), which is the shape a reader
skims past. Those are aggregated by the distinct string and listed, worst
first; a name in a sentence, or in a value in a plant file, is the file doing
its job and comes back as a number only. Read a row as "has this exact string
already been pasted into a tracked file" and check it with `git grep`. It
exits 0 because it has no verdict of its own: every string it prints carries a
term or a structural pattern, so a copy into a tracked file is already a
`--worktree` failure. What it finds is the raw material a leak gets made out
of, weeks earlier — and a check that failed on that would fail every run,
which is a check somebody wraps in `|| true`.
Editing a file does not remove a name from the commit that introduced it, and
`git push` sends commits, not the checkout. Before anything is pushed the
history can still be rewritten; afterwards it cannot.
**The open model is the user's real work.** Before anything that deletes,
renames in bulk, purges, or touches a saved model: prototype read-only, show
counts and a sample, and get an explicit go-ahead. Never use `--tx` to "just
try" a mutation on a model you have not checked with the user.
---
## 4. Idioms
```python
from vibe import q, p, set_p, tx, selected, ft, mm, out, table
```
| Instead of | Write |
| --- | --- |
| `FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Walls).WhereElementIsNotElementType().ToElements()` | `q('OST_Walls')` |
| `StorageType` branching | `p(el, 'Comments')` |
| manual Transaction start/commit/rollback | `with tx('name'):` |
| `uidoc.Selection.GetElementIds()` + `GetElement` | `selected()` |
Signatures: `WillTools.extension/lib/vibe/helpers.py`.
API patterns: `docs/revit-api-cheatsheet.md`.
**Before touching anything that measures geometry, read
`docs/interference.md`.** Two working-looking versions of the clash check
reported no interference anywhere because a cylinder's edges are circles whose
end points coincide; a third was two thirds false positives because a bent
bar's bounding box is mostly the hole in the middle.
Inside a button script, use pyRevit forms rather than raw WPF: `forms.alert`,
`forms.SelectFromList.show`, `forms.ask_for_string`, `forms.select_levels`,
and `script.get_output()` for `print_md` / `print_table` / `linkify`.
---
## 5. Building a new tool for the user
1. Prototype with `rvt.py exec` against the live model until it is correct.
2. Write it into `WillTools.extension/WillTools.tab/<Panel>.panel/<Name>.pushbutton/`
as `script.py` + `bundle.yaml` (+ optional 96×96 `icon.png`).
3. **Add `<Name>` to its parent container's `bundle.yaml` `layout:` list** —
the panel's, or the pulldown's if it is a row inside one. A child that
is not listed is not created — the button will simply not be there, and
nothing anywhere will say why.
4. A tool that is not finished goes behind the `Design.panel` pulldown with
`(WIP)` in its title, never onto a panel beside the working tools.
5. Ask the user to **restart Revit** (never "pyRevit → Reload"), then try it.
Do not declare a tool finished before step 1 has actually run clean.
### Adding a check to Ticket QC — a new check owes a skip probe
This is the rule most likely to bite you, because adding a check is the first
thing anyone does in `ticketchecks.py` and the failure is silent at write time
and loud on the page.
Write the check, add it to `CHECKS`, and it runs. But a check returns a list of
`Finding`s, and **an empty return is ambiguous** — it can mean *ran and found
nothing wrong* or *never ran, for want of data*. `run_all` cannot tell, so it
does not guess:
| what happened | what `run_all` logs | what `checklist_states` shows |
| --- | --- | --- |
| returned findings | `RAN` | the verdict |
| raised | `FAILED` | NOT RUN, plus a `CHECK-ERROR` warning naming the check |
| empty, probe returns a reason | `SKIPPED` | NOT RUN, *with that reason* |
| empty, probe returns `None` | `RAN` | OK — ran, found nothing |
| empty, **no probe registered** | `RAN-EMPTY` | **NOT RUN, "no probe declared"** |
So a check with no probe that ever legitimately finds nothing renders as a red
NOT RUN row for a piece that is fine. That is deliberate — silence that looks
like "fine" is the exact defect the checklist exists to surface — but it means
**a new check with no probe is a defect you have just shipped**, not a check
that works.
And **a checklist row may name more than one check.** It is answered only when
every one of them spoke; any quiet half makes the whole row NOT RUN and the
note carries each quiet half's own reason. Under-claiming is the safe
direction, because a green row is a claim — a row that asks two questions and
prints OK because the half that could still fail it was never asked is exactly
the silent pass the table exists to prevent.
Register one beside the check:
```python
@_register_skip_probe('check_your_thing')
def _probe_your_thing(t, intent, know, spec):
if not t.get('the_input_it_needs'):
return 'no <input> on this piece -- <what> could not be judged'
return None # None means: it ran, and found nothing. Never ''.
```
The probe's return is the *reason*, and it is printed. `None` is the
"everything was there, it just found nothing" answer — do not return an empty
string for that, and do not write a probe that can never return a reason.
This is now test-enforced rather than a count somebody has to keep re-auditing:
`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe`
walks `CHECKS` and fails, naming names, the day a new check ships without a
probe registered for it in `_SKIP_PROBES` — the same failure the 2026-08-31
backlog of seven bare `CHECKS` entries used to sit as silently. Run the test
rather than trusting a number here; a count copied into this paragraph is
exactly the kind of fact this file has gone stale on before.
### Tests must not write into the working directory
`vibe.batchrun.make_worker` takes an `io` for exactly this reason. A test that
actually **calls** the worker must pass `io=RecorderIO()`; the sibling tests
all do, and the one that did not created a file literally named `s` — twice,
once at the repo root and once under `WillTools.extension/` — which reached a
commit (`68adc27`) before anyone noticed and had to be deleted in `9504c05`.
Anything that only inspects the worker's code object can pass the real one,
because nothing runs.
**This is a convention with nothing enforcing it.** There is no fixture and no
guard; `pytest` will happily let a test write anywhere. Use `tmp_path` or a
recorder, and be aware that the next stray file is one `git add -A` from the
history — which is the same reason `tools/scrub.py --worktree` scans untracked
files. A guard would be better than this paragraph; that is a separate job and
nobody has asked for it yet.
---
## 6. When stuck
- `rvt.py params <id>` beats guessing a parameter name. It and `/collect` send
their arguments as a JSON body, not a query string, because pyRevit 5.0.1's
routes `Request` exposes no parsed query string under any name. **Confirmed
live 2026-09-01 on Revit 2024.3 / pyRevit 5.0.1: both endpoints return
`ok: true` over the JSON body.** Getting that confirmation took two tries,
and the lesson is worth more than the fix: **`status` returning ok is not
proof of a fresh engine.** A second Revit can hold the bridge port while
serving code older than the file on disk, and the right model open plus a
session-log line dated today both look exactly like a restart. Compare
`System.Diagnostics.Process.GetCurrentProcess().StartTime` (through `/exec`)
against `startup.py`'s mtime before believing new code is loaded.
- **Two things `rvt.py` can now say about an answer, and neither is a fault to
work around.** *"REFUSING this answer - it belongs to a different request"* is
the shared-response-slot desync of §1 caught in the act; the channel is
latched and the next call drains it, so retry rather than reaching for curl.
*"this answer could not be attributed"* means the in-Revit half is older than
the client and echoes no id — a **Revit restart** (never a reload) is what
fixes it, and until then any answer taken shortly after a timeout is
unattributed, which is exactly how a sweep's progress string once got printed
as a document title.
- `rvt.py exec -c "print([m for m in dir(el) if 'Type' in m])"` — reflection on
the live object beats searching docs.
- pyRevit's library is readable source at
`%APPDATA%\pyRevit-Master\pyrevitlib\pyrevit\` — grep it.
- <https://www.revitapidocs.com/2025/> · <https://pyrevitlabs.notion.site/>
---
## 7. Search the transcripts before you ask the owner
**`PROJECT.md` §3.8 is the rule; this is how to run it.** Before you put a
question to him, search what he has already said. He has answered far more
than any document records, and asking twice gets you a second ruling that does
not match the first.
The transcripts are `~/.claude/projects/<project-slug>/*.jsonl` on the
machine the conversation happened on. **The owner's machine's transcripts
travel in the private KNOWLEDGE repo under `sessions/`** (the Discord agent's
host has them at `~/projects/KNOWLEDGE/sessions/**/*.jsonl`), so a search on a
different machine must include that tree or it is not a search of what he
said (2026-09-05: two issues asked him questions answered there days before).
Locally they are `~/.claude/projects/<project-slug>/*.jsonl` —
newline-delimited JSON, **one record per line**, no enclosing array, so read
them line by line and never `json.load()` the file. His messages are the
records with `type == "user"`; `message.content` is either a plain string or a
list of blocks, where the text lives in the blocks with `type == "text"`. Skip
what is not his: a `type: "user"` record can also carry a tool result, and the
long "This session is being continued…" blocks are a **summary written by an
agent, not by him** — quote one as his words and you have invented a ruling.
**The encoding trap.** The files are UTF-8 and Windows stdout is cp1252, so
printing a Chinese message raises `UnicodeEncodeError` and the search looks
like it crashed rather than like it found something. `PYTHONIOENCODING=utf-8`
is the fix, and it belongs on every one of these commands:
```bash
PYTHONIOENCODING=utf-8 python -c "
import json,glob,os,sys
q=sys.argv[1]
for f in glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')):
for n,l in enumerate(open(f,encoding='utf-8'),1):
if q not in l: continue
r=json.loads(l)
if r.get('type')!='user': continue
c=r['message']['content']
if not isinstance(c,str): c=' '.join(p.get('text','') for p in c if isinstance(p,dict))
if q in c: print(os.path.basename(f)[:8],n,r.get('timestamp',''),c[:300],'\n')
" "7.5sqrt"
```
That exact command is what found the ruling the rule was written for. **It
reads all 65 MB in 0.2 seconds**, so run it three or four times with different
terms rather than once — and search **Chinese as well as English**, because he
writes in both and often mixes them inside one sentence. Search the value
(`7.5`), the concept (`应力极限`), the part number, the parameter name. Prefer
a `git log -S` / `git grep` pass too: a ruling that already landed is usually
quoted in the comment beside the value.
Two ways to get this wrong, and the second is the expensive one:
- **A hit is not automatically the answer.** Check the timestamp and read the
surrounding lines. Two rulings months apart may be about different questions
— one about *what the number is*, one about *where the setting lives* — and
neither retracts the other. §3.8 has the case.
- **No hit is not proof he never said it.** These are one machine's history,
unindexed and bilingual, and the word you searched is very often not the word
he used. Report ***"I searched `X`, `Y`, `Z` — nothing"***, listing the terms,
and never *"he has never ruled on this"*. Then ask him. Asking is not the
failure; asking **first** is.
+102
View File
@@ -0,0 +1,102 @@
# Contributing
Thanks for trying this out. Short version: run the tests, never paste real
job data anywhere, and expect review on anything that changes a verdict.
## Running the tests
Two independent suites, both plain CPython, no Revit and no licence needed:
```bash
python -m pytest panellift -q # PCI Chapter 8 handling checks
python -m pytest WillTools.extension -q # the Revit-free half of the extension
```
Both are self-contained -- fixtures build their own data, nothing reads a
real job. `python -m compileall WillTools.extension tools panellift/src` is
a fast syntax smoke test across all three, including the IronPython-targeted
code under `WillTools.extension/`, which only runs for real inside Revit.
`python tools/setup_machine.py --check` is the doctor. Run it before filing
an issue and paste its output in full -- see the bug report template.
### What does not run in a trial install
`knowledge/catalogue.yaml` ships here, but as a carve-out -- read its own
header for the exact list of what it carries (trade-standard vocabulary,
the shop-ticket checklist skeleton) and what it deliberately omits (the
owner's real manufacturer capacity ratings). A small number of tests exist
specifically to pin THAT real data -- "does the catalogue publish 4:1 in
shear for QLPD4T5WG", not "does the tool's arithmetic work" -- and they are
tests of the owner's knowledge, not of this tool. Faking them with
invented numbers would just be that same real data through a back door, so
instead each suite's `tests/conftest.py` carries an appended block (written
by `tools/make_dist.py`'s `append_test_exclusions()`) that SKIPS them by
name, with that reason, rather than letting them fail:
| suite | tests skipped | why |
| --- | --- | --- |
| `WillTools.extension` | 6, in `tests/test_knowledge.py` | read `catalogue.inserts` directly, assert a real ALP insert's factor/spacing, or a real HK Composites tie's edge distance |
| `panellift` | 1 parametrized function (7 cases), in `tests/test_safety_factors.py` | its own docstring already calls it "a golden-value check against a page, not a recomputation" |
Both blocks **auto-detect**: they check whether `catalogue.inserts` in the
`knowledge/catalogue.yaml` the test run actually finds is non-empty, and
stand down with no edit needed the day someone runs these suites against
the owner's real knowledge layers. Run `python -m pytest -rs` (either
suite) to see every skip and its reason printed, rather than trusting a
count copied into this paragraph.
## No client or job information in the repo. Ever.
Not in an issue, not in a PR description, not in a commit, not in a
screenshot, not in a test fixture. If you need to refer to one, use the
placeholder convention this repository already uses everywhere — write the
placeholder rather than deleting the sentence:
| | |
| --- | --- |
| a client / owner | `sample_client` |
| a job / project / model | `sample_job` |
Product codes are not client information and stay as they are: design
numbers (`PNL1.0`), piece marks (`PNL101`), and hardware part numbers
(`ANCHOR_FACE_4T`) are naming conventions, not identities.
<!-- tools/scrub.py's load_placeholders() reads the two-row table above
verbatim, by this exact heading, as its fallback source when CLAUDE.md is
not present (every colleague distribution) -- keep the heading text and the
backtick-quoted tokens in sync with CLAUDE.md's own copy of this table if
either ever changes. -->
`python tools/scrub.py --worktree --terms .scrub-terms.example` is the same
check CI runs, and you can install it as a pre-commit hook on your own
clone:
```bash
python tools/scrub.py --install-hook
```
It runs on the generic sample terms here (there is no real `.scrub-terms`
file in this distribution) plus structural patterns -- an absolute Windows
user path, a `.rvt`/`.rfa` filename, an email address -- which catch a leak
whether or not it happens to be on anybody's list yet.
## Opening a PR
- Keep it focused. A PR that fixes one thing is reviewed faster than one
that fixes three.
- If it changes what a check reports (a verdict, a finding, a threshold),
say so explicitly in the description and expect the owner to review it
personally -- these tools produce numbers a real plant floor may act on,
and "the tests still pass" is necessary but not sufficient for that class
of change.
- If it's a new tool or button, read `docs/architecture-migration.md` and
the `plan-*.md` docs first for the shape this project is moving toward.
## Filing an issue instead
See the two issue templates -- **Bug report** for something that crashed or
gave a wrong answer, **Question** for something that was unclear or a
refusal you think should have been an answer. The README's "Filing feedback"
section has more on the difference between those two, and it matters: a
refusal is very often the tool working correctly.
+325
View File
@@ -0,0 +1,325 @@
# Installing WillTools
Precast engineering tooling: buttons across four ribbon panels — three
modelling/ticketing tools plus Snapshot behind **Tools**' `Modeling`
pulldown, five set-up commands behind the **Config** pulldown, two finished
and four unfinished analysis tools behind **DesignHub**, and GOD_MOD — plus a
live bridge that lets Claude Code drive a running Revit session. `README.md`
has the table of what each one does.
Developed and exercised on **Revit 2025.4 · pyRevit 6.5.3 · IronPython 2.7.12**.
Everything else in this file is about what to check when one of those differs.
---
## 1. Prerequisites
| | Needed for | Notes |
| --- | --- | --- |
| **Revit** 2021 or newer | everything | see [§6](#6-a-different-revit-version) before assuming a version works |
| **pyRevit** 4.8 or newer | everything | <https://github.com/pyrevitlabs/pyRevit/releases> |
| **Python 3.9+** on the host | `tools/*.py`, `panellift` | not needed to use the ribbon buttons themselves |
| **Claude Code CLI** | GOD_MOD button only | optional — every engineering button runs without it |
**No button that does engineering work needs the CLI, the network or the
bridge.** Design Extract reads a design packet in-process with a
regex/geometry parser and never leaves Revit. GOD_MOD is the only button
that launches the CLI, and it is the developer button.
### The Windows Store Python trap
If `python` at a terminal prints something like *"Python was not found; run
without arguments to install from the Microsoft Store"* and then exits
non-zero, that is not really Python — it is a stub Windows ships in
`WindowsApps` that only exists to point you at the Store. Every command in
this file, and `tools/setup_machine.py` itself, will fail in confusing ways
if that stub is what `python` resolves to.
Fix: install Python from <https://www.python.org/downloads/> (check "Add
python.exe to PATH" during setup), then open a **new** terminal and confirm
with `python --version` — it should print a real version number, not a
Store prompt. If both a real install and the Store stub are on `PATH`, the
Store one usually wins unless you reorder `PATH` or use the `py` launcher
instead (`py -3 tools/setup_machine.py`).
## 2. Install
1. **Clone or unzip anywhere you can write to.** A checkout under your user
folder is easiest. The path is not hardcoded anywhere; the buttons find
the folder relative to themselves.
```bash
git clone https://github.com/will123song/pyrevit-dev.git
cd pyrevit-dev
```
2. **Run the setup script.** It installs the host-side Python dependencies
(`requirements.txt`: PyYAML, pytest, pymupdf) and then *proves* the
install rather than just reporting exit codes — it runs `panellift`
against a published worked example and checks the computed stress
against the number the article prints.
```bash
python tools/setup_machine.py
```
Read the output top to bottom. `PASS` and `SKIP` lines need nothing from
you; a `SKIP` is a deliberate non-answer (this distribution ships no
knowledge layers, so the checks that need one stand down cleanly — see
`knowledge/README.md`), not a problem. A `FAIL` line names what broke and
what to try — fix the first one and re-run; failures often cascade from
one root cause. Re-run any time with `--check` to just re-verify without
changing anything:
```bash
python tools/setup_machine.py --check
```
3. **Register the folder with pyRevit.** pyRevit tab → *Settings* →
*Custom Extension Directories* → add the folder that **contains**
`WillTools.extension` (the cloned folder itself, not the `.extension`
folder inside it) → *Save Settings*.
Equivalent to this in `%APPDATA%\pyRevit\pyRevit_config.ini`:
```ini
[core]
userextensions = ["C:\\path\\to\\pyrevit-dev"]
```
4. **Turn on the routes server** — only if you want the Claude Code bridge
(GOD_MOD). pyRevit → *Settings* → *Routes* → enable, port `48884`. Or:
```ini
[routes]
enabled = true
port = 48884
host = "127.0.0.1"
```
5. **Restart Revit.** Not "pyRevit → Reload" — see [§7](#7-the-one-rule).
A **WillTools** tab appears with four panels, left to right:
| Panel | |
| --- | --- |
| **Tools** | one button, **Modeling**, with a dropdown arrow (Reinforce Suggestion, Reinforcing QC, Ticket QC), plus **Snapshot** as a large button beside it. |
| **Setup** | one button, **Config**, with a dropdown arrow: Project Config, Design Extract, Self Check, Job Recon, Takeoff. |
| **Design** | one button, **DesignHub**, with a dropdown arrow: Panel Lift Check and Panel Analysis (finished), then Beam Analysis, Connection Analysis, Gravity and Lateral, marked `(WIP)`. |
| **Lab** | **GOD_MOD**, the CLI button. |
## 3. Check the install
Open a model and press **Self Check**. It probes every assumption these tools
make — Revit version, pyRevit version, Python engine, routes, library imports,
unit conversion, whether the CLI is findable, where the data folder resolved
to, whether a published checklist is available, and whether this model
actually follows the naming conventions the checks depend on — and prints
each as OK / WARN / FAIL.
Read the FAILs top-down; they cascade. A WARN never blocks a run, and on a
knowledge-less install several rows are *expected* to read WARN — that is
the "checks stand down" state, not a broken install.
## 4. Try it with no Revit at all
```bash
python -m panellift examples/example-panel.job.json -o report.html
```
That runs the PCI Chapter 8 handling check against a published worked
example (the ASPIRE Winter 2019 article) and writes an HTML report. It needs
no Revit, no model, and no knowledge layer — a good first thing to run right
after `tools/setup_machine.py` to confirm the arithmetic reaches you intact.
## 5. Using it on a real job
Per model, in order:
1. **Extract Design** — pick the design summaries, one extraction terminal
opens per design, each writes `specs/<DESIGN>.json`.
2. **Read the spec.** It is JSON on purpose. Fix anything wrong and fill in
`source.reviewed_by`. Nothing downstream questions the spec again.
3. **Ticket QC** — checks every piece of those designs against the spec.
**Needs a knowledge layer with the published checklist** — see below;
without one it refuses to open with a one-line reason.
4. Edit the `knowledge.yaml` files as you learn the job — see the layers
below for which one. They are re-read on every run — **no restart needed
for knowledge or spec edits.**
Everything lands in `_ticketqc/` **beside the model file**:
```
<model folder>/_ticketqc/
evidence/<DESIGN>.json raw scrape, no interpretation
specs/<DESIGN>.json the reviewable spec <-- read this
knowledge.yaml your hand-written rules
log/<DESIGN>.txt what the extractor printed
```
If the model is not saved, or its folder is read-only, it falls back to
`%APPDATA%\pyrevit-ticketqc\<model title>\`. Self Check tells you which.
### The knowledge layers, and why this distribution has none of them
Knowledge is not one file. It is read in layers, each overriding the one
before it key by key, and the split is by **how long the fact stays true**:
| layer | where | what belongs there |
| --- | --- | --- |
| defaults | `lib/vibe/knowledge.py` — in this repo | what the code assumes when nobody has said |
| catalogue | `knowledge/catalogue.yaml` — **not shipped** | published hardware ratings, plus the master shop-ticket checklist |
| **plant** | `%APPDATA%\WillTools\knowledge.yaml` — **not shipped** | naming conventions, casting-bed rails, **parameter names**, which catalogue item each part number is, rigging mode and dunnage position |
| **job** | `<model folder>/_ticketqc/knowledge.yaml` — **not shipped** | tolerances, and anything where this job departs from the plant |
| **project config** | wherever you put it — see below | the whole configuration, pinned to one job |
This distribution ships **none of the last four**, deliberately — see
[`knowledge/README.md`](knowledge/README.md) and
[`knowledge/catalogue.yaml.template`](knowledge/catalogue.yaml.template) for
the shape of what a real layer looks like, with every value blanked out.
Everything on **Tools** and **Setup** except Ticket QC runs without one,
reading only `knowledge.DEFAULTS`; Self Check reports every plant-specific
parameter role as unmapped until real layers are wired in. **Ask the owner**
for real layers when you have a job to run for real — see the README's
"What to ask the owner for."
Copying a job file forward to the next job should be nearly empty work — if
you find yourself copying a lot, it belongs in the plant layer. Set
`WILLTOOLS_KNOWLEDGE` to a full path to put the plant layer on a share so a
whole office reads one file. (`tools/setup_machine.py` deliberately leaves
this variable unset on a knowledge-less install — see its own comments if
you are wiring one in by hand rather than through the script.)
Handling decisions are plant knowledge too: `handling.stages[].rig_mode`
(`rolling` or `static`, blank inherits the global mode) and
`handling.dunnage.mode` (`lifter_rows` or `fraction`, with `fraction` such as
`0.2` for supports at 0.2L from each end). A plant that has not set dunnage
still gets transportation as a lift, and the report labels it as approximate.
Self Check prints which layers it found, and lists any parameter role still
unmapped.
### Pinning a configuration to a job
The **Project Config** button writes every layer above, merged, into one file
wherever you choose — the job folder on the share, usually, next to the rest
of the job's paperwork. From then on the model runs on that file, and it is
the last word: edit it and the next run uses what you wrote.
A one-line pointer at `<model folder>/_ticketqc/active-config.txt` records
where the file went. Press the button again to refresh the file from the
layers underneath, or to drop back to them.
Two things to know before you use it:
- **It freezes the plant layer.** The merged copy in the file contains the
plant's settings as values of its own, so while the file is live, fixing
something in the plant file no longer reaches this job. That is the point of
pinning, and it is also how a plant-wide correction quietly fails to arrive.
- **Do not move or delete the file without clearing the pointer.** Every
button refuses to run rather than falling back, because a run on the layered
files when you meant the pinned ones looks entirely normal and is not the
configuration you set.
A new model has no pointer, so it starts from the layered files and gets its
own configuration the first time the button is pressed there.
### Nothing is hard-coded, including public facts
The obvious things stay out of the code: client names, job names, parameter
names. Less obviously, **so do facts that are perfectly public.** A lifting
insert's rated working load is printed in the manufacturer's catalogue and is
no secret — but written into a `.py` file it becomes a number nobody can
correct without a release, and it will be wrong eventually.
So a rating is a lookup, and it takes two hops, because a plant models its own
part number rather than the manufacturer's designation:
```
family name in the model
-> lifters.catalogue_alias plant layer: P000 -> SAMPLE_ANCHOR_4T
-> catalogue.inserts product data: SAMPLE_ANCHOR_4T -> 8000 lb
```
Each hop fails differently and is fixed in a different place, so the check
says which one is missing rather than reporting a bare "unknown". The same
shape applies to anything else with a published number behind a local name.
## 6. A different Revit version
The code is written against the stable parts of the API and guards the two
places Autodesk moved the floor:
- **`UnitTypeId`** (Revit 2021+) with a `DisplayUnitType` fallback for 2020 and
older, resolved once at import so an old host cannot break the whole library.
- **`ElementId.IntegerValue`** → **`.Value`** (Revit 2024+), read defensively
at every site.
Also version-proofed: the `/exec` snippet namespace skips API types that a
given release does not have, rather than failing to register.
**Not verified anywhere but 2025.** If you move to 2023 or 2026, press Self
Check first — it reports the host version as a WARN and exercises the imports
and a unit round-trip, which is where a version problem shows up first.
### A different Python engine
pyRevit chooses the interpreter. This extension is written in the intersection
of Python 2 and 3 — no f-strings, no `print` statement, no implicit integer
division, no `pathlib`, and `unicode`/`basestring` resolved in
`lib/vibe/compat.py` — so IronPython 2.7, IronPython 3 and CPython 3 should all
load it. Only IronPython 2.7 has actually been run in anger; Self Check warns
on anything else.
## 7. The one rule
**Never use pyRevit → Reload. Restart Revit.**
pyRevit's routes handler is owned by the engine that first loaded it. A reload
tears that engine down and leaves the handler pointing at the corpse; the next
bridge request runs against a dead engine and takes Revit down with it —
an unhandled exception off the main thread, which Revit cannot catch.
There is no reload endpoint, `rvt.py` refuses to run after it sees two loads in
one Revit process, and a restart picks up new buttons just as well.
Restart Revit after: installing, adding or renaming a button, or adding an icon.
No restart needed after: editing a `script.py`, a spec, or `knowledge.yaml`.
## 8. What is in this distribution
```
pyrevit-dev/
├── INSTALL.md this file
├── README.md what the tool is, what needs knowledge
├── CONTRIBUTING.md tests, the no-client-data rule, PRs
├── examples/example-panel.job.json a published worked example, try this first
├── docs/ reference material and design docs
├── knowledge/README.md why this folder is otherwise empty
├── knowledge/catalogue.yaml.template the shape of the layer that isn't here
├── tools/setup_machine.py run this first
├── tools/rvt.py bridge client (host CPython 3)
├── panellift/ the handling-check engine, its own test suite
└── WillTools.extension/
├── startup.py registers the bridge routes
├── lib/vibe/ helpers, compat, designspec, knowledge,
│ ticketqc, ticketchecks, aiextract
└── WillTools.tab/ the ribbon, four panels left to right:
├── Tools.panel/ Modeling.pulldown/ — Reinforce
│ Suggestion · Reinforcing QC · Ticket QC —
│ plus Snapshot as the one remaining large
│ button
├── Setup.panel/ Config.pulldown/ — Project Config ·
│ Design Extract · Self Check · Job Recon ·
│ Takeoff
├── Design.panel/ DesignHub.pulldown/ — Panel Lift Check ·
│ Panel Analysis (finished, grouped here on
│ the owner's own instruction), then Beam
│ Analysis (WIP) · Connection Analysis (WIP)
│ · Gravity (WIP) · Lateral (WIP)
└── Lab.panel/ GOD_MOD
```
No model data or knowledge ships with it. `_ticketqc/` folders stay with
their models; knowledge layers come from the owner, on request — see the
README's "What to ask the owner for."
+709
View File
@@ -0,0 +1,709 @@
# WillTools
This is the document to read before opening a file here. It assumes you know precast and
Python and nothing about this codebase. Four parts, in the order they matter: **what the
tool is**, **how it is built**, **the discipline**, **where everything stands**.
Read part 3 properly. Parts 1 and 2 orient you; part 3 is what stops you doing damage.
Every rule in it was bought with a failure and carries the incident that bought it,
because a rule whose reason has been lost gets "simplified" away by the next reader.
One standing convention, and it is the project's premise: **this repository does not state
things it has not checked.** Anything unverified is marked unverified; anything measured
says when. Where a number rots — line counts, test counts, timings — you get the command
instead, because those have each been wrong more than once in a single week. Where a
document and the code disagree, **the code wins**; §4.6 lists the disagreements known on
2026-08-30.
---
## 1. What it is
### 1.1 The product
WillTools is one engineer's QC instrument, not a plant production system. The owner is
an **independent precast specialty consultant** — not the plant's employee — and on any
job his responsibility is **cladding and wall panels only**. Other engineers hold the
beams, slabs and columns, and the plant knowledge every check runs on was validated
against panels and nothing else.
`lib/vibe/scope.py` exists because of that, and insists that **out of scope is a third
state** — not a pass, not a failure, not an absence. Every report names how much work it
left alone and of what kind. The scope is *declared* in the job knowledge layer
(`scope.mark_prefixes`), never inferred. The project already paid for the alternative:
one unauthorised exclusion hid forty tickets and every check that ran came back clean.
That is the first face of the rule the whole tree is organised around — **silence is
never a pass**. Its full statement is §3.1, and it is the single most important idea
here.
### 1.2 The ribbon
One tab, four panels: **Tools**, **Setup**, **Design**, **Lab**. Folder names *are* the
UI, and `layout:` in a container's `bundle.yaml` decides what gets built at all — a child
not listed is absent, not misordered. No button carries hover text (no `tooltip:`, no
`author:`, no module docstring); `tests/test_button_tooltips.py` fails by name if one
returns.
**Reinforcing Suggestion** asks which already-reinforced panels, *between them*, account
for every feature of this one — a set-cover jigsaw, not a similarity ranking — capped at
four donors, with the shortfall named rather than relaxed to a fifth. Search in
`reinforcematch`, index in `reinforceindex`/`reinforcecore`, join in `reinforcebind`,
wording in `reinforcereport`. **It opens no Transaction, by hard rule**: a wall panel's
middle vertical band is per-design engineering, and copying a donor's cage in
automatically would be the most dangerous thing this repo could do. An ordinary press
rebuilds only pieces whose cheap signature moved; shift-click forces a full rebuild,
because the signature cannot see an opening moved or resized in place.
**Panel Lift Check** is a PCI Chapter 8 handling check over stripping, transportation and
erection. The piece is measured from the model, then **you click the inserts carrying each
stage, on the piece itself** — the rules supply highlighted defaults and no longer get the
last word. It refuses rather than guesses: fewer than two support lines, a stage naming an
insert the panel has not got, a stage with no multiplier and no `pci_case`, short/long
above 0.75 (two-way spanning, which a beam idealisation cannot represent). Multi-select
runs a background batch whose worker lives in `vibe/batchrun.py` and never in the button —
a thread reading the command module's scope after pyRevit tears it down raises on a
non-main thread and kills Revit. Batches skip the position search — 93% of a run, 12.6 s
of a 13.6 s median piece on the reference sweep.
**Ticket QC** is the flagship. It checks every shop ticket of a design against the
extracted spec plus the knowledge layers: concrete strengths, jacking force, strand
count/grade/diameter/position, W.W.F., unit weight, embeds in both directions, bar marks,
note views, size envelope, interference. **27 check functions** in `ticketchecks.CHECKS`
feed **34 numbered checklist rows** in `ticketpage.CHECKLIST_ITEMS` — both counted
2026-08-30, both countable with `len()`. Every check is arithmetic; the one exception is
quarantined (§3.1). A row's status comes from `run_all`'s disposition log, never from an
absence of findings. And a ticket is not one sheet: it is the union of every sheet named
for the mark.
**Snapshot & Close** is the daily close-out. The model is 300+ MB on disk and 10+ GB open,
so there is never a second copy in memory: phase A indexes the live document, phase B
closes Revit (the press *is* the authorisation), phase C optionally reopens it detached in
a deferred host process once the memory is free. The request is journalled before the close
and the deferred process journals its outcome, so a day it silently did not run is reported
in capitals on the next press.
Behind the **Config** pulldown on the Setup panel: *Project Config*, *Design Extract*
(scrapes a design packet into a reviewable `spec.json`, with whatever the parser could not
place landing verbatim in `unresolved` rather than dropped), *Self Check*, *Job Recon*
(reconciles the issued ticket set against the whole job — the question no per-ticket check
can answer, because a ticket that is not there cannot be checked) and *Takeoff*.
The **Design** panel is a quarantine: everything on it sits behind the `DesignHub`
pulldown, on a panel of its own, so nothing half-built ever sits beside a finished tool.
On the **Lab** panel, **GOD_MOD** opens a terminal running `claude` at the repo root with
default permissions.
### 1.3 What a run produces
**Ticket QC.** The checklist table *is* the page — first, unfolded, one per ticket that
has anything to say, in loudness order. `ticketpage.state_color`: OK green; WARN and FOR
ENGINEER yellow; **NOT RUN and FAIL both red**. An all-green ticket gets one line, not a
table.
Everything else is a `foldreport.Section`, and the folding is a contract: *the answer is
the headline and is never folded; everything else starts closed; a section carrying a
problem opens itself, and its `problem` is a **sentence** naming what is wrong, not a
boolean; folded is not gone — a closed header still says what is inside and how much.*
The reader's side of that contract is **if nothing below the tables is open, nothing needs
me.** A problem section renders open as plain HTML needing no script, because pyRevit's
output panel is a WinForms `WebBrowser` in IE7 document mode where `<details>` does not
exist and fails *silently*.
Three files land in `_ticketqc/` on every press: `last-ticketqc.txt` (the whole report,
nothing folded, long notes wrapped rather than cut, so a dead fold costs a file open and
never a sentence); `qc-run.json` (`findings` and `model_read` as two keys, never one list,
each entry carrying `source` and a boolean `computed`; muting never touches it, because a
muted row is a rendering decision and a muted check that failed still fails the ticket —
but it does not yet carry the run log, §4.4); and the `ticketqc/2` evidence file of §2.2
(`qc-run.json` itself carries `ticketvision.RUN_SCHEMA = 'qc-run/3'` — read the constants,
both have moved since this paragraph was written).
**Panel Lift Check** produces a self-contained HTML file with the `.job.json` beside it as
the complete input, re-runnable where there is no Revit: a stage table, an "Inserts picked"
table recording the one part of the run that came from a human, recommended moves in dx/dy
inches, and inline SVG free-body, shear, moment and stress plots that load **nothing
external**. Two habits in it are worth copying. A stage that was not analysed prints
*words*, never a dash, because a dash among numbers reads as a small number. And the
station tables under each plot thin ~2000 stations by stride but never thin what matters —
both ends, every opening edge, every pick or dunnage line, the peak and trough of V and M,
the worst compression, and the governing tension station, tinted and bold and saying so.
Read Δx as "about here", not as a dimension, until §4.4's solver work is done.
---
## 2. How it is built
### 2.1 Two Pythons, and the line between them
`WillTools.extension/` is **IronPython 2.7.12** on Revit's UI thread: no f-strings, no
`pathlib`, no `dataclasses`, no typing syntax. `panellift/` and `tools/` are **host
CPython** (`panellift` declares `>=3.9`).
**`panellift` is deliberately unimportable from Revit** — it uses dataclasses and typing
syntax on purpose. A calculation whose output reaches a plant must be verifiable, and
verifiable means a suite that needs no licence, no model and no Windows. That is the
whole reason the line is where it is.
`vibe/liftexport.py` is the entire Revit side of it: it measures the piece, writes the
job file, shells out with a timeout and tick callback (`find_python`, `run`), and reads
the summary back. **Mechanics live on the far side of that line; facts about *this
model* live on the near side.**
### 2.2 The contract is the file, not the function
Modules hand each other versioned JSON carrying `schema`, `written_at`, `written_by`,
`doc_key` and `knowledge_sources` — not objects. Four instances today:
`liftexport.SCHEMA = 'panellift/1'`, `reinforcecore.SCHEMA = 6`,
`specformat.SCHEMA_VERSION = 1`, and `ticketqc.SCHEMA = 'ticketqc/2'` (bumped from `/1` on
2026-08-31; read the constant, it will move again), written to
`<model>/_ticketqc/evidence/run-<stamp>.json` on every Ticket QC press. **Nothing reads
the last one yet** — that is step 2 of the migration (§4.5), and precisely why the writer
could ship alone. The four rules, from `docs/architecture-migration.md` §2:
1. **Discard whole on mismatch.** `reinforcecore.load()` and `ticketqc.load_evidence()`
throw a stale file away entirely — never partially, never migrated.
2. **No live objects.** The plan named two holdouts; the build found four (`t['doc']`,
`t['legends']`, `t['sheets']`/`t['sheet']`, and live `XYZ` in `t['strand_points']` on
the no-framing early return) — so the serialiser gates on **type**, not on a list of
known offenders.
3. **Absence is not a value.** A gather that could not read something writes an explicit
`null` *and* a reason, through one `put()`. A merely missing key reads downstream as
"nothing to check" — the same failure mode `ticketchecks._SKIP_PROBES` exists to
prevent (§3.1).
4. **Provenance travels** — which layers were in force, which spec source (`spec` or
`regex`), which build. `intent` and `spec_source` ride with each ticket. Findings do
not: `qc-run.json` owns verdicts and the evidence file owns inputs, and step 2 needs
them apart so it can compare one against the other.
### 2.3 The five knowledge layers
Each layer overrides the one before it. `knowledge.load(doc)` merges them;
`knowledge.sources(doc)` lists them, and Self Check prints them.
| layer | where it lives |
| --- | --- |
| `knowledge.DEFAULTS` | this repo — what the code assumes |
| `knowledge/catalogue.yaml` | this repo — published hardware ratings, identifies nobody |
| plant `knowledge.yaml` | `%APPDATA%\WillTools\` or `WILLTOOLS_KNOWLEDGE`, per-plant folder |
| job `knowledge.yaml` | `<model folder>/_ticketqc/` |
| the project config file | wherever the engineer put it; `_ticketqc/active-config.txt` points at it |
**Maps deep-merge, lists replace.** Two consequences bite: a list in the job file
*removes* the plant's entries rather than adding to them, and `_deep_update` simply
assigns, so **an override and a typo are indistinguishable today**.
Two refusals guard the edges. A plant named in `job.plant` whose file is missing sets
`_plant_missing` rather than silently falling back to the machine-wide file; a pointer to
a vanished config sets `_config_missing`. `knowledge.config_problems()` turns either into
a sentence the buttons refuse on.
Note the deliberate two-pass in `load()`: `job.plant` chooses the plant file but lives in
a layer that merges *after* it, so `plant_id()` reads the job and config files once,
alone, first. **Delete that pre-pass and the tool reads the wrong plant while every report
looks normal.**
Which layer a fact belongs in is not a preference; it is an argument, and it is part of
the discipline — §3.2.
### 2.4 The bridge
`tools/rvt.py` is a host client for a pyRevit routes endpoint registered by `startup.py`.
`rvt.py exec` runs a snippet *inside* the live session and returns
`{ok, stdout, result, error, traceback}`. It is how anything is prototyped against a real
model, and it is the most dangerous thing in the repo. Its one absolute rule is §3.5.
### 2.5 Two repos
This repository can be shown to anybody. The other one cannot. `knowledge/plant/`,
`knowledge/job/` and `knowledge/_archive/` sit in this working tree for convenience and
are tracked by the **other** repository, never by this one; `.gitignore` says so at the
point of exclusion. `knowledge/catalogue.yaml` is the one deliberate exception, being
published manufacturer data, and it says so in its own header. The gate that keeps the
split honest is §3.4.
---
## 3. The discipline
### 3.1 The honesty invariants
**NOT RUN is never a pass. Silence that looks like success is the cardinal sin.** NOT RUN
renders **red**, with FAIL, and `ticketpage.py` carries a comment forbidding the next
reader from greying it out: a check that did not run tells you nothing about the piece, so
it must never look calmer than one that failed. One unauthorised exclusion hid forty
tickets and every check that ran came back clean (`scope.py`, §1.1); seven `die()` calls
let refusals leave through a dialog, so a batch where every panel refused produced a blank
report. Every refusal now goes through `not_run()`.
**`None` ("could not judge") is not `[]` ("judged, found nothing").** `ticketchecks.run_all`
disambiguates an empty return with a **skip probe**: `_SKIP_PROBES[check_name]` gives
either a reason or `None` (ran, found nothing). A check with *no* probe logs `RAN-EMPTY`
and renders NOT RUN reading "no probe declared" — unexplained silence is the defect, not
agreement. A probe's sentence must name a verifiable place: row 5.4 now separates *no
spec*, *a spec predating the field*, and *a design recording no mesh*. Every registered
check has a probe now, and it is test-enforced (§4.4) rather than a count to keep
re-auditing by hand.
**A declared out-of-scope row is a third state, not a silence.** The scope is declared in
the job layer, never inferred. No declaration means judge everything, and a mark whose
prefix cannot be read is never set aside — "I could not tell" may only resolve towards
judging.
**A model-read (`AI-*`) finding is always WARN**, never OK and never FAIL. With
`vision_escalation` on, grade-D items no comparison can settle go to a small model reading
one rendered view; the answer lands in `model_read`, never in `findings`, and **can never
set a checklist row**. `ticketvision` carries that provenance four independent ways (the
`source` field, an `AI-` code owning no row, its own list, its own report section), on the
assumption that an edit will drop one and the other three still have to hold. A model that
saw nothing wrong in one view is a reason to look, not to stop looking.
**Muting never silences a FAIL**, and a muted check still *runs*: `apply_muting` repaints
it grey rather than removing it, and MUTED gets its own tally bucket. "31 OK" when four of
them were nobody's judgement is the quiet lie the table exists to stop.
**A check may not claim what the tool is in no position to claim.** Rows 5.6 and 5.7 both
printed *"no reinforcing found on this piece"*; this plant models bars outside any rebar
category, so with `drawings.reinforcing_families` empty a **301-member** piece produced the
same words as a piece with no steel in it. They now say the tool could not **recognise**
any, name the key that would turn it on, and **stay red** — *a better sentence is not
permission to soften the state*. Two siblings landed the same day: 5.1 printed OK off one
of its two questions, so a check slot now takes a tuple and **any quiet half makes the row
NOT RUN**; and 5.3, a person's job by design, printed red because a hand-kept
`FOR ENGINEER` tuple omitted it — that list is now derived from the table.
### 3.2 The knowledge layer law
The mechanism is §2.3. The law is this: **job beats plant beats what everybody in the
trade knows** — not a tie-break, but a statement about where truth lives. The trade knows
a bar mark is a recipe; the plant knows its own hardware; the job knows what was drawn.
So **placement is an argument, not a preference**. Three examples from a single day:
- **Rebar stock lengths → the plant.** One number for every size let a 30 ft `#4` pass
where the plant stocks `#4` in 20 ft — a false OK on a real defect class. It became a
**map keyed by printed size, in integer inches** (`catalogue.rebar.stock_by_size`, values
supplied by the plant layer). As a *list*, a job adding one size would silently delete
the plant's whole table.
- **A job's odd-grade bar → the job.** The manual files every stock length under a
designation the owner says is retired; if it reappears it is an exception to this job,
so it is job knowledge.
- **Which end is up → public.** The plant layer carried `top_is_higher_global_z`, and both
obvious repairs were wrong. An assembly origin is placed when the assembly is made, and
**placing it is the statement**: that is how precast is built, not a house habit, so it
belongs in the repo catalogue. A key asking a plant to declare it is not an unread key,
it is the wrong question — and it named a *mechanism* rather than an answer, which a
layer must never be able to swap.
**Dormant and loud.** A key not yet wired ships **null**, with `*_verified: false` and a
written reason — never a guess. The reinforcing rule shipped that way and proved the right
*shape* around the wrong *field*; the flag did its job, because the rule never ran and the
report named the reason, where a guess would have left every bar in the job silently
un-recognised. Turning it on was two YAML fields and no code change. §4.3 lists what is
dormant now.
### 3.3 Never guess a plant fact
Parameter names, categories, family tokens and casting conventions are not code's to know.
They are reached through **roles** declared in the knowledge layers (`PARAMETER_ROLES`,
`knowledge.param_names`). Two incidents from one day:
- **Three guesses, three wrong.** An agent needed one plant parameter name and guessed it
three ways; none of the three existed in the model. `rvt.py params <id>` answers it in
one request. The precedent: the mark parameter name left the code entirely and became
the role `member.mark`.
- **Types read where the values were on instances.** A census of the reinforcing parameter
read family *types* only, but this plant writes that value on the **instance** — so the
census saw nothing and the agent told the owner the wall panels carried no reinforcing
at all. Measured properly, one piece had 112 bars and another 84. The rule now carries
`read_from: [instance, type]`: the value sits on one or the other, mutually exclusively
per element, and a reader taking only one finds nothing on half the model **and returns
a result that looks perfectly clean**.
**Measure it, ask the owner, or stand down with a written reason — never assume.** When a
bar-size parameter could not be measured, nobody guessed; it is still an open question
(§4.3). *"Ask the owner" is the third resort, not the second:* **search the transcripts
first** — §3.8, one-liner in `CLAUDE.md` §7.
### 3.4 Secrecy and the scrub gate
**No client, job or plant information anywhere** — code, comments, docstrings, commit
messages, examples. Where you need to refer to one, write the `CLAUDE.md` §3 placeholder
rather than deleting the sentence. Product codes are not identities and stay as they are.
`tools/scrub.py` is the gate, in two modes on purpose. `--worktree` is the audit: every
file git can see, tracked *and* untracked, and a hit anywhere is a hit. `--staged` is the
pre-commit hook: only staged files can block a commit, while untracked hits print loudly
without stopping unrelated work — because a gate that blocks unrelated work is one people
learn to pass with `--no-verify`. It also runs inside `tools/package.py`. The term list
lives in gitignored `.scrub-terms`, so the list of names is never itself the leak;
structural patterns (model filenames, absolute user paths) are checked whether or not
anyone remembered to add a term.
**The cautionary tale (2026-08-30).** The gate built each term as `\b<term>\b`. In regex
`_` and digits are word characters, so `\bTerm\b` never matched `PREFIX_TERM` — and
`PREFIX_ROLE` was the dominant naming convention here before the roles refactor. **The
terms most likely to be a real leak were written in the one shape the gate was blind to.**
The fix is letter-only lookarounds, and the sanctioned placeholders are now read out of
`CLAUDE.md`'s own table so the gate cannot drift from the rule. The rescan: working tree 0
hits → **4**; history 19 hits in 3 commits → **22 in 6**, all six already pushed.
**Assume every scan before that fix under-reported.** Editing a file does not remove a
name from the commit that introduced it — §4.4 has the decision still outstanding.
### 3.5 Revit safety
- **Never reload pyRevit. Restart Revit.** `reload_pyrevit()` tears down the engine that
owns the routes handler while the HTTP server still points at it, so the next bridge
request runs against a dead engine and takes Revit down — on a non-main thread, where
Revit cannot catch it. Measured 2026-08-14: 11 clean requests, one reload, the 12th
fatal. It happened twice. Our code never gets to run, so the guard sits outside Revit:
`startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load, and `rvt.py`
reads that log before *every* request and hard-refuses on two loads under one process id.
There is no `/reload` endpoint. If `rvt.py` says the bridge is unsafe, do not work around
it and do not curl the endpoint — ask for a restart, which picks up new buttons just as
well.
- **Never run an unfiltered collector; never guess an API enum.** 2026-08-18: one snippet
walked the whole document with `WhereElementIsNotElementType()`, touched `.Symbol` on
every result, and set `Options.DetailLevel` from a value reached by `__import__`. Revit
died with no exception and no journal entry, because **a bad value handed to a native
API is not a Python error**. Scope every collector; import enums by name, so a bad name
is a traceback rather than a dead process.
- **Transactions for writes; reads never need one** (`with tx('name')`). The production
model is read-only to agents: prototype read-only, show counts and a sample, and get an
explicit go-ahead before any mutation. **Nothing in the extension writes document data or
geometry, on purpose** — a wrong report can be disbelieved, but wrong geometry gets built.
The one exception is view state: Panel Lift Check opens a `tx('Show assembly')` to
isolate the piece temporarily and put an 85% transparency override on its non-insert
members, and writes both back off when it is done.
### 3.6 How the work is organised
The owner works through sub-agents. **One dedicated long-lived agent holds the bridge**,
and all live-Revit work routes through it. Everything else is split into **non-overlapping
file sets** and run in parallel, each landing committed **by explicit path** and pushed.
**`git add -A` is prohibited**, and it has cost twice. `cb1587d` swept in a
**deliberately broken** button that the tooltip agent had planted to prove its guard
fires, so HEAD briefly shipped a fake tooltip (repair and rule in `14de0d3`). And
`109d7df` — titled *"Today is the 30th, not the 31st"* — swept in the first half of
another agent's unfinished up-end work; the follow-up, `cf9e164`, opens by saying so.
A glob is the same mistake in a different coat: `git add .`, `git commit -a`,
`git add lib/` while another agent is mid-edit. **Name the files.**
### 3.7 Testing
**Host-testable by design.** The whole suite runs on plain CPython with no Revit and no
licence:
```bash
python -m pytest WillTools.extension/tests -q # the extension
cd panellift && python -m pytest -q # the solver
```
**Run them; do not quote them.** Two readings on 2026-08-30, hours apart on the same
machine: `panellift` 272 passed both times, while the extension went from 1807 passed / 1
failed to 1860 passed / 1 failed, because another agent was adding tests in between. That
is the normal condition here, and it is why every count in this document is date-stamped
or replaced by a command. The one thing that held across both readings is the single
failure — a real one, environment-coupled, described in §4.4. Several documents in this
tree still quote counts that were true when written and are not now (§4.6).
`tests/conftest.py` is what makes it possible: it registers a bare module object as
`sys.modules['vibe']` with `__path__` pointed at the real package, so `vibe/__init__.py` —
which imports the Revit API the moment it runs — never executes. That reaches any module
touching Revit only *inside* functions needing a live `doc`, and cannot reach one
importing the Revit API at **module scope**. `ticketqc.py` / `ticketchecks.py` are the
standing example: `ticketqc` imports eight `Autodesk.Revit.DB` names at the top and
`ticketchecks` imports `Finding` from it, so freeing them takes more than moving an
import. `test_ticketchecks_host.py` reaches a few pure decisions through a deliberately
**inert** stub of those names: if an edit makes one really call the API, the test raises
instead of quietly passing.
**Pin against a written-down golden, not a recomputation.** The takeoff count is pinned to
numbers typed into the test, because *a pin that recomputes both sides pins nothing* — it
agrees with any refactor, including a wrong one.
**Tombstone tests** keep a deleted thing deleted. `test_no_cap_survives_anywhere_in_the_module`
asserts that `ticketpage` has no `capped_note` and no `NOTE_CAP`: that 180-character cap
had truncated a debond run mid-number, in both the panel and the text file.
### 3.8 Search the transcripts before you ask him
**A question he has already answered is worse than a question nobody asked.** It costs him
time, it tells him nobody looked, and — the real damage — it invites him to answer a second
time. Two rulings then sit on the record with nothing to say which governs, and the next
agent gets to pick. The rule, in his words, 2026-08-31: *"开发纪律里面再加一条,有问题先搜索
聊天记录。搜不到再来问我。"*
**The incident that bought it (2026-08-31).** An overnight audit put
`handling.tension_limit_sqrt_fc` at the top of the owner's list as **A0**, under a heading
saying it was *"the only open item that changes a live engineering answer"* — reasoning
that he had made the stress limit a per-job choice, that the job layer held nothing, and
that the job was therefore running on 7.5 inherited from a layer he never said should
decide it. Every one of those facts was true. The conclusion was still wrong: he had ruled
the value twelve days earlier, **2026-08-19: *"就用7.5sqrt。"*** The plant layer holds
exactly 7.5, the tool had been doing the right thing the whole time, and A0 existed only
because nobody searched. Escalating it would have spent his morning re-deciding a decided
thing.
Note what the audit actually did wrong, because it is the subtle half: it found a **later**
quote (2026-08-30, *"应力极限写入 projectconfig … 让用户自己填写。I can see it both ways"*)
and read a ruling about **where the knob lives** as if it vacated the ruling about **what
the number is**. Those are different questions and both answers stand. A mechanism ruling
does not retract a value ruling — and a search that returns two hits is not finished until
you have worked out whether they even disagree.
**Searching is cheap. `CLAUDE.md` §7 has the one-liner; the measured cost over the whole
65 MB of transcripts is 0.2 seconds.** "It is expensive to look" is not available as a
reason, and neither is "I did not know where they were".
**But a search that finds nothing is not proof he never said it.** The transcripts are one
machine's history, they are not indexed, they hold his messages in two languages, and the
term he used is very often not the term you searched. So the honest report is
***"I searched X, Y and Z and found nothing"* — never *"he has never ruled on this"***.
State the terms; the next reader can then extend the search instead of repeating it. This
is §3.1's invariant wearing different clothes: not-found is a NOT RUN, not a pass.
When you do find it, **write it down where the value lives** — beside the number, with the
date and his words verbatim — so the next agent finds it by reading rather than by
searching. A ruling recorded only in a transcript will be asked again.
### 3.9 Three tiers: the coordinator decides, Sonnet explores, DSV4F labours
**The coordinating agent (Fable / Opus class) is the most expensive process on this
machine, and it spends its budget on four things only: planning and splitting work, judging
results, deciding anything irreversible or anything that touches live Revit, and talking to
the owner.** Below it sit two cheaper tiers, and they are not interchangeable:
- **Exploration — reading a codebase to answer "how does this work / what would it take" —
goes to a Sonnet sub-agent** (the harness's `Agent` tool with `model: sonnet`; or Opus
driving Sonnet), never to DSV4F. Exploration needs judgement about what matters, and the
owner's ruling is that DSV4F does not get to make it. Sonnet returns a note with
`file:line` citations; the coordinator reads the note, not the files, and goes to the
source only for the fact a decision hinges on.
- **Labour — extracting tables from documents, mining PDFs into notes and fixtures,
translating, classifying, reformatting, and writing code to a settled, pre-split spec —
goes to DSV4F (DeepSeek V4 Flash)**, with V4 Pro only where Flash has demonstrably failed
on the same material. The owner, 2026-09-05: *"探索你带着sub sonnet或者让opus带sonnet去做。
DSV4F别做探索。DSV4F就干点儿体力活儿"*.
**The incident that bought it (2026-09-05).** `CLAUDE.md` carried *EXPLORE (me) → WRITE
(DSV4F) → REVIEW (me)* with the sentence *"exploration never goes to DSV4F"*, and no rule
said the exploring had to be cheap. Under that, the coordinator spent an afternoon on
`docs/pa-web-spec.md`, `tools/pa_web/static/app.js`, `draw.js` and every key
`beamcalc/model.py` reads from a job file — twelve consecutive file reads on the most
expensive model, to produce a note a Sonnet sub-agent writes as well. The owner's words:
*"用DSV4F。这些东西开发纪律里面没有?"* — and until that day this section did not exist. The
first draft of the fix then over-corrected and dispatched that same reconnaissance to DSV4F;
he stopped it within minutes with the ruling quoted above. Both halves are the rule: the
coordinator does not do the reading, **and** the reading is Sonnet's, not Flash's.
**Which DSV4F route.** Two exist and they do not share a balance. `cheap` (in `PATH`) is
the DeepSeek platform direct; on 2026-09-05 that balance was **negative** (`402 Insufficient
Balance`) after the `sample_job` document mining ran V4 Pro at 65k output tokens with parallel retries, and
every `cheap` call and mining script died with it. **Command Code (`cmd -p "<brief>" --model
deepseek/deepseek-v4-flash --yolo --no-session`) is a separate account with its own quota**,
and the owner's instruction that day was to use it. Check the balance before dispatching a
batch, not after it has failed; a 402 on the first call means every call.
**Detach anything that outlives a turn.** The coordinator runs as a systemd service under a
watchdog that restarts it — three times on 2026-09-05: 13:11 and 14:11 for a mis-detected
gateway, 15:09 for a stall. A service restart kills the whole cgroup, and each time it took
the background workers with it: seven `cmd` mining jobs launched from the coordinator's
shell, four dead mid-write, output files 0 bytes or absent. A worker launched with
**`systemd-run --user --unit <name> …`** is its own transient unit and survives; one
launched with `&`, `nohup` or `setsid` from the coordinator's shell does not. Write the
worker's result to a file and its status to a log (§3.1 applies: an empty log is a NOT RUN,
not a pass), so a coordinator that has lost its memory can read what finished instead of
asking.
**Split before you send, and size to one reply.** A whole-feature brief truncates at
max-tokens and reads as "it skipped the hard parts"; one function, one section, one file's
change per task (`CLAUDE.md` § delegation, owner 2026-09-03: *"确认了边界的活儿,拆细了再让
DS 干"*). The gates — pytest, scrub, review — run on the coordinator's side, never inside the
worker.
---
## 4. Where it stands
Written 2026-08-30 against `ff10870`. The working tree was clean when this survey was
taken; it is edited concurrently by several agents, so check `git status` yourself rather
than trusting that. `docs/checklist-code-map.md` was rebuilt 08-30 17:19 and commits have
landed since.
### 4.1 The checklist, row by row
Authority is `CHECKLIST_ITEMS` in `lib/vibe/ticketpage.py`.
| # | grade | state | note |
| --- | --- | --- | --- |
| 1.1 sheet name = mark | A | running | no skip probe (§4.4) |
| 1.2 sheet design reduces to piece design | A | running | judged in `ticketqc.check_ticket`; no named check fn |
| 1.3 one ticket set per piece | A | running | no skip probe |
| 1.4 view names refer to this design | B | running; **dormant out of the box** | needs `nomenclature.design_number_re` — plant layer, not `DEFAULTS`. Re-graded A→B 08-30 |
| 1.5 call-outs do not overlap | C | running, built 08-30 | rotated annotations set aside under `CALLOUT-OVERLAP-UNREAD`; no sample, untested |
| 2.1 printed size vs modelled | B | running | |
| 2.2 weight three ways | A | running | the *measured* leg needs run option `expensive_geometry` (default ON); off, the row is NOT RUN naming it |
| 2.3 unit weight | A | running | |
| 2.4 cast extras in the weight | A | running, built 08-30 | verified end-to-end on a real ticket |
| 3.1 f'c · 3.2 f'ci | A | running | |
| 3.3 release ratio | B | **muted on this job**, still runs | owner's own dated sentence: no panel here has the thing it asks about |
| 4.1 strand count · 4.2 diameter & grade | B/A | running | |
| 4.3 jacking force | A | running | `tolerances.it_lb` wired 08-30 to **100 lb**; the job layer's `1.0` deleted, not corrected |
| 4.4 strand positions vs chain from rail | B | running | stands down with a reason where there is no strand geometry |
| 4.5 debond call-out | C | running, built 08-30 | consumes 4.7; where 4.7 could not judge, 4.5 is NOT RUN with 4.7's reason |
| 4.6 rows & spacing | A | running | same check as 4.4 |
| 4.7 strand debonded where concrete runs short | B | running | |
| 5.1 BED PLAN call-out embed exists | A | **half running by construction** | `check_bom_members` runs; `check_detail_callouts` reports NOT RUN *naming the sheets nobody read* wherever the plan cites a bubble. Only a plan citing no detail can be OK |
| 5.2 embed in model appears on ticket | A | running | |
| 5.3 face designations vs model side | C | **FOR ENGINEER by design** | printed a red NOT RUN on every ticket until 08-30 — missing from a hand-kept tuple (§3.1) |
| 5.4 mesh / WWF designation | A | **muted on this job**, still runs | no mesh on this job |
| 5.5 bar marks legible | D | **FOR ENGINEER by design** | a property of the picture |
| 5.6 bar marks unique | B | running, built 08-30 | split out of the old 5.5; runs where the reinforcing rule resolves (§4.3) |
| 5.7 no bar longer than its stock | B | running, **against one generic number** | the per-size stock table is built, populated and unreachable (§4.3) |
| 6.1 insert capacity · 6.2 edge distance | A | running | no skip probe |
| 6.3 handling result | A | running | no skip probe; three silent `return []` paths |
| 6.4 dunnage not under an opening | A | running, probe added 08-30 | open ruling on the all-blind-embed path: NOT RUN (today) or OK |
| 6.5 note views say something | B | running; **dormant out of the box** | needs `notes.instruction_view_sets`; `DEFAULTS` ships it empty |
| 7.1 interference | B | running | WARN-only by ruling, over 157 findings on 57 archived tickets. Do not re-infer that the rest are noise — that was overruled once |
| 8.1 unresolved design-summary lines | C | running | the one true FAIL of the 08-30 live run ("no probe declared"); probe added |
| 8.2 no invisible characters | A | running | no skip probe |
Nothing on the list is unbuilt. Two rows are a person's job on purpose, two are muted with
a written dated reason, the rest compute. What varies is how much of its question each row
actually reaches — **5.1 and 5.7 claim least**.
**The table above is the 34-row census this section was written against and is now stale
by count** — `checklist.master` in `knowledge/catalogue.yaml` carries **46** entries as of
2026-08-31 (verified by reading `len(ticketpage.CHECKLIST_ITEMS)` on a fresh load; run it
rather than trusting this number later). The rows above through 8.2 are still real; twelve
more (`4.8`, `5.8`–`5.15`, `6.6`–`6.8`) have landed in the master since and are not
individually described here. Extend the table from the catalogue, not from memory of it.
### 4.2 The other tools
| tool | state |
| --- | --- |
| **Panel Lift Check** | Finished, in daily use, no licence needed to test. Numerics open (§4.4). The per-stage input control aligned to the reference tool's `HandlingData` is a **mapping document only** (`docs/panellift-ewf-alignment.md`); nothing is built from it |
| **Reinforcing Suggestion** | Finished. End-to-end 08-30: cold 10 s, warm ~1 s, offline byte-identical to the bridge. Position recommendation defaults **off**; one invariant test still owed |
| **Ticket QC** | Finished, the flagship. Six in-process `vibe/ticket*.py` modules — `wc -l WillTools.extension/lib/vibe/ticket*.py`, and expect the number to have moved since you last looked; it is what §4.5 is about |
| **Snapshot & Close** | Finished. Phases A and B measured green; phase C (the deferred detached copy) is off unless the job layer enables it, and journals its own silence |
| **Takeoff** | Finished. `nomenclature.ticket_unit` wired 08-30, so the tool stops deciding for itself what one ticket covers |
| **Job Recon** | Finished. Self-loading, scope-aware, 9.3 s on the real job |
| **Design Extract** | Finished, with its caveat printed on it: every value it writes is machine-read and unverified |
| **Self Check** | Finished |
| **Gravity (WIP)** | Neither this nor Lateral writes to the Revit model — both are extractors: read the model, write a file elsewhere. Gravity is meant to extract information and write **Excel**. Today it does none of that: **a button with nothing behind it, and it says so.** ~200 lines of placeholder printing the open questions and assuming nothing. Blocked on the owner supplying old jobs and a control spreadsheet |
| **Lateral (WIP)** | Meant to extract lateral-resisting members and write **RISA** — a structural analysis package — its input format. Half-built, and it does real work: it extracts marked members and writes a JSON intermediate model, two CSVs, and a `.r3d.DRAFT.txt` (`.r3d` is RISA-3D's own file extension) — the `.DRAFT` suffix deliberate, so it cannot be loaded into RISA by accident. What the RISA format needs beyond that is not established |
### 4.3 Dormant, and what would wake it
| dormant | what wakes it | who |
| --- | --- | --- |
| `drawings.reinforcing_rule.scopes` — empty, so 5.6 and 5.7 see one population | a ruling on three edge cases: bar-formed hardware carrying the token (one is a strand loop, with neither mark nor stock length), and reinforcement carrying none | owner, A1 |
| `catalogue.rebar.stock_by_size` — populated, read by `knowledge.rebar_stock()`, **never called by `check_bar_stock_length`** | which parameter carries a bar's size | owner, A3 |
| 5.3 as a computed check | what makes an elevation line dashed — line style, subcategory, or nested detail items. The view's 17 filters were read; none carries it | owner, A5 |
| the up-end rule | woken 08-30 on evidence over 282 assemblies. Still open: the vertical-panel case, and the colour→axis mapping, which is inference not measurement | owner, A4 |
| `tolerances.psi` (inert, muted not deleted) and `details`, the 70-entry job catalogue nothing reads | one word each: wire or delete | owner, D1 / D7 |
| `drawings.rebar_must_appear_on` / `section_a` / `section_b` | a dated promise queued behind the current phase — not drift | queued |
| `check_detail_callouts` as a verdict | something that reads a detail sheet's *contents*. Not built; needs the model | build |
| vision escalation (`AI-` codes) | off by default; owns no checklist row and cannot flip one (§3.1) | config |
### 4.4 Known defects and unfinished business
- **`qc-run.json` carries findings, not the run log.** `ticketvision.qc_json` takes
`findings` + `model_read` and nothing else — checklist rows, mutes and `run_all`'s log
stay behind. A NOT RUN row's *reason* reaches the HTML page and `last-ticketqc.txt` but
never the machine-readable file, so anything reading the JSON cannot tell a row that
passed from a row nobody asked.
- **A failing test, environment-coupled.** `test_reinforceoffline.py`'s no-index case fails
here because `cli.resolve_index` finds the real, gitignored `knowledge/job/` and answers
with a job list instead of the three-ways message. Green on a clean checkout, red on the
owner's machine.
- **Closed, 2026-08-31: every registered check now has a skip probe, and it is
test-enforced rather than a hand count that can silently rot again.**
`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` walks
`ticketchecks.CHECKS` against `_SKIP_PROBES` and fails, naming the orphan, the day a new
check ships without one. A silent `return []` from an unprobed check still logs
RAN-EMPTY and prints a red NOT RUN reading "no probe declared" — the mechanism is
unchanged — but the gap this bullet used to track (eight bare entries, then seven) is
gone; run the test rather than trusting a count copied in here.
- **Client and plant names are in pushed history** (§3.4). **Untaken decision:** redact the
working tree only, or rewrite history and force-push.
- **The lifting solver's numerics.** `docs/lift-check.md`, written up, unstarted. Not a
speed question: today's recommendations are not determined to better than several inches
and on some pieces are not the best layout available. It gates any further
insert-placement work.
- **`families.wwf_prefix` has two defaults** — `takeoffgather` falls back to empty (no mesh
detection), `ticketqc` to `'WWF'`. Reachable only when a layer sets the key null, which is
a plant saying it has no welded wire fabric. If that is what null means, `ticketqc` is
wrong.
- **Smaller, recorded:** `read_ticket` costs 37.6 s on a large piece, entirely outside the
checking wall and with no code-level cure; and `catalogue.rebar.stock_exempt_marks` is
empty pending the 60-ft continuous-bar ruling.
### 4.5 The roadmap, as it actually exists
**`docs/architecture-migration.md` — nine steps, approved 08-30.** In-process IronPython
keeps only the gatherers; judgement, matching and rendering move to host CPython, fed by
the versioned JSON of §2.2.
*Step 1 landed 08-31*: Ticket QC writes `evidence/run-<stamp>.json` on every press and
**nothing reads it**. As built it corrected its own plan — four live-object holdouts, not
two, so the serialiser gates on type. **§5.1, parity on real data, is not met and could
not be**: it needs a press, and the bridge was held.
*Step 2* buys a parity proof and nothing else: a host runner judging the 13 Revit-free
checks offline against the twelve-piece audit set. Cost M, no visible change. *Step 3* is
where it pays — the button calls that runner (reusing `liftexport.find_python`/`run`),
making it the first press where a `ticketchecks` judgement edit is visible **without a
Revit restart**. Cost S.
**§8.2, honestly:** `engine: clean` is landed on three buttons and buys nothing today. A
clean engine is already the default here, because `extension.json` declares no `url`, so
pyRevit's package lookup drops the extension and rocket mode never takes the clean engine
away — verified two ways offline. It is a **pin, not a lever**, against the day somebody
adds a `url`. **The one-minute behavioural check — press, edit a rendered string, press
again without restarting — has still never been run**, and no bridge call answers it.
**`QNA/eriksson-gap-inventory.md` — the commercial-parity list.** 46 quantities: 10 at
parity, 2 needing no build, **34 to build** (17 S · 5 S-after-dependency · 5 M · the rest
larger). **Mcr is the cheapest first win** — S, P, A and e are held at every station and
`modulus_of_rupture_psi` is written. The load-bearing finding: the reference tool is a **1D
single-member analysis, not 2D FEA** — every plot is a function of x, with no mesh
reference and no Mxy/Wood-Armer column in 18 pages. That reframes tier B entirely. The list
needs the owner's tick before anything on it is built.
Beyond those two, the only genuine plans are the reinforcing-suggestion brief's open
granularity questions and §4.3's queued coverage rule. The rest of `QNA/` is record, not
plan.
### 4.6 Where the docs and the code disagree
Several documents were accurate when written. Where they are not, the code wins.
- **Every test count quoted in this tree is stale.** `CLAUDE.md` says `panellift` has 76
tests and the extension suite is `49 passed`; `README.md` says `34 passed`. Run the
commands in §3.7.
- **Every line count quoted in this tree is stale, recent ones included.**
`architecture-migration.md` §3 warns it will drift and has drifted again; on 2026-08-30
`ticketqc.py` moved between two measurements minutes apart, because other agents were
editing. Use `wc -l`, and read any line count as a date-stamp rather than a fact. §3.3's
"26 registered checks" is now 27. **Verdicts hold; numbers do not.**
- **Design Extract calls no AI**, despite `README.md` ("hands the scrape to Claude Code")
and `CLAUDE.md`. The script says "no AI call, no subprocess", and no button routes
through `vibe/aiextract.py` any more.
- **`configdump.py` and `CLAUDE.md` say four knowledge layers**; `layer_values()` handles
five, with config behind `include_config=False`.
- **`knowledge.load()`'s docstring drops a folder** — it gives the job layer as
`<model folder>/knowledge.yaml`, where `designspec.knowledge_path()` returns
`<model folder>/_ticketqc/knowledge.yaml`.
- **`docs/handoff.md` calls Panel Lift Check "the one under development"**; it is one of
the four finished large buttons, and the same file's "Never migrate" section calls it
*already migrated*.
- **Batch speeds disagree across three files** — `panellift/cli.py` 41 min / 40 s,
`docs/lift-check.md` ~40 min / 2 min, `PanelLiftCheck/script.py` 38 min / 2.5 min. The
last is the most specific and names its path.
- **`rails.left_rail_is_fixed` is a dead flag** (`docs/knowledge-override.md` says so
correctly): one reader, `aiextract`, which puts it in a prompt. No check consumes it.
+52
View File
@@ -0,0 +1,52 @@
# QNA — 有啥不明白的,写在这儿
这个文件夹只有一个用途:**你随手记下没搞懂的问题,我看到了就回答。**
不用等到我在旁边。想到了就写,反正记下来的问题总比忘掉的强。
---
## 怎么问
打开 `questions.md`,在最下面加一段:
```markdown
## Q: transfer length 那个 33.2 in 是怎么来的?
```
`## Q:` 开头,一行说清楚就行。要贴代码、贴报错、贴一段 job JSON 都可以,
跟在下面就是了。**不用管格式,不用管排版。**
写完存盘。就这样,没有别的步骤。
## 怎么得到回答
我会在问题下面直接补一段:
```markdown
**A**(2026-08-22):ACI 318 的 ℓt = f_se·db/3 ……
```
答案就跟在问题下面,一个文件从上往下读就是一份记录 —— 不用在两个文件之间来回翻。
---
## 说清楚一件事:我不是 24 小时在线
盯这个文件夹的是 `tools/qna_watch.py`:它是当前 agent 会话里的一个后台任务,
一有改动就退出并叫醒我去读、去做,做完再重新布防。**只有这个会话开着的时候才有效**。
终端关了、电脑重启了,就没人看了 —— 那之后写进来的问题会一直躺在
`questions.md` 里,等下次会话开始时 watcher 一启动就会把积压的改动先报出来,
我读一遍,照样会答,只是没那么快。
所以:**积压是正常的,丢掉是不会的。**
下次开工的时候直接跟我说一句「看下 QNA」,我就把没答的都补上。
## 别在这儿写客户名和项目名
跟仓库里别的地方一个规矩(见 `CLAUDE.md` 第 3 节):不写业主名、不写工程名、
不写模型文件名。产品编号不算 —— design number、piece mark、五金件料号都可以照写。
保险起见,`questions.md` 本身是 gitignore 掉的,不会被 push 上去。
只有这份 README 进仓库,因为它是约定,不是内容。
哪天你想把问答记录也存进版本库,把 `.gitignore` 里那两行删掉就行。
+173
View File
@@ -0,0 +1,173 @@
# WillTools
Precast engineering tooling for Revit: shop-ticket QC against the design
summary, and PCI Chapter 8 handling calculations. Built for one precast
plant's workflow and shared here so a colleague can try it against their own.
Two halves, deliberately kept apart. `WillTools.extension` runs inside
Revit, on the model. `panellift` is plain CPython outside Revit — the same
handling arithmetic, testable in seconds with no Revit licence at all.
## Quick start
```bash
git clone https://github.com/will123song/pyrevit-dev.git
cd pyrevit-dev
python tools/setup_machine.py
```
That installs the host-side Python dependencies and proves the install with
real answers — not just exit codes: it runs `panellift` against a published
worked example and checks the arithmetic against the number the article
prints. Read [INSTALL.md](INSTALL.md) for what to do with the output, and
for pointing pyRevit at the extension so the ribbon shows up in Revit.
Try the handling calculator immediately, no Revit needed:
```bash
python -m panellift examples/example-panel.job.json -o report.html
```
## What works without knowledge layers, and what stands down
This distribution ships **no knowledge** — not a plant's parameter names,
not its casting-bed conventions, not even the published hardware catalogue.
See [`knowledge/README.md`](knowledge/README.md) for why and what the shape
of a real layer looks like.
Runs fully, with no knowledge at all:
- `panellift`'s handling checks against a job file — the example above, or
one you build by hand.
- The ribbon itself, and **Self Check**, which tells you exactly what is and
is not configured on this machine.
- `python tools/setup_machine.py --check` — reports the no-knowledge state
as a **pass**, not a failure. A knowledge-less trial install is an
expected, first-class state here, not a broken one.
Stands down cleanly, with a stated reason rather than a wrong or silent
answer: anything that needs a plant's parameter mapping, the published
hardware catalogue, or the master shop-ticket checklist — most visibly,
**Ticket QC**, which refuses to open with a one-line reason rather than
rendering a report that looks like every ticket passed. Ask the owner for
real knowledge layers once you are ready to point this at an actual job.
## `WillTools.extension` — inside Revit
A pyRevit extension. Runs on IronPython 2.7 on Revit's UI thread, so it does
the things that need the model and nothing that needs testing.
One tab, **WillTools**, four panels. Reorganised 2026-09-05: the modelling
and ticketing tools now sit behind **Tools**' `Modeling` pulldown, and the
analysis tools — finished and unfinished alike — sit behind **Design**'s
`DesignHub` pulldown.
| Panel | Button | |
| --- | --- | --- |
| Tools | **Modeling** ▾ | A pulldown. The order is the order of the work: suggest the reinforcing, check it once it is modelled, check it once it is ticketed. |
| | ↳ Reinforce Suggestion | Suggests reinforcing for a design from what comparable designs already carry. |
| | ↳ Reinforcing QC | A cut-down Ticket QC for a panel whose reinforcing is finished but whose ticketing has not started — only the checks whose evidence is the panel's modelled content run. |
| | ↳ Ticket QC | Checks every shop ticket of a design against its spec — f'c, f'ci, I.T., strand count/grade/diameter, W.W.F., unit weight, pattern legend, size envelope. Needs a knowledge layer; see above. |
| | **Snapshot** | Takes a timestamped detached copy of the model at close-out. |
| Setup | **Config** ▾ | A pulldown. The commands you run to get a job ready or to look at it sideways, rather than the ones you press all day. |
| | ↳ Project Config | Writes the project config layer: every setting that is the engineer's to make, in one file, wherever they want it. |
| | ↳ Design Extract | Scrapes a design packet — every sheet whose number reduces to the design, and every view placed on them — reads it with the regex/geometry parser in-process, writes a reviewable `spec.json`. No AI call, no subprocess, nothing that leaves Revit. Run once per design. |
| | ↳ Self Check | Probes everything the tools assume about this machine, this Revit and this model. Run it first on a new computer. |
| | ↳ Job Recon | Reconciles the issued ticket set against the whole job — the question no per-ticket check can answer, because a ticket that is not there cannot be checked. |
| | ↳ Takeoff | What the job contains, per piece and in total: hardware by part number, reinforcing by bar mark. |
| Design | **DesignHub** ▾ | A pulldown. PanelLiftCheck and PanelAnalysis are finished and grouped here with the other analysis tools; the four rows after them are buttons-and-docs only and carry `(WIP)`. |
| | ↳ Panel Lift Check | PCI Chapter 8 handling check on the piece, from the insert positions the model already holds. |
| | ↳ Panel Analysis (WIP) | Opens an input-confirmation form, pre-filled from the model, feeding a Stage-0 self-weight solve; the full in-place/wind/service engine is not built yet. |
| | ↳ Beam Analysis (WIP) | Placeholder; nothing is implemented. |
| | ↳ Connection Analysis (WIP) | Placeholder; nothing is implemented. |
| | ↳ Gravity (WIP) | Gravity load chasing. Unfinished; today the button writes nothing at all. |
| | ↳ Lateral (WIP) | Lateral extraction. Unfinished; today it writes a JSON model, two CSVs, and a RISA draft that is deliberately not a real RISA input yet. |
| Lab | **GOD_MOD** | Opens Claude Code in a terminal on this repository, wired to the running Revit through a local bridge. Optional — every engineering button runs without it. |
Anything half-built lives behind the **Design** panel's pulldown and carries
`(WIP)` in its name, so nothing unfinished ever sits beside a tool that
works on **Tools** or **Setup** — `DesignHub` itself is the one pulldown
that mixes finished and unfinished rows, because the owner wants the
analysis tools grouped together rather than split across panels.
The design intent lands on disk as JSON that a human reads and corrects before
anything trusts it. Repeated checking is then pure arithmetic — no model, no
judgement, same answer every time.
See [INSTALL.md](INSTALL.md) to deploy it, including on a different Revit
version.
## `panellift` — outside Revit
PCI Design Handbook Chapter 8 handling checks: stripping and yard handling of
flat panels, from the insert positions the model already holds.
Plain CPython, **no dependencies**, and no Revit import anywhere. That is the
point — a calculation whose output reaches a plant has to be verifiable, and
verifiable means a test suite that runs in seconds without a licence.
It reproduces every published number in the ASPIRE Winter 2019 worked examples
(which reprint PCI Table 8.3.1 and Figure 8.3.2), and it *derives* PCI's
0.0214 moment coefficient from the beam solution rather than quoting it:
```bash
cd panellift && python -m pytest
```
**Run it rather than trusting a number printed here** — a suite grows every
day and a README does not.
See [panellift/PLAN.md](panellift/PLAN.md) for what is built, what is next,
and the idealisations that are known to be idealisations.
## Filing feedback
Please do — that is what this trial is for. Two templates when you open an
issue: **Bug report** for something that crashed or gave a wrong answer,
**Question** for something that was unclear.
One thing worth knowing before you file: **this tool refuses rather than
guesses, by design.** A row that reads "NOT JUDGED" with a stated reason —
an unmapped parameter, a missing knowledge layer, a check that legitimately
has nothing to say about this piece — is very often the tool working
*correctly*, not a bug. The bug report template asks you to tell these two
apart:
- **Wrong answer** — it computed something, and the something is wrong, or
it crashed. That is a code bug; file the Bug report template, and paste
`python tools/setup_machine.py --check`'s full output — it is the single
most diagnostic thing you can attach.
- **Refused, and I think it shouldn't have** — it declined to answer and you
believe it had enough information to. That is usually knowledge-layer
work (a parameter role that needs mapping, a rating that needs
publishing) rather than a code fix — file it as a Question, and say what
you expected it to know.
**No client or job data in an issue, a PR, or a screenshot — ever.** See
[CONTRIBUTING.md](CONTRIBUTING.md) for the placeholder convention this
project uses everywhere instead.
### What to ask the owner for
This distribution ships no knowledge on purpose. To point it at a real job
you will want, from the owner directly (not filed as an issue — these are
not code, and they identify a real plant):
- a **plant knowledge layer** — your office's parameter names, casting-bed
conventions, and rigging defaults;
- the **published hardware catalogue** — manufacturer ratings for lifting
inserts and connectors, plus the master shop-ticket checklist Ticket QC
reads;
- sample decks or a walkthrough, if you want a guided first run rather than
starting from the example in `examples/`.
## Working on this
- **Never use pyRevit → Reload. Restart Revit.** A reload leaves the routes
handler pointing at a dead engine and the next bridge request kills Revit.
- IronPython 2.7 syntax under `WillTools.extension/`; modern Python is fine in
`tools/` and `panellift/`.
- Any change to a model needs a Transaction. Internal units are decimal feet.
- Scope every `FilteredElementCollector`. An unfiltered one crashed Revit.
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
@@ -0,0 +1,12 @@
title: Beam Analysis (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside ConnectionAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Beam Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own
# instruction to put a button here for it. Same posture as GravityLoadChase
# and LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not what a "beam" is in this model's terms,
# not which code governs, not read-only or not. None of that is guessed
# here; this button exists so there is somewhere to build it once it is.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Beam Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Beam Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Beam Analysis', exitscript=True)
@@ -0,0 +1,12 @@
title: Connection Analysis (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside BeamAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Connection Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside BeamAnalysis, on the owner's own instruction to
# put a button here for it. Same posture as GravityLoadChase and
# LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not which connections, not which code
# governs, not read-only or not. None of that is guessed here; this button
# exists so there is somewhere to build it once it is.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Connection Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Connection Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Connection Analysis', exitscript=True)
@@ -0,0 +1,14 @@
title: Gravity (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration. This tool is buttons-and-docs only until the owner
# provides worked examples; the suffix is what stops it being mistaken for a
# finished tool by anyone who opens the DesignHub dropdown. It sits on the
# title rather than the second line now because a pulldown row has no second
# line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
#
# Gravity load chasing -- PLACEHOLDER. Nothing is implemented.
#
# It does not write to the Revit model and never will -- like Lateral
# Extract, this is meant to be an extractor: read the model, write a file
# elsewhere. This one's target is Excel -- it is meant to extract
# information and write an Excel workbook. The owner's framing, verbatim
# (2026-08-30):
#
# gravity-load-chasing 是设计,但是我一两句话说不清楚。这不是一个现在就要推的
# 项目,先做个按钮跟占位符出来。这将是一个不小于 panel_lift 的功能性按钮。
#
# "I cannot say it in a sentence or two" is a statement about the problem, so
# this file does not try to say it either. It states what is settled, states
# that the rest is not, and prints the list of open questions -- above all the
# ones only he can answer, because those are what the placeholder is FOR. A
# placeholder that quietly starts making engineering assumptions is worse than
# an empty one; a placeholder that collects the open questions is worth having.
#
# The full document is QNA/gravity-load-chasing.md. This script deliberately
# carries the substance rather than only a pointer: the person who presses the
# button is in Revit, not in a text editor, and "see the file" is how a
# placeholder becomes a dead end.
#
# WHAT THIS FILE MUST NOT GROW INTO
# ---------------------------------
# No arithmetic. No load model, no tributary rule, no accumulation. No
# knowledge key, no schema field, no config default, no data structure, no file
# format. None of that has been chosen by anybody, and structure invented ahead
# of the method is how a placeholder turns into a set of assumptions nobody
# agreed to. If something looks obviously needed, it goes in the QNA document
# as a PROPOSAL and waits.
#
# It also reads nothing. Not geometry, not parameters, not the knowledge
# layers. It opens no Transaction, because there is nothing to write and there
# never will be from this file. The only thing it touches is the model's title,
# so the report says which session printed it.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Gravity (WIP)'
import os
from pyrevit import revit, script
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Gravity Load Chasing'
NOTES = 'QNA/gravity-load-chasing.md'
def notes_path():
"""Absolute path to the design-notes document, if it can be found.
Four levels up from this file is the extension folder; one more is the
repo. Purely a convenience for the reader -- a miss costs a line, never
the report.
"""
try:
here = os.path.dirname(os.path.abspath(__file__))
repo = os.path.abspath(os.path.join(here, '..', '..', '..', '..'))
found = os.path.join(repo, 'QNA', 'gravity-load-chasing.md')
if os.path.isfile(found):
return found
except Exception:
pass
return None
# --------------------------------------------------------------- the report
output.print_md('# {0} &mdash; NOT IMPLEMENTED'.format(TITLE))
output.print_md(
'**None of this feature is built.** There is no calculation behind this '
'button, and nothing was read from the model to produce this page. That '
'is deliberate and it is the whole state of the work: a button, and a '
'document holding the questions.')
try:
output.print_md('Model **{0}** &mdash; opened, not read.'.format(doc.Title))
except Exception:
pass
output.print_md('---')
output.print_md('## What it will be')
output.print_md(
'Gravity load chasing, as the owner put it on 2026-08-30: *"it is '
'design, but I cannot say it in a sentence or two. It is not a project '
'to push right now &mdash; make a button and a placeholder first. It '
'will be a functional button no smaller than Panel Lift Check."*')
output.print_md(
'One thing beyond that quote is already settled: like Lateral Extract, '
'this tool does not write to the Revit model and never will. Both are '
'extractors &mdash; read the model, write a file elsewhere. This one\'s '
'target is **Excel**: it is meant to extract information and write an '
'Excel workbook. Nothing here does that yet.')
output.print_md(
'Only four things follow from the quote itself, and nothing here will '
'pretend to more:')
output.print_md(
'1. It is **design**, not checking.\n'
'2. It **cannot yet be specified**, and that is a fact about the '
'problem, not about anybody\'s time.\n'
'3. It is **not being pushed now**.\n'
'4. When it is built it is a **full feature** &mdash; its own '
'engineering core, its own report, its own tests &mdash; at least the '
'size of Panel Lift Check.')
output.print_md('## Two boundaries that are already settled')
output.print_md(
'**It is design, not checking, and the liability is different.** '
'Everything else on this panel reviews work that already exists &mdash; '
'Ticket QC compares a drawn ticket to the model, Panel Lift Check judges '
'rigging somebody placed, Reinforcing Suggestion proposes donors for a '
'human to accept. In each case the original work is still there to look '
'at if the tool is wrong. Gravity load chasing produces the answer '
'itself, and it goes out under an engineer\'s seal. That has to shape '
'the design from the first commit; it cannot be added at the end.')
output.print_md(
'**Its input is not the current model.** It needs old jobs and their '
'spreadsheets as samples and controls &mdash; mass-layout among them. '
'Nothing else in this extension works that way. Those files also carry '
'client and job identity, which may never enter the repository, so where '
'the controls live is a real constraint on the project layout and not a '
'detail for later.')
output.print_md(
'**Carried forward from the reinforcing work** (see '
'`QNA/reinforcing-suggestion-brief.md`): the plant standard bands '
'vertical reinforcing `0.2H / PER DESIGN / 0.2H`, and the middle band is '
'explicitly the engineer\'s own calculation for that one piece. The '
'reinforcing matcher is forbidden from proposing it, because presenting '
'one panel\'s design calculation as another panel\'s answer would look '
'exactly like an ordinary suggestion. Gravity load chasing stands on the '
'same ground &mdash; its entire output is that class of quantity. So the '
'rule here is the mirror image: **a result from one job must never '
'become an input to another by any automatic path**, and nothing this '
'tool produces may ever be presented with the confidence the checking '
'tools use.')
output.print_md('## What it will need from you before it can be built')
output.print_md(
'This is the useful half of a placeholder. None of these have answers '
'yet, and none will be invented:')
output.print_md(
'- **Old jobs, and the spreadsheets that go with them.** Which ones, and '
'where they can be reached from &mdash; they cannot live in the '
'repository.\n'
'- **Mass-layout: what it actually is.** A spreadsheet, a tool, a stage '
'of the job? Who produces it, when, in what format?\n'
'- **Which of those jobs are the controls**, and what counts as passing '
'&mdash; reproducing your number to within what tolerance, on which '
'quantities?\n'
'- **Whether the spreadsheets are machine-readable** &mdash; fixed '
'columns and stable sheet names, or hand-formatted per job. This is a '
'large fork: a parser, or an import UI.\n'
'- **What is being chased, over what unit, producing what.** One panel, '
'one wall line, a whole building; a number per piece, a table, a '
'drawing.\n'
'- **Which method governs** &mdash; which standard and edition, and '
'whether the plant has its own that supersedes it.\n'
'- **Which loads are in scope**, and what arrives as given rather than '
'computed.\n'
'- **Design, or a first pass an engineer edits.** Different products, '
'not different wordings.\n'
'- **Read-only, or not.** Everything in this extension is read-only '
'today. Is this the feature that changes that?')
output.print_md('## The five that block everything else')
output.print_md(
'The rest can be discovered while building. These cannot &mdash; each '
'one changes the shape of the whole thing, and getting one wrong is a '
'rewrite rather than a fix:')
output.print_md(
'1. What is being chased, over what unit, producing what.\n'
'2. What mass-layout is, and whether the sample spreadsheets can be read '
'by a program.\n'
'3. Where the controls live, given that they are client work.\n'
'4. Design or first pass; read-only or not.\n'
'5. Which method governs.')
output.print_md('## What was deliberately not built')
output.print_md(
'So it reads as a decision and not an oversight: no load model, no '
'tributary logic, no accumulation rule, no arithmetic. No knowledge key, '
'no schema field, no config default, no data structure, no file format. '
'No library module on either side of the CPython boundary. Anything that '
'looked obviously needed was written into the notes as a **proposal** '
'instead, so that it gets chosen rather than inherited.')
output.print_md('---')
found = notes_path()
if found:
output.print_md(
'The full scope, every open question, and the proposals are in '
'`{0}`.'.format(found))
else:
output.print_md(
'The full scope, every open question, and the proposals are in '
'`{0}` in the repository.'.format(NOTES))
output.print_md(
'Answers are welcome anywhere &mdash; in that file, or in '
'`QNA/questions.md` the usual way. Until they arrive this button stays '
'exactly as honest as it is now.')
@@ -0,0 +1,10 @@
title: Lateral (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Half-built, dropdown only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,351 @@
# -*- coding: utf-8 -*-
#
# Extract the lateral-resisting members and write an analysis input file.
#
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
# when somebody has typed the marker into its identity description -- by
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
# Nothing here infers it from geometry, category or name.
#
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
# the output files go beside the model, never into the repository.
#
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
# loaded by accident. See vibe.latrisa for what is established about the RISA
# format and what is not.
#
# The selection idiom follows Ticket QC and Panel Lift Check: what is
# preselected is what is checked; nothing preselected scans the configured
# categories across the model. The difference from both is that this tool
# never asks the user to pick pieces from a list -- the marker IS the pick,
# and offering a second way to choose would let the two disagree.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Lateral (WIP)'
import io
import os
import traceback
from pyrevit import revit, script, forms
from vibe import designspec, knowledge, latextract, latmodel, latrisa
from vibe import runlog
from vibe import scope as scopelib
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = 'Lateral Extract'
def die(message):
"""The user has not given us something yet. Not a refusal to extract."""
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused',
report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
def not_run(message):
"""'Could not judge' -- a RESULT, and it must survive on the report.
The distinction this whole tool turns on: NOT RUN is not the same as
'ran and found nothing marked'. NOT RUN means the question could not be
asked at all, so the absence of marked members says nothing. A run that
asked and found nothing gets the ordinary report, with a nothing-found
banner and no files written.
"""
output.print_md('# Lateral Extract &mdash; NOT RUN')
output.print_md('**NOT RUN** &mdash; the extraction did not run.')
output.print_md('> {0}'.format(message))
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run',
report_text=message)
script.exit()
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# -------------------------------------------------------------- context
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused', report_text=problem)
forms.alert(problem, title=TITLE, exitscript=True)
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
scope = ('the {0} element(s) you selected'.format(len(selection))
if selection else 'the whole model, in the configured categories')
# --------------------------------------------------------------------- run
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
pb.update_progress(0, 2)
model, found, why = latextract.gather(doc, selection=selection or None,
know=know)
pb.update_progress(2, 2)
cats = knowledge.get(know, 'lateral.categories', None) \
or latextract.DEFAULT_CATEGORIES
if why == latextract.NOT_RUN_UNMAPPED:
not_run(
'The `parameters.{0}` role is not mapped, so this does not know '
'which parameter holds the identity description the marker is '
'typed into.\n\n'
'It will not guess a name. A guessed name would work on the '
'template it was guessed from and silently find nothing on every '
'other one, and "found nothing" is indistinguishable from "nothing '
'is marked" once it is on the screen.\n\n'
'Map it in the plant knowledge file and run again. Self Check lists '
'which layers are in play.'.format(latextract.DESC_ROLE))
if why == latextract.NOT_RUN_ABSENT:
# A literal role path, not a variable, so knowledge_drift can see it.
names = knowledge.param_names(know, 'member.identity_description')
not_run(
'The `parameters.{0}` role is mapped to **{1}**, but not one '
'candidate element actually carries that parameter.\n\n'
'That is not the same as "nothing is marked". The field is not '
'there, so nothing could have been marked, and this tool has no '
'opinion of its own about which members resist lateral load.\n\n'
'Scope searched: {2}. Categories: `{3}`.\n\n'
'Either add the parameter to these families, or point the role at '
'whatever field this office really uses.'
.format(latextract.DESC_ROLE, ', '.join(names), scope,
', '.join(cats)))
# ------------------------------------------------------------------ report
output.print_md('# Lateral extraction')
output.print_md(
'Model **{0}** &middot; scope: {1} &middot; {2} candidate element(s) read'
.format(doc.Title, scope, found['candidates']))
output.print_md(
'Marker tokens accepted: {0}. Read from: {1}.'
.format(', '.join(['`' + t + '`' for t in found['tokens']]),
', '.join(['`' + p + '`' for p in found['params_tried']])))
# ---- the declared scope, and why it is NOT applied here ------------------
#
# Every other button on this panel narrows what it judges to the job's
# declared scope (`vibe.scope`). This one deliberately does not, and says so
# rather than leaving the reader to assume either way.
#
# The reason is that the marker IS the pick. A member is lateral-resisting
# because somebody typed it into the identity description, one element at a
# time -- that is a person choosing, not the tool choosing, and this whole
# tool's rule is that a filter never overrides what the user picked. Beyond
# that, a lateral system is columns, beams and shear walls by nature; scoping
# it to one discipline's prefix would delete most of the frame and produce an
# analysis model that looks complete and is not. The line below is printed
# every run so the difference is visible on the page.
_declared = scopelib.of(know)
output.print_md(
'> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is '
'deliberately NOT applied here: a member is in this extraction because '
'somebody marked it, and a lateral system crosses every discipline on '
'the job. Every marked member is exported whatever its mark, and the '
'members another engineer owns are as much a part of the frame as '
'yours.'.format(_declared.label()))
n_marked = len(found['marked'])
n_amb = len(found['ambiguous'])
if not n_marked and not n_amb:
# Asked, and the answer was nothing. Distinct from NOT RUN above, and
# said in words rather than left as an empty table -- and critically,
# NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer.
output.print_md('## Nothing is marked')
output.print_md(
'> {0} element(s) were read and **not one** carries the marker. '
'The identity-description field exists, so the question was asked '
'properly and the answer is that no member has been marked as '
'lateral-resisting yet.\n\n'
'> **No files have been written.** An empty analysis file on disk '
'would look like a result.\n\n'
'> To mark a member, type `{1}` into its identity description '
'(`{2}`) and run this again.'
.format(found['candidates'], found['tokens'][0],
found['params_tried'][0]))
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Nothing is marked - no files written.')
script.exit()
if n_amb:
output.print_md('## Ambiguous marks &mdash; not counted either way')
output.print_md(
'The marker appears in these fields but is preceded by a negating '
'word, so it may mean the opposite. They are **excluded** from the '
'export and named here for you to settle.')
output.print_table(
[[str(r['element_id']),
r['mark'] or '', r['category'] or '', r['desc'] or '']
for r in found['ambiguous']],
columns=['element id', 'mark', 'category', 'identity description'])
# ---- what was selected, element by element -------------------------------
output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked))
output.print_table(
[[str(r['element_id']), r['mark'] or '', r['category'] or '',
r['desc_param'] or '', r['token'] or '', r['desc'] or '']
for r in found['marked']],
columns=['element id', 'mark', 'category', 'read from', 'matched',
'identity description'])
# ---- what was skipped, element by element, with the reason ---------------
output.print_md('## Skipped ({0})'.format(len(model['skipped'])))
if model['skipped']:
output.print_table(
[[str(s.get('element_id')), s.get('mark') or '',
s.get('category') or '', s.get('reason') or '']
for s in model['skipped']],
columns=['element id', 'mark', 'category', 'why it is not a member'])
else:
output.print_md('> Nothing was skipped.')
# ---- what became members -------------------------------------------------
output.print_md('## Members written ({0})'.format(len(model['members'])))
if model['members']:
output.print_table(
[[m['label'], m['i_node'], m['j_node'],
'{0:.3f}'.format(m['length_ft']),
(m.get('section') or {}).get('name') or '(none)',
m.get('material') or '(none)',
m.get('axis_source') or '',
'; '.join(m['unresolved'])]
for m in model['members']],
columns=['member', 'i', 'j', 'length ft', 'section label',
'material', 'axis from', 'NOT determined'])
else:
output.print_md(
'> **No member survived extraction.** Every marked element is in the '
'skipped table above with its reason. Nothing analysable came out of '
'this run.')
# ---- what could not be determined ----------------------------------------
rows = latmodel.unresolved_rows(model)
output.print_md('## Not determined ({0} item(s))'.format(len(rows)))
output.print_md(
'Every one of these is **left empty** in the output. None of them is '
'given a default value. A member with no section is not quietly assigned '
'one; the draft file writes `{0}` for it, which no analysis program can '
'resolve, so it fails to analyse rather than analysing wrongly.'
.format(latrisa.UNRESOLVED_SHAPE))
if rows:
output.print_table(
[[r['label'], str(r['element_id']), r['mark'] or '', r['missing']]
for r in rows],
columns=['member', 'element id', 'mark', 'what is missing'])
# ---- connectivity --------------------------------------------------------
conn = model['connectivity']
output.print_md('## Connectivity')
output.print_md(
'{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only '
'one member reaching them. This tool does not extract supports, so it '
'cannot tell a support from a disconnected member -- both look like this.'
.format(conn['node_count'], conn['member_count'], model['node_tol_ft'],
len(conn['free_ends'])))
if not conn.get('near_miss_checked'):
output.print_md(
'> The near-miss search was **not run** ({0} nodes is too many for an '
'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no '
'near misses" -- nobody looked.'.format(conn['node_count']))
elif conn['near_misses']:
output.print_md(
'**{0} pair(s) of nodes sit within 6 inches of each other but further '
'apart than the weld tolerance.** These are almost certainly meant to '
'be the same point. They are **not** merged here.'
.format(conn['near_miss_total']))
output.print_table(
[[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])]
for r in conn['near_misses'][:40]],
columns=['node', 'node', 'gap ft'])
output.print_md(
'> A member ending part-way along another member is **not** connected to '
'it and no node is inserted to make it so.')
# ---- the standing caveats ------------------------------------------------
output.print_md('## What is deliberately not in the output')
for note in model['notes']:
output.print_md('- ' + note)
# ------------------------------------------------------------ where it goes
#
# Beside the MODEL, never in the repository.
start_dir = knowledge.get(know, 'lateral.output_dir', None) or \
designspec.sub_dir(doc, 'lateral')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
if not doc.PathName:
output.print_md(
'## Not written\n> This model has never been saved, so there is no '
'folder beside it to write to. Save the model and run again.')
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Model has never been saved - nothing written.')
script.exit()
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
written = []
for name, text in latrisa.file_set(model, stem):
path = os.path.join(start_dir, name)
try:
f = io.open(path, 'w', encoding='utf-8')
try:
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
finally:
f.close()
written.append(path)
except Exception as ex:
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
output.print_md('## Files written')
for path in written:
output.print_md('- `{0}`'.format(path))
output.print_md(
'> The `.json` and the two `.csv` files are complete statements of what '
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
'file -- its section keywords follow RISA\'s published Appendix D but '
'the field order inside each record is unverified, so it is named so '
'that it cannot be opened into RISA by accident. Read its header before '
'renaming it.')
if not latmodel.is_analysable(model):
output.print_md(
'> **This model would not analyse correctly as written**, and that '
'is on purpose: it has unresolved sections, free ends, no supports '
'and no releases. Fix those in the analysis package, not here.')
# The extraction ran and its files are on disk -- the record is 'ok' with
# the report text kept short, since record() caps at 20000 characters.
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok',
report_path=written[0] if written else None,
report_text=('{0} marked member(s), {1} skipped, '
'{2} node(s), {3} member(s) written; files: {4}'
.format(n_marked, len(model['skipped']),
conn['node_count'],
conn['member_count'],
'; '.join(written))))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,17 @@
title: Panel Analysis (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration, same as its siblings in this pulldown. This button
# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section
# per selected panel, pre-filled from the model where readable and blank
# otherwise, gated so a panel cannot run until its required fields (incl. >=2
# supports) are filled -- then feeds the confirmed inputs to the Stage-0
# self-weight solve. The in-place / wind / service engine behind the form is a
# later stage. See script.py's own comments for exactly what it does and does
# not do.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,27 @@
title: Panel Lift Check
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to `liftexport` -- the Revit half of the
# panellift boundary -- is visible without a Revit restart. It trades press
# latency (engine construction instead of engine reuse) for restart-freedom.
# The CPython half already needs no restart; this is for the half that does.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons
# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row
# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a
# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the
# longest row, so the title is free to say what it means. No (WIP): moving
# panels did not change what this tool does, and it is not half-built.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,36 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: DesignHub
# REORGANISED 2026-09-05, on the owner's own instruction: "把 Panel Lift
# 移到 Design Hub 里面去,再把 Beam Analysis 跟 Connection Analysis 也加
# 进去" -- Panel Lift Check moved onto this pulldown from being one of
# Tools.panel's four large icons (see that panel's own bundle comment), and
# two new placeholder rows, BeamAnalysis and ConnectionAnalysis, joined
# GravityLoadChase and LateralExtract as more (WIP) rows.
#
# PanelLiftCheck is NOT half-built -- it is the one row here that runs, end
# to end, on a real piece, exactly like PanelAnalysis. It moved here because
# the owner wants it grouped with the other analysis tools rather than
# because it stopped working; nothing about its own behaviour changed.
#
# The other four rows are half-built and all four say so in their own
# titles. Keep the (WIP) suffixes until the tool behind one actually runs:
# the arrow hides them from the ribbon, but a row that opens is still a row
# somebody clicks. PanelAnalysis differs from BeamAnalysis, ConnectionAnalysis,
# GravityLoadChase and LateralExtract in one way worth naming: it DOES run,
# end to end, and opens a real page -- what is unfinished is the analysis
# pipeline behind the page it opens, not the button itself. See that
# button's own script.py.
#
# Order: the two tools that run today, then the four that do not, in the
# order the owner named them.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- PanelLiftCheck
- PanelAnalysis
- BeamAnalysis
- ConnectionAnalysis
- GravityLoadChase
- LateralExtract
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,16 @@
# The panel's name on the ribbon is the FOLDER name ("Design").
#
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
# standing instruction is that unfinished tools must never sit on the same
# panel as the polished ones; gravity-load chasing, lateral extraction, beam
# analysis and connection analysis are buttons-and-docs only until he
# provides worked examples, so they live one click down, behind DesignHub,
# on a panel of their own. Panel Analysis and Panel Lift Check share that
# same door for a different reason (2026-09-05 for Panel Lift Check) --
# both actually run, but the owner wants them grouped with the tools that
# do not rather than back out on Tools.panel; see DesignHub's own bundle
# comment.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- DesignHub
@@ -0,0 +1,13 @@
title: GOD_MOD
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Two lines, short ones. A large ribbon button is as wide as its widest line
# of text, so a long title makes one button dwarf its neighbours -- which is
# exactly what the owner objected to. Keep every line under ~9 characters.
#
# The name is the owner's, verbatim and with the underscore, from
# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod
# because a pyRevit component name is a C# class name in the generated
# assembly; the title is what anybody actually reads.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
#
# Starts a detached terminal running the `claude` CLI with its working
# directory set to the repository root, so the session comes up on CLAUDE.md
# and tools/rvt.py -- which is what wires it to THIS running Revit.
#
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
# long agent session cannot freeze or crash Revit, and this button touches the
# model in no way at all: it opens no Transaction, reads no element, and does
# not even import `vibe.helpers`' document handle.
#
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
# any other auto-approve flag. The agent is being pointed at a live Revit
# session and the owner's real model; every tool call it makes is worth a
# human seeing first. If a future edit "just needs" the prompts gone, that is
# a conversation with the owner, not a flag.
#
# Interactive, so this deliberately does NOT use the redirect-both-streams
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
# run without deadlocking on a pipe, and redirecting stdout here would leave
# the CLI with no terminal to draw on. What is reused is the other half of the
# idiom: a generated .cmd does the cd and holds the window open, so nothing
# has to nest quotes inside a wt.exe command line.
#
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
# unreferenced, for the owner to keep or bin.
#
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
# word, underscore and all. Same button, same folder contents throughout; the
# folder is `GodMod.pushbutton` because a component name becomes a C# class
# name in the generated assembly.
#
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
# bundle file cannot resurrect a name nobody uses.
__title__ = 'GOD_MOD'
import io
import os
import tempfile
from System.Diagnostics import Process, ProcessStartInfo
from pyrevit import forms, script
TITLE = 'Claude'
MARKER = 'CLAUDE.md' # the project instructions the session reads
def repo_root():
"""The checkout to start the session in, and whether it looks like one.
This script lives at
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
so the repo is four levels above the button. Deriving it means the
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
case where pyRevit does not hand us __file__.
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
session -- it is only missing the project instructions -- but the report
says so rather than letting the agent start somewhere surprising in
silence.
"""
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
try:
root = os.path.dirname(os.path.abspath(__file__))
except NameError:
return home, os.path.isfile(os.path.join(home, MARKER))
for _ in range(4):
root = os.path.dirname(root)
if os.path.isfile(os.path.join(root, MARKER)):
return root, True
if os.path.isfile(os.path.join(home, MARKER)):
return home, True
return root, False
def find_cli():
"""Absolute path to the Claude Code CLI, or None.
`vibe.aiextract.find_claude` already knows the four places a per-user
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
drift. Imported here rather than at module scope so a broken library
still leaves this button able to say what went wrong.
"""
from vibe.aiextract import find_claude
return find_claude()
def write_runner(claude_exe, workdir):
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
It goes in %TEMP%, never in the repo and never beside the model: it holds
an absolute path to somebody's install, which is exactly what the scrub
gate exists to keep out of the tree. Named per Revit process, because
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
file under each other is a class of bug not worth owning.
"""
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
if not os.path.isdir(folder):
os.makedirs(folder)
runner = os.path.join(folder,
'god-mod-claude-{0}.cmd'.format(os.getpid()))
# newline='' or the CRLFs below get translated again and every line ends
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
# text editor is a bad place to be debugging a launch from.
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'title Claude Code - Revit\r\n')
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
fh.write(u'echo Revit dev folder: %CD%\r\n')
fh.write(u'echo.\r\n')
fh.write(u'"{0}"\r\n'.format(claude_exe))
fh.write(u'echo.\r\n')
fh.write(u'echo [claude exited - this window stays open]\r\n')
fh.write(u'pause >nul\r\n')
return runner
def launch(runner, workdir):
"""Start the runner detached. Windows Terminal when it is there."""
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.WorkingDirectory = workdir
psi.UseShellExecute = True
return Process.Start(psi)
WORKDIR, HAS_MARKER = repo_root()
try:
CLAUDE = find_cli()
except Exception as ex:
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
if not CLAUDE:
forms.alert(
'Claude Code CLI not found.\n\n'
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
'full path of claude.cmd / claude.exe.\n\n'
'Looked in the usual per-user install folders and on PATH.',
title=TITLE, exitscript=True)
try:
RUNNER = write_runner(CLAUDE, WORKDIR)
launch(RUNNER, WORKDIR)
except Exception as ex:
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
output = script.get_output()
output.print_md('### Claude Code is starting')
output.print_md('Working folder: `{0}`'.format(WORKDIR))
output.print_md('CLI: `{0}`'.format(CLAUDE))
if not HAS_MARKER:
output.print_md(
'**No `{0}` in that folder** - the session will start without the '
'project instructions, and without them it does not know about the '
'bridge to this Revit.'.format(MARKER))
output.print_md(
'It runs with **default permissions**, so it asks before it acts. '
'It can read and drive **this** Revit session through `tools/rvt.py` - '
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
'button that renumbers the selected doors"_.')
@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Lab").
#
# One button, and it is not a work in progress -- GOD_MOD works. It sits
# alone at the right-hand end because it is the escape hatch rather than one
# of the four tools, and an escape hatch you can find without looking is
# worth a panel divider.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- GodMod
@@ -0,0 +1,8 @@
title: Design Extract
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,714 @@
# -*- coding: utf-8 -*-
#
# Turn a design summary into a reviewable spec file, in two chained passes.
#
# PASS 1, mechanical, in-process: scrapes the summary drafting view and its
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
# no subprocess, nothing that leaves this process. What the parser could not
# place lands verbatim in the spec's 'unresolved' list rather than being
# dropped, which is what makes a narrow rule set honest instead of lossy.
# Read-only as far as Revit is concerned, except for exporting the summary
# view to a PNG for pass 2 (also read-only -- ExportImage writes no model
# data).
#
# PASS 2, AI, out of process (owner, 2026-09-01, Discord): chained
# automatically after pass 1 succeeds, `vibe.aiextract.launch` starts a
# DETACHED process (`tools/aiextract_run.py`, DSV4F with vision) that fills
# whatever pass 1 left null, empty or unresolved, plus the strand matrix
# pass 1 never attempts. It can only ADD to the spec pass 1 wrote -- see
# that module's docstring for the never-overwrite rule -- and this button
# does not wait for it: the spec file is updated in place whenever that
# process finishes, or left exactly as pass 1 wrote it if the AI pass fails.
#
# Every value this button writes is machine-read and UNVERIFIED. Check it
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
# is trusted to have used the right numbers.
#
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
# -----------------------------------------------------------
# The specs written here are what Ticket QC's design-intent comparison
# enforces, so a design extracted here enters the scoped path. Until
# 2026-08-31 this button offered EVERY design in the model with no mention of
# the scope at all -- a side door: an out-of-scope design could be extracted,
# reach Ticket QC, and be compared against, without ever being labelled.
#
# It is not a door that should be shut. The engineer may have a reason to
# extract somebody else's design -- to read it, to compare against it, to
# check a shared edge -- and a picker that hid designs would be exactly the
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
# nothing is blocked. Instead:
#
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
# choosing, before anything is written;
# * the report carries the scope line every other report carries, and,
# when one was actually extracted, the annotation saying the values in
# it rest on knowledge nobody validated for that kind of piece;
# * the fold opens ITSELF when that happened, by the standing display rule.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Design Extract'
import io
import os
import traceback
from collections import OrderedDict
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
ViewSheet)
from pyrevit import revit, script, forms
from vibe import designspec, ticketqc, knowledge, specsave
from vibe import aiextract, ticketvision
from vibe import foldreport as fold
from vibe import runlog
from vibe import scope as scopelib
doc = revit.doc
output = script.get_output()
output.close_others()
if not doc.PathName:
runlog.record('Design Extract', doc_title=doc.Title,
outcome='refused',
report_text='Save the model first - the spec files live '
'beside it.')
forms.alert('Save the model first - the spec files live beside it.',
exitscript=True)
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# --------------------------------------------------------------- pick work
know = knowledge.load(doc)
grouped = ticketqc.assemblies_by_design(doc, know=know)
existing = designspec.list_specs(doc)
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
# The declared scope, off the job knowledge, read the same way Job Recon,
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
# note at the top of this file.
the_scope = scopelib.of(know)
def out_of_scope(design):
"""True when this design number is declared to be somebody else's.
Through `as_mark`, so a design key that does not read as a mark at all
is 'could not tell' and is never labelled as another engineer's.
"""
return not the_scope.includes(scopelib.as_mark(design))
def sheets_by_folder():
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
organization, so this picker is grouped the way the browser tree is.
A flat list of every drafting view is unusable on a real job -- there are
hundreds, and their names are not the design numbers anyway.
"""
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
out = {}
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if sheet.IsTemplate:
continue
number = (sheet.SheetNumber or '').strip()
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
continue
folder = ''
if org is not None:
try:
folder = ' / '.join(
[item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
folder = ''
out.setdefault(folder or '(no folder)', []).append(sheet)
return out
def packets(sheets):
"""{design key: [ViewSheet, ...]} in page order.
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
pages of design PNL1, and extracting any of them means extracting the
packet.
"""
out = {}
for sheet in sheets:
key = designspec.design_key(sheet.SheetNumber, know)
if key:
out.setdefault(key, []).append(sheet)
for key in out:
out[key].sort(key=lambda s: (s.SheetNumber or ''))
return out
DESIGNS = {} # row label -> design key, so nothing has to be split
def row(design, sheets=None):
bits = []
if sheets:
cover = sheets[0]
bits.append(cover.Name)
if len(sheets) > 1:
bits.append('{0} sheets ({1})'.format(
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
else:
bits.append('sheet {0}'.format(cover.SheetNumber))
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
else 'NO pieces')
if design in existing:
bits.append('spec exists - will be overwritten')
# At the point of choosing, and in capitals: this is the one moment the
# engineer can decide he did not mean to extract somebody else's design.
# It never removes the row.
if out_of_scope(design):
bits.append('OUTSIDE THE DECLARED SCOPE')
label = '{0} - {1}'.format(design, ', '.join(bits))
DESIGNS[label] = design
return label
groups = OrderedDict()
by_folder = sheets_by_folder()
for folder in sorted(by_folder):
found = packets(by_folder[folder])
if found:
groups['Sheets / ' + folder] = [row(key, found[key])
for key in sorted(found)]
# Designs the pieces claim that no sheet in the design folder accounts for.
# These would silently go unchecked, so they get their own group rather than
# being left off the list.
accounted = set()
for folder in by_folder:
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
accounted.update(packets(by_folder[folder]).keys())
orphans = [d for d in sorted(grouped) if d not in accounted]
if orphans:
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
if existing:
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
if leftover:
groups['Specs already on disk'] = leftover
if not groups:
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
report_text='No design numbers found in this model.')
forms.alert('No design numbers found in this model.', exitscript=True)
kwargs = {
'title': 'Extract which design summaries?',
'group_selector_title': 'Browser folder',
'multiselect': True,
'button_name': 'Extract',
'width': 900,
'height': 650,
}
preferred = 'Sheets / ' + DESIGN_FOLDER
if DESIGN_FOLDER and preferred in groups:
kwargs['default_group'] = preferred
picked = forms.SelectFromList.show(groups, **kwargs)
if not picked:
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
report_text='Design picker closed -- no design chosen.')
script.exit()
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
# What was OFFERED and left unpicked is not the scope's business -- the
# engineer chose. What matters is what he DID pick that the declaration says
# is somebody else's, because those specs are about to feed Ticket QC.
picked_out = [d for d in designs if out_of_scope(d)]
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
scope_annotation = the_scope.annotation(picked_out, unit='design')
# ------------------------------------------------------------- gather + go
output.print_md('# Extract design summaries')
output.print_md('Model **{0}**'.format(doc.Title))
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
output.print_md(
'**Every value below is machine-read and unverified.** A regex either '
'matched a rule or it did not -- there is no judgement in between. What '
"matched is filled in; every line of the summary that didn't is listed "
'verbatim in the spec\'s `unresolved` section instead of being silently '
'dropped -- and, from this press on, on this page as well. Read those, '
'then check the spec in `{0}` against the drawing before Ticket QC or '
'Panel Lift Check are trusted to have used the right numbers.'
.format(designspec.sub_dir(doc, 'specs')))
# ---- the declared scope. Not folded, and above the results rather than
# below them, because a spec is written to disk by the time this page exists
# -- the reader is being told what he already has, and Ticket QC will pick it
# up whether or not he scrolls.
output.print_md('> ' + scope_line)
if scope_annotation:
output.print_md('> **' + scope_annotation + '**')
for _note in the_scope.declaration_lines():
output.print_md('_{0}_'.format(_note))
def _per_plant_layer_exists():
"""True when this machine already has at least one per-plant folder.
Answers a different question from `knowledge.plant_id(doc)`: that asks
which plant THIS job names; this asks whether the per-plant migration
has already happened on THIS MACHINE at all, which is what decides
whether the legacy machine-wide path is still a safe place to drop a
starter.
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
-- writing the machine-wide starter there is exactly what this button has
always done, and it is still right: a fresh install needs something to
edit. But once a machine has even one real per-plant folder, the
machine-wide file at `<root>/knowledge.yaml` is what
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
(`plantpath.py`), so writing a blank template there would give a job
that simply forgot to set `job.plant` a silent, empty plant layer
instead of a loud "no plant declared." That is the exact trap this fix
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
migrated machine the safe move is to write nothing and say so.
"""
root = designspec.plant_root()
try:
names = os.listdir(root)
except Exception:
return False
for name in names:
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
return True
return False
# `write_plant_starter()` with no plant argument always targets the
# machine-wide `<root>/knowledge.yaml` -- the exact file
# `plant_knowledge_path(None)` returns as the fallback for any job that
# omits `job.plant`. Calling it unconditionally, as this button used to,
# recreated a blank template there on every press, which any no-plant job
# would then silently read as its entire plant layer. `knowledge.plant_id`
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
# before the ordinary merge runs (see its docstring), so it is read the
# same way here rather than re-derived.
plant = knowledge.plant_id(doc)
if plant:
# A named plant gets its own folder. `write_plant_starter` already
# refuses to overwrite a file that exists (knowledge.py:6601 --
# `if os.path.isfile(path) and not overwrite: return path, False`), so
# calling it on every press is safe: it only ever fills a gap, never
# clobbers a real plant layer.
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
output.print_md('Plant standards: `{0}`{1}'.format(
ppath, ' (just created - edit it)' if pcreated else ''))
elif _per_plant_layer_exists():
# No plant named, but this machine has already been migrated to
# per-plant folders -- writing at the legacy path would recreate the
# exact decoy this fix removes. Report the gap instead of filling it
# with a blank file that looks like an answer.
output.print_md(
'Plant standards: **no `job.plant` set**, and this machine already '
'has per-plant folders under `{0}` -- nothing written at the '
'legacy machine-wide path, because that path is what a no-plant '
'job reads as its plant layer. Set `job.plant` in this job\'s '
'knowledge file.'.format(designspec.plant_root()))
else:
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
# so the machine-wide file is still the only plant layer this install
# could possibly mean, exactly as it was before per-plant folders
# existed. Safe to seed, and `write_plant_starter` still refuses to
# overwrite if one is somehow already there.
ppath, pcreated = knowledge.write_plant_starter()
output.print_md('Plant standards: `{0}`{1}'.format(
ppath, ' (just created - edit it)' if pcreated else ''))
kpath, created = knowledge.write_starter(doc, plant=plant)
if created:
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
written, skipped = [], []
for design in designs:
try:
evidence = designspec.gather_evidence(doc, design, know=know)
except Exception as ex:
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
continue
if not evidence.get('summary_views'):
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
'sheets carry no views'.format(
DESIGN_FOLDER or 'any folder', design)))
continue
designspec.write_json(designspec.evidence_path(doc, design), evidence)
notes = sum([len(v.get('text_notes') or [])
for v in evidence.get('summary_views') or []])
try:
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
if spec is None:
skipped.append((design, 'no summary view to read'))
continue
path = designspec.save_spec(doc, design, spec)
# The spec itself is kept, not just its counts: what is unresolved is
# listed by name below, and the copy the engineer asks for is written
# from the file this returned.
written.append({'design': design, 'notes': notes, 'path': path,
'spec': spec})
except Exception as ex:
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
continue
# ----------------------------------------------- the AI pass, chained on
#
# Owner, 2026-09-01 (Discord), approving the two-stage-extraction
# proposal: the mechanical reader above always runs first and never
# guesses; whatever it left null, empty or unresolved is this second
# pass's job, on DSV4F WITH VISION, never opus ("可以,不过别用opus,用
# dsv4f带vision吧"). The strand matrix is one such field, not a
# standing exception any more -- `build_spec` now reads it from
# geometry (the drawn strand symbols against the dimension chain) and
# takes it only when every identity check on that reading passes; the
# AI pass picks it up here exactly when the geometry read refused, the
# same as any other blank left by the mechanical reader. It runs
# detached (`vibe.aiextract.launch`), the same
# way the AI reader always has, so this button returns immediately --
# the spec file above is updated IN PLACE whenever that process
# finishes, or is left exactly as written above (plus one `unresolved`
# line saying so) if the AI pass fails or times out. Nothing here waits
# for it and nothing here re-reads the spec after kicking it off.
#
# Rendering the summary view to an image is Revit API work, done here
# rather than inside `aiextract` (which otherwise has no reason to touch
# a live View) -- OWNER MUST VERIFY: exercised only against a live Revit
# session, not tonight.
ai_image, ai_error = None, None
try:
ai_views, _ai_sheets = designspec.find_summary_views(doc, design,
know=know)
if ai_views:
ai_image = ticketvision.render_view_png(
doc, ai_views[0].Id, designspec.sub_dir(doc, 'log'))
except Exception as ex:
ai_error = ('could not render the summary view to an image ({0}: '
'{1}) -- the AI pass will run text-only, and the strand '
'matrix will very likely stay blank'
.format(type(ex).__name__, ex))
try:
aiextract.launch(doc, design, know=know,
image_paths=[ai_image] if ai_image else [])
written[-1]['ai_launched'] = True
except Exception as ex:
written[-1]['ai_launched'] = False
started = ('the AI pass itself could not be started ({0}: {1})'
.format(type(ex).__name__, ex))
ai_error = (ai_error + '; ' + started) if ai_error else started
written[-1]['ai_image'] = ai_image
written[-1]['ai_error'] = ai_error
# ------------------------------------------------------- where the copy goes
#
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
# 路径。用户也可以打开看到底是什么东西unresolved。"
#
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
# else, so a spec saved only into a folder the engineer picked would look
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
# read.
#
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
# on cancel, which is right there: nothing has been calculated yet, so a
# cancel costs nothing. Here the extraction has already run and the files are
# already on disk, so exiting would throw away work that only another press
# could get back. Cancel therefore means "no second copy" and nothing else --
# the results still print, pointing at the canonical file.
copies = {}
copy_problems = []
in_place = []
cancelled_save = False
if written:
designs_written = [w['design'] for w in written]
# Seeded with wherever the last extraction was saved; the job's data
# folder when there is no such memory. Both are real places on this
# machine -- a dialog opening on a path that does not exist is how a file
# lands somewhere nobody meant. NOT the `specs` folder itself: the
# default name is the name the canonical file already has, so opening
# there would greet a single-design run with "PNL1.yaml already exists,
# replace it?" -- a question about the file it just wrote.
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
or designspec.root_dir(doc))
chosen = forms.save_file(
file_ext='yaml', init_dir=start_dir or '',
default_name=specsave.default_name(designs_written),
title='Save the design extraction as')
if not chosen:
cancelled_save = True
else:
by_design = dict([(w['design'], w['path']) for w in written])
for design, target in specsave.targets(chosen, designs_written):
# Saving onto the file itself. The dialog opens on the folder the
# specs already live in, so this is an ordinary thing to do by
# accident -- and rewriting a file with its own contents is at
# best pointless. Say so instead of claiming a copy that is the
# same path twice.
if specsave.same_file(by_design[design], target):
in_place.append(design)
continue
try:
# Copied as text rather than re-rendered from the spec, so the
# copy is character-for-character the file Ticket QC reads. A
# second rendering could drift from the first and the two
# would look like the same extraction.
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
body = fh.read()
with io.open(target, 'w', encoding='utf-8') as fh:
fh.write(body)
copies[design] = target
except Exception as ex:
copy_problems.append('{0}: {1}'.format(design, ex))
specsave.remember_dir(designspec.root_dir(doc),
os.path.dirname(chosen))
if written:
output.print_md('## Written')
output.print_table(
[[w['design'], str(w['notes']),
str(len(specsave.unresolved_entries(w['spec']))),
os.path.basename(w['path'])] for w in written],
columns=['design', 'text notes scraped', 'unresolved notes',
'spec file'])
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
# is a dead link in this panel -- backslashes and no scheme, so it renders
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
# stays the real path, because that is what gets copied into Explorer,
# and only the href gets the URL form. `specsave.link` is that pattern.
for w in written:
line = '- **{0}** &nbsp; {1}'.format(w['design'],
specsave.link(w['path']))
if w['design'] in copies:
line += ' &nbsp;&mdash;&nbsp; copy: {0}'.format(
specsave.link(copies[w['design']]))
output.print_md(line)
if cancelled_save:
output.print_md(
'> No second copy was saved -- the save dialog was cancelled. '
'Nothing was lost: every spec above is on disk at the path shown, '
'which is where Ticket QC reads it from.')
if in_place:
output.print_md(
'> {0}: you picked the file the spec is already saved as, so '
'nothing further was written. The path above is it.'
.format(', '.join(in_place)))
if len(written) > 1 and copies:
output.print_md(
'> More than one design was extracted, so the FOLDER you chose '
'was used and each spec kept its own design number as its file '
'name -- that name is how everything downstream finds it.')
for problem in copy_problems:
output.print_md('> The copy could not be written &mdash; {0}'
.format(problem))
output.print_md(
'Then check the fields the reader did fill against the drawing. '
'Ticket QC picks specs up automatically, from the paths above.')
# --------------------------------------------------------- the AI pass
output.print_md('---')
output.print_md(
'## AI pass (DSV4F, vision) -- chained automatically\n\n'
'For each design above, a second, detached pass just launched: it '
'reads whatever the mechanical reader left null, empty or '
'unresolved, plus the strand matrix the mechanical reader never '
'even attempts. It runs on DSV4F with vision, never opus (owner\'s '
'ruling, 2026-09-01) -- and it can only ADD to the spec: a field '
'the mechanical reader above already filled is never touched. It '
'writes back to the SAME spec file above, in place, whenever it '
'finishes; if it fails or times out the file is left exactly as '
'the mechanical reader wrote it, with one line in `unresolved` '
'saying so.')
for w in written:
bits = ['launched' if w.get('ai_launched') else 'NOT launched']
if w.get('ai_image'):
bits.append('drawing image rendered')
else:
bits.append('no drawing image -- text-only pass')
output.print_md('- **{0}** &mdash; {1}'.format(w['design'],
', '.join(bits)))
if w.get('ai_error'):
output.print_md(' > {0}'.format(w['ai_error']))
output.print_md(
'**Every value in these spec files remains machine-read and '
'unverified** -- the AI pass does not change that, it only changes '
'who read which line. Re-open the spec after the AI pass\'s window '
'closes and check it against the drawing the same as above, '
'especially the strand matrix and anything now stamped '
'`dsv4f-vision` in `source.extractor`.')
# ------------------------------------------- what is unresolved, by name
#
# "用户也可以打开看到底是什么东西unresolved" -- so the notes themselves are
# on the page, verbatim, not a number in a column. A count tells a reader
# there is something to look at and nothing about whether to look now.
#
# Folded by the standing display rule (`vibe.foldreport`, set 2026-08-30):
# everything below the answer starts closed, and a section carrying a
# problem opens ITSELF. Unresolved notes are exactly that case, so a
# design with any opens on its own; a design with none stays a one-line
# header. The contract that buys is: if nothing here is open, nothing on
# this page needs you.
sections = []
for w in written:
entries = specsave.unresolved_entries(w['spec'])
blanks = specsave.blank_fields(w['spec'])
problems = designspec.validate_spec(w['spec'])
section = fold.Section(
key='unresolved-' + w['design'],
# A plain dash. `foldreport._section_html` now restores the
# reports' entities in a title the way it always did for a table
# cell or a markdown block (fixed 2026-08-30, after this note was
# written), so `&mdash;` would render here too -- but a plain dash
# is right in the panel AND in the .md, so it stays.
title='{0} - what the reader could not place'.format(w['design']),
summary=specsave.summary_line(entries, blanks),
problem=specsave.problem_sentence(entries, problems))
reason = specsave.shared_reason(entries)
if entries:
# The mechanical reader gives every unplaced note the same reason.
# Printed once it is an explanation; printed on all fourteen lines
# it is what stops the list being read.
if reason:
section.md('_{0}_'.format(reason))
for i, entry in enumerate(entries):
text, why = entry
line = '**{0}.** {1}'.format(i + 1, text)
if why and not reason:
line += ' &mdash; {0}'.format(why)
section.md(line)
else:
section.md('Every text note on this design summary matched a '
'rule. Nothing was dropped and nothing was left over.')
if blanks:
section.md(
'**Still blank** &mdash; the summary filled none of these, '
'and a check that reads one of them does not run: {0}'
.format(', '.join(['`' + b + '`' for b in blanks])))
if problems:
section.md('**Validation:** ' + '; '.join(problems))
# PLAIN PATH INSIDE THE FOLD, NOT A MARKDOWN LINK. `foldreport.md_html`
# converts bold, code and italic and nothing else -- a markdown link
# written here reaches the panel as the literal text `[path](file:///
# ...)`, which is worse than no link at all. Measured on stubs,
# 2026-08-30. The CLICKABLE link is in the list above, printed through
# pyRevit's own markdown, which does render one.
section.md('The spec: `{0}` (clickable in the list above)'
.format(w['path']))
if w['design'] in copies:
section.md('Your copy: `{0}`'.format(copies[w['design']]))
sections.append(section)
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(sections)
output.print_md('---')
if opened:
output.print_md(
'**{0} of the {1} design(s) below opened themselves** &mdash; '
'they have notes the reader could not place. The rest are '
'folded; click a header to open one.'
.format(len(opened), len(sections)))
else:
output.print_md(
'**Nothing below needs you.** Every note on every summary matched '
'a rule. The sections are folded; click a header to open one.')
output.print_html(fold.html(sections))
if skipped:
output.print_md('## Skipped')
for design, why in skipped:
output.print_md('- **{0}** - {1}'.format(design, why))
# ---------------------------------------------- review specs already on disk
if existing:
output.print_md('## Specs already on disk')
rows = []
for design in sorted(existing):
spec = existing[design]
problems = designspec.validate_spec(spec)
row_cells = [design,
spec.get('source', {}).get('extractor') or '?',
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
'ok' if not problems else '; '.join(problems[:2]),
str(len(spec.get('unresolved') or []))]
# A spec on disk feeds Ticket QC whether this press wrote it or an
# earlier one did, so the label belongs on the whole folder, not
# only on what was extracted just now.
if the_scope.is_declared:
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
rows.append(row_cells)
columns = ['design', 'extractor', 'reviewed by', 'validation',
'unresolved notes']
if the_scope.is_declared:
columns.append('scope')
output.print_table(rows, columns=columns)
if the_scope.is_declared:
output.print_md(
'`scope` marks a spec whose design number falls outside {0}. '
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
'these specs if it is pointed at those pieces, and the label is '
'so that it is a decision rather than an accident.'
.format(the_scope.label()))
output.print_md(
'`reviewed by` stays NOT reviewed until a person sets '
'`source.reviewed_by` in the spec file by hand, which is the only '
'thing that ever marks a spec as checked.')
output.print_md(
'`extractor` says which reader wrote the file: `regex` is the '
'mechanical reader this button runs, `dsv4f-vision` is the second, '
'AI pass this button now chains after it (`tools/aiextract_run.py`, '
'DSV4F with vision -- owner\'s ruling, 2026-09-01), and `opus` / '
'`claude-code` are what an earlier AI reader stamped before that '
'ruling -- older files, read the same way. Nothing branches on the '
'value; it is shown so a spec written by one reader is not mistaken '
'for a spec written by another.')
# The extraction ran and everything is on the page. The record is 'ok'
# with a short summary; record() never raises, so a logging failure cannot
# turn a finished run into an error.
runlog.record('Design Extract', doc_title=doc.Title, outcome='ok',
report_text=('{0} design(s) written, {1} skipped; saved '
'copy: {2}'
.format(len(written), len(skipped),
chosen if not cancelled_save else 'none')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Design Extract', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,8 @@
title: Hot Reload
# No tooltip and no author on purpose -- the owner wants nothing on hover,
# and test_button_tooltips.py enforces it. Document the button in comments
# in script.py instead.
#
# One line: this is a row in a `.pulldown` (text beside a 16px icon), so a
# `\n` has nowhere to go. The dropdown grows to fit, so the title may read
# in full.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
#
# Hot Reload -- stop the WillTools routes bridge cleanly, THEN reload pyRevit.
#
# Why the stop must come first: the routes HTTP dispatch is wired to a module-
# global external event handler created once, on the host main thread, when
# `pyrevit/routes/server/server.py` is first imported (server.py:35-36), and it
# is NOT recreated across a reload -- pyRevit re-imports extension scripts, not
# its own lib. A request in flight when the engine is torn down runs its handler
# against a dead engine -> unhandled exception on a non-main thread -> Revit dies
# uncatchably (measured 2026-08-14; see startup.py and CLAUDE.md "Never reload").
# Stopping the HTTP server first closes that crash window. On reload, startup.py
# re-registers the routes and a fresh server comes back up automatically.
#
# If the server will NOT stop cleanly, this ABORTS rather than reloads -- a
# reload with a live bridge is the exact crash, so a bad stop must never fall
# through to it.
#
# IronPython 2.7: no f-strings. No module docstring / tooltip / author
# (test_button_tooltips.py).
# pylint: disable=import-error,invalid-name,broad-except
from pyrevit import EXEC_PARAMS
from pyrevit import routes
from pyrevit import forms
from pyrevit import script
from pyrevit.loader import sessionmgr
from pyrevit.loader import sessioninfo
logger = script.get_logger()
go = True
if EXEC_PARAMS.executed_from_ui:
go = forms.alert(
'Hot Reload stops the WillTools bridge, then reloads pyRevit.\n\n'
'Run this only when no bridge request is in flight -- no sweep, no '
'agent mid-exec. A request raised the instant before this click cannot '
'be cancelled and can still take Revit down.\n\nReload now?',
ok=False, yes=True, no=True)
if go:
stopped_clean = True
try:
routes.deactivate_server()
except Exception as ex:
stopped_clean = False
logger.error('deactivate_server raised: {0}'.format(ex))
# Verify the socket is actually gone before tearing the engine down. A
# deactivate that "succeeded" but left the server up is still unsafe.
try:
if routes.get_active_server() is not None:
stopped_clean = False
except Exception as ex:
stopped_clean = False
logger.error('get_active_server raised: {0}'.format(ex))
if not stopped_clean:
forms.alert(
'Could not stop the WillTools bridge cleanly.\n\n'
'Do NOT reload with a live bridge -- restart Revit instead of '
'reloading. (A reload while the routes handler is live is what '
'crashes Revit.)', ok=True)
else:
# Vouch to tools/rvt.py that THIS reload is a clean stop-first one, so
# the reloaded bridge is not refused as a raw-reload landmine. startup.py
# consumes this marker on the very next load and stamps that session
# 'hotreload'. Keyed by pid: a reload is in-process, so the same Revit
# picks it up and no other session can. Best-effort -- if it cannot be
# written the only cost is rvt.py refusing the reloaded bridge (the safe
# default), never a failed reload.
try:
import os as _os
from System.Diagnostics import Process as _Process
_here = _os.path.abspath(__file__).replace('\\', '/')
_mark = _here.find('/WillTools.extension/')
if _mark != -1:
_root = _here[:_mark]
else:
_root = _os.path.join(_os.path.expanduser('~'), 'pyrevit-dev')
_pid = _Process.GetCurrentProcess().Id
_flagpath = _os.path.join(
_root, '.bridge-hotreload-{0}.flag'.format(_pid))
_fh = open(_flagpath, 'w')
_fh.write('hotreload\n')
_fh.close()
except Exception as ex:
logger.warning('could not write hot-reload marker: {0}'.format(ex))
logger.info('WillTools bridge stopped. Reloading pyRevit...')
sessionmgr.reload_pyrevit()
# Mirrors the shipped Reload button: record the new session id.
script.get_results().newsession = sessioninfo.get_session_uuid()
@@ -0,0 +1,12 @@
title: Job Recon
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
# the tool changed while it was out: only its ribbon entry was ever parked,
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
#
# Reconcile the issued ticket set against the whole job.
#
# Every other check on this panel judges one ticket at a time, and no amount of
# per-ticket checking can find a ticket that is not there. This button asks the
# question about the SET: every piece printed on something, everything printed
# about a piece that exists, and the counts reconciling mark by mark.
#
# It reads. It never writes, and it opens no transaction.
#
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
# across this tab: the answer is the headline and is never folded; everything
# else follows it, collapsed; a section that carries a problem opens itself.
# The reader's contract is that if nothing below the headline is open, nothing
# needs him.
#
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
# for, and a count per finding -- not the findings themselves. On a job with
# four hundred uncovered pieces the lists are thousands of lines and the
# verdict is invisible inside them. So:
#
# * the headline is the totals and the finding counts, printed through
# pyRevit's own markdown and tables;
# * every non-empty finding is a section below it, OPEN, because there is no
# finding in this reconciliation that is merely interesting;
# * coverage by design, the page-set inventory, the categories swept, the
# checks that found nothing and the timings are folded;
# * and the two sections that say what this run could NOT see -- out of
# scope, and blind spots -- are separate, always, and carry their counts
# on their headers with the fold shut. A blind spot that is a REFUSAL
# (nothing reconciled) opens itself and is repeated in the headline,
# because a refusal reported quietly reads as a clean job.
#
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
# problem in it renders open as plain HTML and needs no script at all; and the
# whole report, nothing folded and nothing capped, is written to
# `last-report.txt` beside the model's other job data on every press, with the
# page saying where. A fold that will not open therefore costs a file open,
# never a lost sentence. All of that lives in `vibe.foldreport`.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Job Recon'
import os
import traceback
from pyrevit import revit, script
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
from vibe import foldreport as fold
from vibe import runlog
doc = revit.doc
output = script.get_output()
output.close_others()
REQUIRED = ('member.control_number', 'sheet.control_numbers')
# The cap on each list ON THE PAGE. The file written beside the model has no
# cap at all -- a page is read, a file is searched.
PAGE_LIMIT = 60
HOW_TO_MAP = """
This reconciliation needs roles the older checks never used. Add them to the
plant knowledge file (the layer that describes the office TEMPLATE), naming
whatever your title block and your pieces actually call these things:
parameters:
member:
control_number: <the piece's control number>
ticket_created: <optional -- the model's own 'a ticket exists' flag>
ticket_detailed: <optional>
sheet:
total_required: <the quantity the title block states>
sheet_count: <how many pages the ticket says it has>
# either list the control-number slots explicitly ...
control_numbers: [<slot 1>, <slot 2>, ...]
nomenclature:
# ... or, for numbered slots, match them with one expression:
control_slot_re: '^<PREFIX>_\\d+$'
Until then this button refuses rather than reporting a job it cannot see.
"""
def write_full(recon, full_path):
"""The whole report, unfolded, beside the model. Returns True if written.
Written BEFORE anything is printed, so the file exists even if the
printing throws. `as_text` first and encode second: under IronPython 2.7
the report is a byte string and a mark carrying a non-ASCII character
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
"""
payload = compat.as_text(
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
return True
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if problems:
for line in problems:
output.print_md('**' + line + '**')
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
report_text=('\n'.join(problems) +
'\n\nJob Recon cannot run until the '
'configuration is fixed.'))
return
names = knowledge.param_names(know, 'member.control_number')
slots = (knowledge.param_names(know, 'sheet.control_numbers')
or knowledge.get(know, 'nomenclature.control_slot_re', None))
if not names or not slots:
output.print_md('## Job Recon cannot run')
output.print_md('Nothing was reconciled, and that is **not** a clean '
'result -- it is a refusal.')
print(HOW_TO_MAP)
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
report_text=('Job Recon cannot run: the control-number '
'roles are not mapped.\n' + HOW_TO_MAP))
return
recon = jobreconscan.run(doc, know=know)
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
'last-report.txt')
written = False
try:
written = write_full(recon, full_path)
except Exception as exc:
output.print_md('> The full report could not be written to disk ({0}). '
'Everything is still on this page; use the fold '
'headers.'.format(exc))
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
full_path=full_path if written else None)
# ---- the answer, through pyRevit, big and first.
for block in sections[0].blocks:
if block[0] == fold.TABLE:
output.print_table(block[2], columns=block[1])
else:
output.print_md(block[1])
# ---- everything else, folded.
tail = sections[1:]
if not tail:
# The headline alone was the whole page -- recorded the same as a
# full page, with the file copy's path when one was written.
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
report_path=full_path if written else None,
report_text='Job Recon ran -- nothing below the headline '
'to report.')
return
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
output.print_md('---')
if opened:
output.print_md('**{0} of the {1} section(s) below opened themselves '
'&mdash; they have something in them that needs you.** '
'The rest are folded; click a header to open one.'
.format(len(opened), len(tail)))
else:
output.print_md('**Nothing below needs you.** Every section is folded; '
'click a header to open one.')
if written:
output.print_md('The whole report, nothing folded and nothing capped, '
'is in `{0}`.'.format(full_path))
output.print_html(fold.html(tail))
# The page is complete and the file copy exists. The verdict in numbers
# is the headline, which `sections[0]` carried as markdown -- this short
# summary says what reconciled, and points at the file for the rest.
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
report_path=full_path if written else None,
report_text='Job Recon ran; full report at {0}'
.format(full_path if written else 'this page'))
try:
main()
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Job Recon', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,167 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Project Config"
ShowInTaskbar="False"
WindowStartupLocation="CenterScreen"
SizeToContent="Height"
Width="880" MaxHeight="940" MinHeight="380"
ResizeMode="CanResize"
Background="#f6f7f9">
<!--
THE WINDOW IS CHROME ONLY. Every field is built in `script.py` from
`vibe.configform`, because the field list is data: it depends on which
build of `configmodel` this Revit session is holding, on which stages
the job has, and on how many checklist items are muted. A XAML
DataTemplate would have had to know all three.
What lives here is what does not change: the four module boxes, the
reason line every job-layer write needs, and the four buttons. Every
x:Name below is looked up by name in `script.py`, and every Click
handler names a method on the window class. `tests/test_configform.py`
parses this file and that one together and fails if the two have
drifted, which is the only check available for a window that cannot be
opened without Revit.
EVERY MODULE IS AN Expander, NOT A GroupBox, AND STARTS COLLAPSED. The
owner's ruling, 2026-09-01, verbatim in `script.py`'s own comment (kept
out of THIS file because the no-Chinese-in-the-XAML test below scans
every byte of it, comments included, and not only the strings a build
would render): every section folds, and folded is the default; open
the one you came to change, adjust it, done. `IsExpanded="False"` here
is the fallback for the very first time this window opens in a
session; `script.py` overrides it per module from
`vibe.configform.remembered_expanded`, which is the ONLY thing that
may reopen one on its own, since a module the owner left open a minute
ago staying open is a convenience, and a module opening because it
carries a value is not this window's call to make silently, which is
why the header text says so instead (see `configform.is_modified` /
`modified_count`, and `script.py`'s use of them building each
Header).
No double hyphen anywhere in this comment: XML forbids it inside a
comment, and the test above catches it as a parse error rather than as
a window that will not load.
-->
<Window.Resources>
<!-- No FontWeight or Foreground here: a GroupBox passes both down to
its CONTENT, so setting them to style the header would make every
label and every note in the box bold as well. The rows set their
own weight and colour in script.py. -->
<Style TargetType="{x:Type GroupBox}">
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Padding" Value="6"/>
<Setter Property="BorderBrush" Value="#c9d2dd"/>
</Style>
<!-- The four modules are Expanders now, not GroupBoxes; see the
note above this Window's own opening comment. Same border,
same spacing, so folding cost nothing visually except the
fold itself. -->
<Style TargetType="{x:Type Expander}">
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Padding" Value="6"/>
<Setter Property="BorderBrush" Value="#c9d2dd"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="White"/>
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Padding" Value="10,3,10,3"/>
<Setter Property="MinWidth" Value="76"/>
<Setter Property="Margin" Value="0,0,6,0"/>
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Padding" Value="3,2,3,2"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
</Window.Resources>
<DockPanel Margin="14">
<!-- The sentence that used to be cut off by a 36-pixel Label. It
wraps here, because there is room here. -->
<TextBlock x:Name="header"
DockPanel.Dock="Top"
TextWrapping="Wrap"
FontWeight="Bold"
Foreground="#2c3e50"
Margin="0,0,0,4"
Text="Project Config"/>
<TextBlock x:Name="subheader"
DockPanel.Dock="Top"
TextWrapping="Wrap"
Foreground="#5a6b7d"
Margin="0,0,0,10"
Text=""/>
<Border DockPanel.Dock="Bottom"
Background="#eef1f5"
BorderBrush="#c9d2dd"
BorderThickness="1"
Padding="8"
Margin="0,10,0,0">
<StackPanel>
<TextBlock x:Name="status"
TextWrapping="Wrap"
Foreground="#2c3e50"
Margin="0,0,0,6"
Text="Nothing changed yet."/>
<DockPanel Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left"
VerticalAlignment="Center"
Width="150"
Text="Why (written into the file)"/>
<TextBox x:Name="reason_box" Height="24"/>
</DockPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="save_button"
Content="Save to the job layer"
MinWidth="150"
Click="save_click"/>
<Button x:Name="cancel_button"
Content="Close"
Click="cancel_click"/>
<Button x:Name="openfile_button"
Content="Open the job file"
MinWidth="130"
Click="openfile_click"/>
<Button x:Name="fullview_button"
Content="Full view"
Click="fullview_click"/>
</StackPanel>
</StackPanel>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Expander x:Name="group_file_path" Header="File paths"
IsExpanded="False">
<StackPanel x:Name="panel_file_path"/>
</Expander>
<Expander x:Name="group_lifting" Header="Lifting and handling"
IsExpanded="False">
<StackPanel x:Name="panel_lifting"/>
</Expander>
<Expander x:Name="group_ticketqc" Header="Ticket QC"
IsExpanded="False">
<StackPanel x:Name="panel_ticketqc"/>
</Expander>
<Expander x:Name="group_job_knowledge" Header="Job knowledge"
IsExpanded="False">
<StackPanel x:Name="panel_job_knowledge"/>
</Expander>
<Border Background="#fff8e6"
BorderBrush="#e0c98a"
BorderThickness="1"
Padding="8">
<TextBlock x:Name="rest_line"
TextWrapping="Wrap"
Foreground="#6b5a20"
Text="Everything else: edit the config file directly."/>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,29 @@
title: Project Config
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses is visible without
# a Revit restart. It trades press latency (engine construction instead of
# engine reuse) for restart-freedom. Second-hottest button on this ribbon --
# 14 commits to configmodel/configpage/configform/configdump/knowledge in the
# seven days to 2026-08-30.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# "Project Config" rather than plain "Config": the button one level up is
# already called Config, and a Config row inside a Config button reads like
# a mistake even though the two name tables never collide.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,8 @@
title: Self Check
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
#
# Report whether this machine, this Revit and this model can run the QC tools.
#
# Written for the moment the extension lands on a computer it was not developed
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
# so a failure names itself instead of arriving as a traceback from four frames
# deep in a checking routine.
#
# Reads nothing but metadata and does not touch the model.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Self Check'
import os
import sys
import traceback
from pyrevit import revit, script, HOST_APP
from vibe import runlog
output = script.get_output()
output.close_others()
doc = revit.doc
rows = []
def report(area, item, state, detail):
rows.append([area, item, state, detail])
def probe(area, item, fn, missing='FAIL'):
"""Run one probe. An exception is a finding, never a crash."""
try:
state, detail = fn()
report(area, item, state or missing, detail)
except Exception as ex:
# Name the frame that blew, not just the exception: a bare
# "SystemError: Object reference not set to an instance of an object"
# (IronPython's face for a .NET NullReferenceException) says nothing
# about WHERE, and 2026-09-05 one of these cost a round trip to the
# owner's machine to find out. Innermost frame of our own code first.
where = ''
try:
import traceback
frames = traceback.extract_tb(sys.exc_info()[2])
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
if ours:
fn, ln, func, _ = ours[-1]
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
except Exception:
pass
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
# ------------------------------------------------------------------- host
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
def _revit():
app = HOST_APP.app
version = app.VersionNumber
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
if version == DEVELOPED_ON['revit']:
return 'OK', detail
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
DEVELOPED_ON['revit'])
def _pyrevit():
from pyrevit import versionmgr
version = versionmgr.get_pyrevit_version().get_formatted()
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
return state, version
def _engine():
detail = sys.version.split('\n')[0]
major = sys.version_info[0]
if major == 2:
return 'OK', detail
# The code is written for both, but only Python 2 has been exercised.
return 'WARN', detail + ' (only Python 2 has been run in anger)'
def _routes():
from pyrevit.userconfig import user_config
if not user_config.routes_server:
return 'WARN', ('routes server off - the Claude Code bridge will not '
'connect. The buttons themselves do not need it.')
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
user_config.routes_port)
probe('host', 'Revit', _revit)
probe('host', 'pyRevit', _pyrevit)
probe('host', 'python engine', _engine)
probe('host', 'routes bridge', _routes, missing='WARN')
# ---------------------------------------------------------------- library
def _import_all():
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
ticketqc, ticketchecks, aiextract)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks, aiextract'
def _units():
from vibe import ft, mm
round_trip = mm(ft(1000.0))
if abs(round_trip - 1000.0) > 1e-6:
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
return 'OK', '1000 mm round-trips exactly'
def _checklist():
from vibe import ticketpage
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
return 'OK', '{0} row(s) from {1}'.format(
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
probe('library', 'modules import', _import_all)
probe('library', 'unit conversion', _units)
probe('library', 'published checklist', _checklist, missing='WARN')
# ----------------------------------------------------------------- tooling
def _claude():
from vibe import aiextract
found = aiextract.find_claude()
if not found:
return 'WARN', ('not found - Extract Design cannot run. Install the '
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
return 'OK', found
probe('tooling', 'Claude Code CLI', _claude, missing='WARN')
# ------------------------------------------------------------------- model
def _saved():
if not doc.PathName:
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
return 'OK', doc.PathName
def _data_dir():
from vibe import designspec
return 'OK', designspec.root_dir(doc)
def _knowledge():
"""Every knowledge layer, in the order load() merges them.
Which layers exist decides what the tool actually believes, so the answer
is the list, not a yes/no: naming conventions that should be plant-wide
sitting only in a project file is a real and invisible mistake.
"""
from vibe import knowledge
layers = knowledge.sources(doc)
detail = ' | '.join(
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
for label, path, ok in layers])
# A project config that has gone missing is worse than one never set: the
# run looks entirely normal and uses a configuration nobody chose.
gone = [path for label, path, ok in layers
if label == 'project config' and not ok]
if gone:
return 'FAIL', ('the project config this model runs on is not there: '
'{0} || {1}'.format(gone[0], detail))
if not any(ok for _label, _p, ok in layers):
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
return 'OK', detail
def _knowledge_parses():
"""Read every layer file that IS there, exactly the way load() reads it.
The layer list above says which files exist. It does not say whether any
of them can be READ, and those are different questions with the same
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
mapping raises `Duplicate key` and it returns nothing -- and a layer that
fails to load looks exactly like a layer that agrees with the defaults.
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
layer, every parameter role came back None, the sweep enumerated 0
assemblies, and every report rendered normally.
So this re-reads each file through the same reader and prints ok or the
parser's own message. `knowledge.config_problems()` reports the same
failure from the merge itself; this is the early warning, on the layer
list, before a button is pressed. `python tools/knowledgelint.py` is the
same check on the host, where the edit is made.
"""
from pyrevit.coreutils import yaml as pyyaml
from vibe import knowledge
notes = []
bad = []
for label, path, ok in knowledge.sources(doc):
if not ok:
notes.append('{0}: absent'.format(label))
continue
try:
pyyaml.load_as_dict(path)
notes.append('{0}: ok'.format(label))
except Exception as ex:
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
label, path, type(ex).__name__, ex))
if bad:
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
'was used, and the run fell back to the layers under '
'it. Fix the file, then run '
'tools/knowledgelint.py to confirm.')
if not notes:
return 'WARN', 'no knowledge layer to read yet'
return 'OK', ' | '.join(notes)
def _specs():
from vibe import designspec
specs = designspec.list_specs(doc)
if not specs:
return 'WARN', 'none yet - run Extract Design'
unreviewed = [d for d, s in specs.items()
if not (s.get('source') or {}).get('reviewed_by')]
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
if unreviewed:
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
return 'OK', detail
def _scope():
"""What the job declares this engineer is answerable for.
Never a FAIL. An undeclared scope is a legitimate state -- it means every
button judges every piece -- and it is reported as such rather than as a
misconfiguration, because 'judge everything' is the honest default and
the only wrong answer here would be a scope the tool chose for itself.
"""
from vibe import knowledge
from vibe import scope as scopelib
the_scope = scopelib.of(knowledge.load(doc))
if not the_scope.is_declared:
return 'WARN', ('no scope declared -- every button judges every '
'piece on the job, including work whose plant '
'knowledge has never been validated. Set '
'scope.mark_prefixes in the job knowledge file to '
'be told what is being taken on trust.')
detail = the_scope.label()
if the_scope.declared_by:
detail += ' (declared by {0})'.format(the_scope.declared_by)
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
probe('model', 'saved', _saved)
probe('model', 'declared scope', _scope, missing='WARN')
probe('model', 'data folder', _data_dir)
probe('model', 'knowledge layers', _knowledge, missing='WARN')
probe('model', 'knowledge layers parse', _knowledge_parses)
probe('model', 'design specs', _specs, missing='WARN')
# --------------------------------------------------- project conventions
# These are the assumptions most likely to be wrong on a different job: they
# are naming and parameter conventions, not Revit API.
def _mapping():
"""Which parameter roles this office has told us the names for.
Nothing in the code knows what a panel's length parameter is called, so
an unmapped role is not a detail -- it is a check that will not run. It
reports before the convention probes below, because they depend on it.
"""
from vibe import knowledge, designspec
know = knowledge.load(doc)
missing, not_applicable = knowledge.unmapped_roles(know)
total = len(knowledge.PARAMETER_ROLES)
if missing:
# The template is the plant's, so that is where the names belong; a
# project file may still override one.
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
len(missing), total, ', '.join(missing),
knowledge.plant_file_in_use(know),
'; {0} declared not applicable: {1}'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
if not_applicable else '')
mapped = ['{0}={1}'.format(r.split('.')[-1],
knowledge.param_names(know, r)[0])
for r in knowledge.PARAMETER_ROLES
if knowledge.param_names(know, r)]
extra = ''
if not_applicable:
extra = '; {0} declared not applicable at this plant ({1})'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
def _plant_gaps():
"""Plant-owned data the buttons need, checked at setup time.
The per-button refusals gate on `handling.lifter_groups`,
`handling.stages` and friends, so a gap is normally found by the user
as a per-piece NOT RUN. This probe runs the same gate off the merged
tree and says which plant file to edit, so the developer finds it here
instead -- see knowledge.plant_layer_gaps for the wording.
"""
from vibe import knowledge
know = knowledge.load(doc)
gaps = knowledge.plant_layer_gaps(know)
if not gaps:
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
knowledge.plant_file_in_use(know))
texts = [g['text'] for g in gaps]
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
def _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
# Two states that used to share one FAIL (issue #20). A model with
# NO assemblies at all is a job that has not reached the reinforcing
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
# Assemblies present but none carrying a design number is the
# convention failure this probe exists for.
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
if n_asm == 0:
return 'WARN', ('no assemblies in this model yet - the job has not '
'reached the reinforcing stage; the design-number '
'grouping is checked once assemblies exist')
return 'FAIL', ('{0} assemblies, none carries a design number - either '
'the panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so'
.format(n_asm))
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
return 'OK', '{0} designs, {1} pieces: {2}'.format(
len(grouped), sum([len(v) for v in grouped.values()]),
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
def _summaries():
from vibe import ticketqc
summaries, sheets = ticketqc.collect_context(doc)
if not summaries:
return 'FAIL', ('no drafting view is named after a design number - '
'Extract Design has nothing to read')
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
len(sheets))
probe('conventions', 'parameter role mapping', _mapping)
probe('conventions', 'plant layer completeness', _plant_gaps)
probe('conventions', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
#
# The whole report body is wrapped so an unexpected exception still leaves a
# run-log record (outcome='error') before pyRevit shows its own error dialog.
try:
output.print_md('# Self check')
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
fails = len([r for r in rows if r[2] == 'FAIL'])
warns = len([r for r in rows if r[2] == 'WARN'])
if fails:
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
output.print_md('A **FAIL** means a QC button will not work here. Start '
'with the first one - they cascade.')
elif warns:
output.print_md('## Ready, with {0} warning(s)'.format(warns))
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
else:
output.print_md('## Everything checks out')
outcome = 'ok' if not fails else 'fail'
runlog.record(
'Self Check', doc_title=doc.Title,
outcome=outcome,
report_text='\n'.join(
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
except Exception as exc:
runlog.record('Self Check',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(exc),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,10 @@
title: Takeoff
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
#
# The takeoff, for the whole job: what it contains, per piece and in total.
#
# The counting lives in `vibe.takeoffcore` (classification and both totals),
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
# (the two renderings), all three Revit-free and unit-tested on host CPython.
# `vibe.takeoffgather` is the only part that touches a document. This file is
# the button and nothing more.
#
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
# reports what a model contains; it has no business changing it.
#
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
# -----------------------------------------------------------
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
# takeoff of the model as it was, and the report says when the index was built
# so a reader who has been editing can see the stamp predates his edits. A
# button that silently spends ten seconds rebuilding is a button that sometimes
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
# and it has the progress bar for it.
#
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
# -----------------------------------------------------
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
# ordering is done once for the job, and a per-piece takeoff is already in
# the by-piece CSV this writes.
# 2. It multiplies by control number, and prints both figures. Per-mark and
# per-job answer different questions and a reader will want to check one
# against the other, so neither is hidden behind the other.
# 3. The multiplier comes from the control numbers on framing elements, via
# the job reconciliation's own gatherer -- not from assembly names, which
# were measured through the bridge and found to be 755 unique names for 755
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
# printed as a refusal at the top of the multiplier section, never as a job
# where every mark happens to be one piece.
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
# that looks like a takeoff of a job with nothing in it is the worst
# possible output, so it says the index has not been built and names the
# button that builds it.
# 5. The reports go beside the model, never into the repository. They name
# marks, part numbers and quantities for one client's job.
#
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
# --------------------------------------------------
# The owner's standing rule for the whole tab, and it lives in
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
# first and never folded; everything else follows FOLDED; a section with a
# problem in it opens itself and says on its header why. The contract that
# buys is "if nothing below the headline is open, nothing needs me", and it
# is worth more than any one section being visible.
#
# Here the headline is the ORDER -- hardware by part number, reinforcing by
# bar mark, both figures on every line, with what multiplied them written
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
# tallies fold. The refusal does not: an unmapped control-number role means
# the model was never asked how many pieces a mark is, every per-job figure
# is an undercount, and that renders OPEN as plain HTML.
#
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
# ---------------------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
# `<details>` does not exist there and fails SILENTLY. So the fold is an
# inline `onclick` plus `style.display`, and it has not been exercised inside
# Revit by the agent that wrote it. Two things make that survivable:
# a section with a problem renders open with no script at all, and the whole
# report, nothing folded, is the `.md` written beside the model every press
# -- which this page names. A dead toggle costs a file open, never a
# sentence.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Takeoff'
import traceback
from pyrevit import revit, script, forms
from vibe import foldreport as fold
from vibe import knowledge
from vibe import runlog
from vibe import takeoffgather as gather
from vibe import takeoffreport as report
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Takeoff'
def not_judged(headline, message):
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
a dialog that vanishes. It must never read as 'judged and found
nothing'."""
output.print_md('# {0}'.format(TITLE))
output.print_md('## {0}'.format(headline))
output.print_md(message)
runlog.record('Takeoff', doc_title=doc.Title, outcome='not-run',
report_text='{0}: {1}'.format(headline, message))
script.exit()
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if problems:
runlog.record('Takeoff', doc_title=doc.Title, outcome='refused',
report_text='\n'.join(problems))
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
takeoff, counts, extras = gather.run(doc, know=know)
coverage = takeoff.get('coverage') or {}
if not coverage.get('indexed_marks'):
not_judged(
'The reinforcing index has not been built for this model',
'A takeoff is built from that index and there is nothing in it '
'yet. This is not a job with no hardware in it -- it is a '
'question nobody has asked the model.\n\n'
'Run **Reinforcing Suggestion** once; it builds the index. Then '
'press this again.')
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
# printed -- the file then exists even if the printing throws, and the
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
# every section in full, folded or not, so it can never say less than
# this page does.
paths, write_error = {}, None
try:
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
except Exception as exc:
write_error = exc
# ---- the answer, through pyRevit, big and first.
for block in sections[0].blocks:
if block[0] == fold.TABLE:
output.print_table(block[2], columns=block[1])
else:
output.print_md(block[1])
# ---- everything else, folded, and the one line that says whether any of
# it needs him. That line IS the contract; without it a page of shut
# headers looks the same whether or not something is wrong.
tail = sections[1:]
if tail:
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
output.print_md('---')
if opened:
output.print_md(
'**{0} of the {1} section(s) below opened themselves &mdash; '
'they have something in them that needs you.** The rest are '
'folded; click a header to open one.'
.format(len(opened), len(tail)))
else:
output.print_md(
'**Nothing below needs you.** Every section is folded; click '
'a header to open one.')
if paths.get('.md'):
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
.format(paths['.md']))
output.print_html(fold.html(tail))
output.print_md('---')
if write_error is not None:
# The page is still complete, but only while the toggle works -- so
# this says which, rather than the old flat report's promise that
# what is on the page is all of it.
output.print_md(
'**The report could not be written to disk:** {0}. Every section '
'is still on this page, but the folded ones need the click to '
'open; there is no file copy of them this press.'.format(
write_error))
else:
output.print_md('Written beside the model:')
for suffix in sorted(paths):
output.print_md('- `{0}`'.format(paths[suffix]))
output.print_md('---')
output.print_md(
'_Read only. Nothing was written to the model. Index built '
'{0}._'.format(coverage.get('built_at') or 'never'))
# The takeoff is on the page and (usually) in the files beside the model
# -- the record is 'ok' with the paths that were written.
runlog.record('Takeoff', doc_title=doc.Title, outcome='ok',
report_path=paths.get('.md'),
report_text=('Takeoff ran; index built {0}. Reports: {1}'
.format(coverage.get('built_at') or 'never',
', '.join(sorted(paths)) or 'not '
'written')))
try:
main()
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Takeoff', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,31 @@
# A `.pulldown` is a large button with a downward arrow: one icon on the
# panel, and every item below opens as a row when it is clicked
# (`components.PullDownButtonGroup`, drawn by
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
# keep their own name table, so the "Project Config" row inside this button
# does not collide with the button itself.
#
# The pulldown's own label. Unlike a stack, a pulldown container does read a
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
# from it, and `_produce_ui_pulldown` passes that straight to
# `create_pulldown_button`.
title: Config
# Row order, top to bottom. Rows are text beside a 16px icon and the
# dropdown is as wide as it needs to be, so these titles are free to say
# what they mean -- unlike the four large buttons on Tools, which are
# rationed to ~9 characters a line.
#
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
# 2026-09-02, at the owner's request, they are parked again: pulled out of
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
# the two lines -- the owner will decide later how to handle them.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ProjectConfig
- ExtractDesign
- SelfCheck
- HotReload
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Setup").
#
# One pulldown, on purpose. These are the commands you run to get a job
# ready or to look at it sideways -- not the four you press all day. They
# were taking two stack slots and a whole panel between them; behind one
# arrow they take none.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- Config
@@ -0,0 +1,11 @@
title: Reinforce Suggestion
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. A pulldown row renders as text beside a
# 16px icon and the dropdown grows to fit the longest row, so the title is
# free to say what it means, unlike a large button rationed to ~9 characters
# a line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,459 @@
# -*- coding: utf-8 -*-
#
# Reinforcing suggestion for one assembly: which already-reinforced panels,
# between them, account for every feature of this one.
#
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
# a similarity ranking. Every detail of the target has to be covered by
# something, and the number of donors should be as small as possible. The
# search lives in `vibe.reinforcematch`, the candidate index in
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
# the Revit end and nothing more: it resolves the selection, decides how
# current the index has to be, and prints.
#
# READ ONLY, AND THAT IS THE POINT
# --------------------------------
# No Transaction is opened anywhere in this file or anything it calls. Nothing
# is written to the model -- not a parameter, not a rebar, not a copy of a
# donor's cage. The tool proposes; a human decides and a human draws it. This
# is the single most important constraint on the feature, because a suggestion
# tool is exactly the thing somebody will ask to "just copy it in for me", and
# the middle band of a wall panel's vertical reinforcing is explicitly PER
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
# automatically would look like an ordinary suggestion and would be the most
# dangerous thing this feature could do.
#
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
# ----------------------------------------------------
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
# on which a button press deserves. Which one to spend is a UI policy question
# (how many seconds may a click cost, and what does a modifier key mean), so it
# belongs at the UI, in one readable place, not buried in the library where two
# callers would end up disagreeing.
#
# The policy, with the measured numbers it rests on:
#
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
# moved. Nothing moved is the common case and then nothing is rebuilt and
# nothing is rewritten: about a second. A full rebuild on every press is
# 10 s of frozen UI to answer a question that usually has not changed.
# * cold or empty index -- a full build regardless, ~10 s, with a progress
# bar. There is no cheaper honest answer available.
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
#
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
# ---------------------------------------------------
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
# only between donor sets the geometry cannot separate. Owner's ruling: fit
# wins. The report marks it and says so.
#
# The staleness signature is `reinforced | on_hold | master | member-count |
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
# catch a geometry edit that leaves the member set alone -- an opening moved or
# resized in place, a panel stretched. Those pieces serve a stale measurement
# until something else about them moves.
#
# So an ordinary press can be wrong in exactly that one way, and the report
# says so every time, in `reinforcereport.staleness_note`, along with the
# instruction to shift-click. The report also always prints when the index was
# built, so a reader who knows he has been editing geometry can tell whether
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
# inconvenience, not a last resort.
#
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
# -----------------------------------------------------
# 1. One target per press. The engineer is reinforcing one piece; a batch of
# suggestions is a report nobody reads. Several assemblies preselected asks
# which one rather than fanning out.
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
# told, so it is in the footer of every report and in the staleness note
# the report prints when the index may be out of date. It used to be in the
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
# the report is now the only place that says it. A dialog
# on every press would be worse: the answer is the same 99 times out of 100.
# 3. The outline audit runs automatically the first time only. It costs one
# geometry read per indexed piece, and its answer changes only when the
# model does. Never-audited and audited-and-found-none are different
# answers, so the report says which one it has.
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
# four cannot account for everything, the report prints the best four and
# names what they miss -- it does not relax to a fifth. That overflow rule
# is INFERRED from the ruling, not stated by the owner, and is the thing to
# overrule first if this behaves wrongly on a real piece.
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
# 6. A target that is itself already reinforced, or on hold, is answered
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
# rules -- but silently treating an already-reinforced piece as a fresh
# request would hide a misclick.
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
# count as a problem is `reinforcereport`'s decision and is stated there,
# section by section; the one to argue with first is the staleness rule,
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
# a folded header names what it holds, and the whole report is written
# unfolded to `last-report.txt` beside the index on every press.
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
# "not judged" and an exit. It used to lose the index stamp and the
# staleness note on the way out, which are exactly what a reader needs in
# order to know whether to believe a refusal.
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
# before a single comparison is made, and when no same-design donor exists
# this button REFUSES -- it names the design, counts what the pool actually
# held, and says the way out. It never falls back to the nearest other
# design, not even labelled as one. Packet suffixes do not split a design:
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
# `specformat.design_key` reduction Ticket QC already uses.
# A design number nobody could read is UNJUDGED, never a match -- an index
# whose design numbers are all empty (which is what a build with no
# knowledge layer produces) refuses and asks for a rebuild.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinforce Suggestion'
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
# the force-full-rebuild gesture. See the notes at the top of this file.
__shiftclick__ = True
import os
import traceback
from Autodesk.Revit.DB import AssemblyInstance, ElementId
from pyrevit import revit, script, forms
from vibe import compat
from vibe import foldreport as fold
from vibe import knowledge
from vibe import runlog
from vibe import scope as scopelib
from vibe import reinforcebind as bind
from vibe import reinforcecore as core
from vibe import reinforceindex as index
from vibe import reinforcereport as R
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = R.TITLE
def die(message):
"""A usage error: the user has not given us something yet. Not a refusal
to judge -- those go in the report, where they survive the dialog."""
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused', report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
# `not_judged` used to live here: a short page and an exit, for a target that
# could not be measured. It is gone, and deliberately -- 'could not judge' is
# a RESULT, and printing it on a stripped-down page dropped the index stamp
# and the staleness note, which are precisely what tells a reader whether to
# believe the refusal. `reinforcereport.render` now returns the whole page for
# that case too, with the headline saying NOT JUDGED, and this file prints it
# the same way it prints an answer.
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------------ force rebuild
def _forced():
"""True when the button was shift-clicked. Never fatal if unavailable."""
try:
from pyrevit import EXEC_PARAMS
return bool(EXEC_PARAMS.config_mode)
except Exception:
return False
# ------------------------------------------------------------- the selection
#
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
# straight through, a picked member resolves to its owning assembly, nothing
# picked offers a list. The one difference is that this tool answers about
# ONE piece, so several preselected assemblies ask which.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not picked_assemblies:
owners = []
for el in selection:
try:
owner = doc.GetElement(el.AssemblyInstanceId)
except Exception:
owner = None
if isinstance(owner, AssemblyInstance) and owner not in owners:
owners.append(owner)
picked_assemblies = owners
if selection and not picked_assemblies:
die('What is selected is not part of an assembly, so there is no piece '
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
'any member of it -- and run this again.')
all_assemblies = index.assemblies(doc)
by_name = dict(all_assemblies)
# The knowledge file is read here rather than further down, because the pick
# list below needs the DECLARED SCOPE before it can offer anything.
try:
know = knowledge.load(doc)
except Exception as exc:
know = None
output.print_md('> The knowledge file could not be read ({0}). No scope '
'is declared, so every assembly in the model is offered '
'&mdash; and **design numbers will be blank**, which '
'since 2026-09-01 means this run will REFUSE rather than '
'answer: a donor has to carry the same design number as '
'the target, and an unread design number is never assumed '
'to match. Fix the knowledge file, or shift-click to '
'rebuild the index once it reads.'.format(exc))
the_scope = scopelib.of(know) if know else scopelib.Scope()
if not picked_assemblies:
# The list this button OFFERS is scoped: this is the TOOL choosing, and
# it chooses inside the declared scope. What the user picks HIMSELF is
# never filtered -- see the branches below and the report's annotation.
names, left_alone = the_scope.split_names(
sorted(n for n, _a in all_assemblies))
if not names:
die('There is nothing here to pick from.\n\n{0}'
.format(the_scope.line(left_alone, total=len(all_assemblies),
unit='assembly')))
chosen = forms.SelectFromList.show(
names, multiselect=False, title=TITLE + ' - which piece',
button_name='Suggest donors')
if not chosen:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target chosen.')
script.exit()
target_name = chosen
elif len(picked_assemblies) > 1:
names = sorted(str(a.Name) for a in picked_assemblies)
chosen = forms.SelectFromList.show(
names, multiselect=False,
title=TITLE + ' - one piece at a time',
button_name='Suggest donors')
if not chosen:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target chosen.')
script.exit()
target_name = chosen
else:
target_name = str(picked_assemblies[0].Name)
target_asm = by_name.get(target_name)
if target_asm is None:
die('{0} is not an assembly in this model.'.format(target_name))
def _flag(el, pname):
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
try:
p = el.LookupParameter(pname)
return None if p is None else int(p.AsInteger())
except Exception:
return None
target_reinforced = _flag(target_asm, index.P_REINFORCED)
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
# ------------------------------------------------------------------ the index
#
# See the notes at the top of this file for the policy. The cheap plan is
# computed here rather than inside `refresh` so that the commonest press --
# nothing changed -- can skip `refresh` entirely and avoid a second
# signature pass, and so the progress bar knows how many pieces it is about
# to build.
forced = _forced()
# `know` was loaded above, for the pick list. Past the scope it is used for
# one optional thing -- reading each piece's design number for the report --
# so a missing or broken one costs a label, not the answer. It is not worth
# stopping the run over, unlike in Ticket QC where every check depends on it.
cache = index.read(doc)
if cache.get('_load_error'):
output.print_md('> The index on disk could not be read ({0}); it is being '
'rebuilt from the model. A cache is derived data, so this '
'costs time and nothing else.'
.format(cache['_load_error']))
forced = True
live = index.live_signatures(doc, all_assemblies, know=know)
the_plan = core.plan(cache, live)
todo = len(the_plan['build'])
refreshed = bool(todo or the_plan['drop']) or forced
refresh_report = None
if refreshed:
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
bar = 'Indexing donors ({value} of {max_value})'
if forced:
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
with forms.ProgressBar(title=bar, cancellable=False) as pb:
state = {'n': 0}
def _tick(_name):
state['n'] += 1
pb.update_progress(state['n'], max(total, 1))
pb.update_progress(0, max(total, 1))
cache, refresh_report = index.refresh(
doc, rebuild_all=forced, know=know, on_piece=_tick)
# The outline audit: once, unless forced. It is what keeps an approximated
# edge condition from ever being printed as a measured exact match.
audit_needed = forced or (bind.load_audit(doc) is None)
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
indeterminate=True):
suggestion, info = bind.suggest_for_piece(
doc, target_name, refresh=False, audit=audit_needed, know=know)
# ------------------------------------------------------------------- report
#
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
#
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
#
# So the target and the donor proposals are the page, printed big and first
# through pyRevit's own markdown and tables -- which is also what can linkify
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
# section that has a problem in it opens itself. That turns the page into a
# contract: if nothing below the answer is open, nothing needs him.
#
# The rule lives in `vibe.foldreport`, not here, and the sections come from
# `reinforcereport.render` -- the same sections the offline CLI prints as
# plain text, so the two cannot say different things.
#
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
# -----------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
# hosted in WinForms, running in IE7 document mode unless the host process
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
# element renders inline and the "collapsed" content is simply always visible.
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
# but the toggle has NOT been exercised inside Revit, so it is designed for
# failure rather than trusted:
#
# * a section with a problem in it renders OPEN, as plain HTML, and does not
# need the script to run at all;
# * the whole report, nothing folded, is written to `last-report.txt` beside
# the index every press, and the page says where. A fold that will not
# open therefore costs a file open, never a lost sentence.
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
'last-report.txt')
def _donor_link(_name, record):
"""pyRevit's clickable element id, for a donor's line in the headline."""
if not (record or {}).get('asm_id'):
return None
try:
return output.linkify(ElementId(record['asm_id']), 'select assembly')
except Exception:
return None
best = suggestion.best if suggestion is not None else None
rows = bind.explain(suggestion, info, best) if best is not None else []
sections = R.render(suggestion, info, rows, cache,
model=doc.Title,
refresh_report=refresh_report,
rebuilt_all=forced, refreshed=refreshed,
offline=False,
reinforced=target_reinforced, on_hold=target_on_hold,
full_path=full_path, donor_link=_donor_link)
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
# even if the printing throws.
try:
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
# string, and a piece mark or design number carrying a non-ASCII character
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
payload = compat.as_text(fold.text(sections, show_all=True))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
except Exception as exc:
output.print_md('> The full report could not be written to disk ({0}). '
'Everything is still on this page; use the fold headers.'
.format(exc))
# ---- the answer, through pyRevit, big and first.
for block in sections[0].blocks:
if block[0] == fold.TABLE:
output.print_table(block[2], columns=block[1])
else:
output.print_md(block[1])
# ---- everything else, folded.
tail = sections[1:]
if tail:
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
if opened:
output.print_md('---')
output.print_md('**{0} of the {1} section(s) below opened themselves '
'&mdash; they have something in them that needs you.** '
'The rest are folded; click a header to open one.'
.format(len(opened), len(tail)))
else:
output.print_md('---')
output.print_md('**Nothing below needs you.** Every section is folded; '
'click a header to open one. The whole report is also '
'in `{0}`.'.format(full_path))
output.print_html(fold.html(tail))
# The report is on the page and the full text is on disk -- record the run
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
# the page led with; keep the record short, since report_text is capped.
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
report_path=full_path,
report_text=('Target {0}; suggestion {1}'
.format(target_name,
'found' if best is not None
else 'not judged')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
title: Reinf QC
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses (it shares
# `lib/vibe/ticketqc_run.py` and `ticketchecks.py` with Ticket QC) is
# visible without a Revit restart. See TicketQC.pushbutton/bundle.yaml for
# the full explanation; this one carries the same key for the same reason.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Added 2026-09-05, on the owner's own instruction: a cut-down TicketQC for
# a panel whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started. Shares its engine with
# TicketQC -- both call into `lib/vibe/ticketqc_run.py`, which takes a
# `mode` of 'ticket' or 'reinforcing' -- and reads the same evidence one
# stage earlier: no ticket sheet is required, and any check whose evidence
# is a ticket artefact (a hatch, a callout, a BOM table, a sheet name...)
# does not run here. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` for
# exactly which of the 41 checks that is, and why, one by one.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means -- kept
# short anyway so it reads at a glance beside "Ticket QC".
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
#
# Check a panel's MODELLED reinforcing against its design spec -- for a
# piece whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started yet.
#
# A cut-down Ticket QC: same engine, same evidence-first discipline, but
# only the checks whose evidence is the panel's modelled content run here
# (connectors/embeds, lifters, strands, bars, WWF, solid zones as modelled,
# member interference, weight, cast extras, release ratio, the handling
# result, lift positions/points). Anything that needs a ticket artefact --
# a sheet, a placed view, a printed call-out, a hatch pattern, a BOM table,
# a bed-plan note -- does not run here; it is listed, with its reason, in
# the report's own "Not checked at this stage" section rather than left
# silent. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` /
# `REINFORCING_EXCLUDED` for the check-by-check classification, and
# `lib/vibe/ticketqc_run.py` for the shared engine this and Ticket QC both
# call.
#
# Read-only. Nothing here opens a transaction.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinf QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='reinforcing')
@@ -0,0 +1,27 @@
title: Ticket QC
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses is visible without
# a Revit restart. It trades press latency (engine construction instead of
# engine reuse) for restart-freedom, and it is the hottest button here --
# 51 commits to its six modules in the seven days to 2026-08-30.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. One line, no `\n`: a pulldown row renders
# as text beside a 16px icon and the dropdown grows to fit the longest row,
# so the title is free to say what it means -- unlike a large button, which
# is as wide as its widest line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
#
# Check shop tickets against their design spec.
#
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
# then compares every piece of the chosen design numbers against them.
#
# Every CHECK here is pure arithmetic - the same model always gives the same
# answer, and every row of the checklist table is computed. One thing in the
# report is not: with the run-time option `vision_escalation` on, the checks
# code cannot settle (5.5) are escalated to a small model reading one rendered
# view. That result is quarantined - its own list, its own codes, its own
# section, and it can never set a checklist row. See vibe.ticketvision and
# docs/ticket-checklist.md section 8bis.
#
# Read-only. Nothing here opens a transaction, including the image export.
#
# THE WHOLE BUTTON MOVED into `lib/vibe/ticketqc_run.py` on 2026-09-05, so
# the new Reinforcing QC button could reuse it instead of these 765 lines
# being copy-pasted -- see that module's own header for exactly what is
# shared and what differs. This script is `mode='ticket'`, which is BYTE
# FOR BYTE what used to live here: nothing about Ticket QC's own behaviour
# changed in the move.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Ticket QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='ticket')
@@ -0,0 +1,27 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: Modeling
# THE MODELLING-STAGE TOOLS, gathered under one arrow (2026-09-05, on the
# owner's own instruction: "加一个 Modeling pulldown,把 Reinforcing 和
# TicketQC 放进去"). ReinforceSuggest and TicketQC moved here from being two
# of the four large buttons this panel used to show side by side; the fourth
# tool that lived here, Panel Lift Check, moved the other way -- onto
# Design.panel/DesignHub.pulldown, see that bundle's own comment. Snapshot
# stays put as the one remaining large icon.
#
# ReinforcingQC is new: a cut-down TicketQC for a panel whose reinforcing is
# finished but whose ticketing has not started yet -- see
# lib/vibe/ticketqc_run.py and lib/vibe/ticketchecks.REINFORCING_CHECKS for
# what it does and does not judge at that stage. It sits beside TicketQC
# because it shares the engine and reads the same evidence, one stage
# earlier.
#
# Order is the natural order of the work: suggest the reinforcing, check the
# reinforcing once it is modelled, check the ticket once it is drawn.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ReinforceSuggest
- ReinforcingQC
- TicketQC
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,11 @@
title: Snapshot
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Two lines, short ones. A large ribbon button is as wide as its widest line
# of text, so a long title makes one button dwarf its neighbours -- which is
# exactly what the owner objected to. Keep every line under ~9 characters.
#
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
# four large buttons on this panel all come out the same width.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,889 @@
# -*- coding: utf-8 -*-
# Index the model as it stands, then hand the sandbox off -- or close Revit.
#
# WHAT THIS BUTTON IS NOW, 2026-09-01
# ------------------------------------
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
#
# So the whole cycle moved out of this button and into
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
# it, and can be started by Claude. What is left here is the one job that
# genuinely requires the live session and cannot be done anywhere else:
# handing over the model's IDENTITY. Only the open document can cheaply report
# its cloud region and GUIDs, and without those nothing else can find the
# model to copy.
#
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
# ----------------------------------------------
# The design this replaced expected an in-session background open --
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
# does not exist for this model, and the reason is not a version guard:
#
# * a cloud model cannot be opened detached at all. Any
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
# is not valid for cloud model"; Autodesk's answer is that the Revit team
# does not allow it and that SaveAs is the way; and the UI agrees --
# "Detach from Central" is greyed out for anything from Autodesk Docs.
# * Revit holds one Document per model per session, so the live session
# cannot open the model it already has open.
# * the only call that could copy the document already in memory is
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
# here it would leave HIS Revit sitting in a local copy, disconnected
# from the cloud. There is no SaveCopyAs.
#
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
# The copy is therefore made by a Revit that is not his, and this button's
# phase C is a spawn rather than an open.
#
# THE OLD RITUAL, KEPT
# ---------------------
# `snapshot.after_close` is NOT retired. The close-model path below is
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
# day, where the answer really is "close everything first". It is simply no
# longer the only way to get a copy, and it is no longer the default.
#
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
# ------------------------------------------------
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
# session and a detached sandbox ran side by side all morning. What nobody has
# measured is three, so the pipeline sequences to keep it to two and this
# button never opens a model at all.
#
# The close-model ritual, still here as one of the options:
#
# he syncs his act, outside this button. This button NEVER
# calls SynchronizeWithCentral and never asks about it.
# phase A this button indexes the OPEN document. The model is
# already in RAM and already paid for; a snapshot read
# from the live document at sync state and one read
# from a detached copy of that same state contain the
# same numbers. Seconds, not minutes, and no copy.
# phase B Revit closes. The press IS the authorisation for the
# close -- that is what the button is called.
# phase C AFTER Revit has exited and the 10 GB is free, a
# deferred host process opens the model detached, saves
# a timestamped copy and quits. Optional, off unless
# the job layer configures it, and its outcome lands in
# a log this button reads out on the next press.
#
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
# --------------------------------------------------------------------
# No Transaction is opened in this file or in anything it calls. Nothing is
# written to the model -- not a parameter, not a workset, not a save. The only
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
# file, in another process, after this one has exited. The sync that used to
# live here has been removed entirely at the owner's instruction.
#
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
# ----------------------------------------------------
# Revit is closed LAST and only after phase A has reported complete. A crash
# halfway through the index must leave the session open, with the traceback on
# screen, because the alternative is a closed Revit and no way to find out
# what went wrong. `PostCommand` is therefore reached on exactly one path.
#
# The other half of that discipline is phase C. It runs when nobody is
# watching, in a process this one has already outlived, and a day it silently
# did not run looks exactly like a day it worked. So the request is journalled
# BEFORE the close and the deferred process journals its own outcome; a
# request with nothing after it is reported, in capitals, at the top of the
# next press. Silence is not an acceptable report.
#
# JUDGEMENT CALLS, for the owner to overrule
# -------------------------------------------
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
# press elsewhere trusts the cheap signature, which cannot see an opening
# moved or resized in place. This is the daily archival snapshot and it is
# the one that gets trusted months later, so it pays the measured 6-10 s
# for an answer that owes nothing to yesterday's cache.
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
# hang on a mis-click, and "snapshot but stay open" is a real want (a
# lunchtime checkpoint). Cancel writes nothing at all.
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
# declared scope and no parameter-role mapping, so the index would measure
# the whole model and mark every piece could-not-judge on the review
# signal -- and would overwrite a good index to do it. That is worth a
# dialog. It is not worth a refusal on a machine that legitimately has no
# job layer yet.
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
# default and None means not configured, never "pick somewhere". The button
# says so instead of guessing a folder for a 300 MB file.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Snapshot'
import os
import time
import traceback
from pyrevit import revit, script, forms
from vibe import knowledge
from vibe import liftexport
from vibe import reinforcecore as core
from vibe import reinforceindex as index
from vibe import runlog
from vibe import snapshotmeta as S
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Snapshot & Close'
# Where this button sits, and therefore where the repo is. Hoisted to the top
# because three separate places need it now (the deferred tool, the cycle
# pipeline and the runner) and computing it three times is how two of them end
# up one directory apart.
HERE = os.path.dirname(os.path.abspath(__file__))
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
REPO_ROOT = os.path.dirname(EXT_ROOT)
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
CYCLE = 'Snapshot, then run the sandbox cycle'
CLOSE = 'Snapshot, then close Revit'
STAY = 'Snapshot only, stay open'
CANCEL = 'Cancel'
def die(message):
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='refused', report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
if doc is None:
# `doc.Title` would itself fail on a None document -- record what there is
# to record, then hand the refusal to the dialog.
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
report_text='No model is open, so there is nothing to '
'snapshot.')
die('No model is open, so there is nothing to snapshot.')
# --------------------------------------------------------------- versions
def _revit_version():
try:
app = doc.Application
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
except Exception:
return None
def _pyrevit_version():
try:
from pyrevit import versionmgr
return versionmgr.get_pyrevit_version().get_formatted()
except Exception:
return None
def _revit_pid():
try:
from System.Diagnostics import Process
return Process.GetCurrentProcess().Id
except Exception:
return None
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------ what already happened
#
# Read and printed FIRST, before anything is decided, because the one thing
# this design can lose is a phase C that never ran -- and the only moment
# anybody is looking is the moment they press this button again.
know = knowledge.load(doc)
job_folder = None
try:
from vibe import designspec
job_folder = designspec.root_dir(doc)
except Exception:
job_folder = None
log = S.log_path(job_folder) if job_folder else None
history = S.read(log) if log else []
output.print_md('# {0}'.format(TITLE))
for line in S.summary(history):
if line.startswith('WARNING'):
output.print_md('**{0}**'.format(line))
else:
output.print_md('- {0}'.format(line))
# ---------------------------------------------------- is this job set up
problems = knowledge.config_problems(doc, know)
if problems:
die('\n\n'.join(problems))
has_job_layer = False
for label, path, exists in knowledge.sources(doc):
if label == 'job' and exists:
has_job_layer = True
if not has_job_layer:
keep_going = forms.alert(
'This model has no job knowledge file.\n\n'
'Without it there is no declared scope and no parameter-name '
'mapping, so the index will measure every assembly in the model and '
'will record "could not judge" for the review signal on all of them '
'-- and it will overwrite whatever index is there now.\n\n'
'Snapshot anyway?',
title=TITLE, yes=True, no=True)
if not keep_going:
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='No job knowledge file; user declined to '
'snapshot anyway.')
script.exit()
# --------------------------------------------------------- phase C settings
#
# Resolved BEFORE the confirmation so the dialog can say what will actually
# happen after the close, rather than promising a detached copy that turns out
# not to be configured.
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
after_close = knowledge.get(know, 'snapshot.after_close', False)
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
# TEXT, so two call sites spelling the same value differently are reported as
# disagreeing about what "unset" means.
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
or S.revit_exe_guess(revit_year)
if not revit_year:
# `pyrevit run` (both the cycle route's detach step and the close-model
# route) takes an optional `--revit=<year>`, and a job layer that states
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
# job's shape -- used to leave it off entirely. On a machine with more
# than one Revit version attached that is not a neutral omission: see
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
# is named for. `revit_exe`'s own path almost always carries the year.
revit_year = S.revit_year_from_exe(revit_exe)
batch = knowledge.get(know, 'snapshot.batch', []) or []
def _pyrevit_cli():
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
if pyrevit_exe and os.path.isfile(pyrevit_exe):
return pyrevit_exe
if pyrevit_exe:
return None # stated and wrong: never fall back
return S.pyrevit_exe_guess()
def _copy_blocker():
"""What stops ANY detached copy being made, whichever route. Or None."""
if not detached_dir:
return ('not configured -- set snapshot.detached_dir in the job '
'knowledge file to switch it on')
if not os.path.isdir(detached_dir):
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
if _pyrevit_cli() is None:
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
if liftexport.find_python(know) is None:
return 'no host Python was found to run the deferred step'
return None
def phase_c_blocker():
"""Why the OLD after-close route cannot run, or None.
`snapshot.after_close` is deliberately still consulted here and nowhere
else: it gates the close-model ritual, which is the thing it has always
meant. The cycle route below does not read it -- it is not chained to an
exit, so "after close" has nothing to say about it, and making one key
mean two things is how a setting becomes untrustworthy.
"""
stop = _copy_blocker()
if stop:
return stop
if not after_close:
return ('switched off -- snapshot.after_close is false in the job '
'knowledge file')
return None
def cycle_blocker():
"""Why the sandbox cycle cannot be started, or None.
Note what is NOT in here: `after_close`, and any check on other Revits.
The cycle runs beside this session on purpose -- that is the whole point
of it -- and it sequences its own steps so that at most two Revits are
ever resident.
"""
stop = _copy_blocker()
if stop:
return stop
if not revit_exe:
return ('no Revit.exe to open the sandbox with -- set '
'snapshot.revit_exe or snapshot.revit_year')
if not os.path.isfile(revit_exe):
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
if not batch:
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
if not os.path.isfile(CYCLE_TOOL):
return 'tools/snapshot_cycle.py is not where this button expects it'
return None
blocker = phase_c_blocker()
cycle_stop = cycle_blocker()
# -------------------------------------------------------------- the decision
if cycle_stop is None:
cycle_tail = (
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
'own, then opened in a sandbox session, then {1} runs inside it, then '
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
.format(detached_dir, ' + '.join(batch)))
else:
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
if blocker is None:
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
'copy is written to:\n{0}'.format(detached_dir))
else:
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
choice = forms.alert(
'Sync first if you have not -- this button does not sync, and it never '
'touches the model.\n\n'
'It rebuilds the reinforcing index from the model as it stands now and '
'stamps it with where it came from.' + cycle_tail + close_tail,
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
if choice not in (CYCLE, CLOSE, STAY):
output.print_md('---')
output.print_md('Cancelled. Nothing was read, written or closed.')
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='User cancelled the snapshot dialog.')
script.exit()
closing = (choice == CLOSE)
cycling = (choice == CYCLE)
# ------------------------------------------------------------------ phase A
output.print_md('---')
output.print_md('## Phase A - indexing the open model')
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
'an earlier press. Measured at 6-10 s on this model.')
# The index AS IT STOOD BEFORE this press, captured so the update statistics
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
# only loads the file, it never touches the model. Absent or damaged is fine:
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
try:
old_cache = index.read(doc)
except Exception:
old_cache = None
report = None
cache = None
failure = None
try:
# An upper bound for the bar: a full rebuild measures at most every
# assembly in the model. The cheap collector pass costs ~0.3 s here and
# buys a progress bar that moves, instead of one pinned at 100%.
total = max(len(index.assemblies(doc)), 1)
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
cancellable=False) as bar:
state = {'n': 0}
def tick(_name):
state['n'] += 1
bar.update_progress(state['n'], total)
bar.update_progress(0, total)
cache, report = index.refresh(doc, rebuild_all=True, know=know,
on_piece=tick)
except Exception:
failure = traceback.format_exc()
if failure is not None:
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
output.print_md('## Phase A FAILED - Revit has not been closed')
output.print_md('```\n{0}\n```'.format(failure))
if log:
S.append(log, {'event': 'phase-a-failed',
'error': failure.splitlines()[-1]})
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='Phase A failed - Revit has not been closed.',
error=failure.splitlines()[-1])
script.exit()
# ------------------------------------------------- stamp it with where it came from
#
# `refresh` writes the index; this adds the block that says whose numbers
# those are, and rewrites. It is written on EVERY press, including the press
# where nothing changed and `refresh` therefore did not save -- an index whose
# provenance is a week older than its numbers is worse than none.
source_path = None
try:
source_path = doc.PathName or None
except Exception:
source_path = None
prov = S.provenance(
'A', S.LIVE_DOC,
source_path=source_path,
source_stamp=None, # a live document has no snapshot stamp
source_title=index.doc_key(doc),
revit=_revit_version(),
pyrevit=_pyrevit_version(),
job_folder=job_folder,
note=('read from the open document immediately after the engineer synced; '
'no detached copy was involved in these numbers'))
pieces = len(cache.get('pieces') or {})
try:
cache['provenance'] = prov
core.save(report['path'], cache)
stamped = True
except Exception:
stamped = False
output.print_md('- {0} assemblies seen, {1} pieces in the index'
.format(report['assemblies'], pieces))
output.print_md('- scope: {0}'.format(report['scope']))
output.print_md('- built in {0} s, written to `{1}`'
.format(report['seconds_total'], report['path']))
if stamped:
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
else:
output.print_md('- **the provenance stamp could not be written** -- this '
'index cannot say where it came from')
missing = (report.get('coverage') or {}).get('missing') or []
if missing:
output.print_md('- **{0} pieces could not be measured** and are not in '
'this snapshot'.format(len(missing)))
if log:
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
'path': report['path'], 'scope': report['scope'],
'seconds': report['seconds_total'],
'provenance': prov})
# ------------------------------------------------- update statistics
#
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
# press changed: which pieces are newly donors, how many features each carries,
# and which feature-IDs went into the shared vocabulary for the first time
# versus which were already known. READ ONLY -- this only reads the two caches
# and one cheap signature pass; nothing about the model is written.
#
# `index.refresh` already grew the registry (via `core.register_features`), so
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
# runs, redone here so the diff sees the model exactly as it stands now.
output.print_md('## Update statistics -- what this snapshot marked')
try:
live = index.live_signatures(doc, know=know)
stats = core.snapshot_stats(old_cache, cache, live)
except Exception:
stats = None
output.print_md('- **the update statistics could not be computed** -- the '
'index was still written; only this summary is missing')
if stats is not None:
newly = stats['newly']
if stats['first_snapshot']:
output.print_md('- **first snapshot for this index** -- there was '
'nothing to diff against, so every reinforced piece '
'below is new to the donor pool, not a change since '
'last press.')
if not newly:
output.print_md('- no piece was newly marked reinforced since the last '
'snapshot. (This is "nothing changed", not "nothing '
'is reinforced".)')
else:
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
'{1}'.format(len(newly), ', '.join(newly)))
output.print_md('- feature-IDs new to the vocabulary this press: '
'**{0}**; already known: **{1}**'
.format(len(stats['new_ids']),
len(stats['known_ids'])))
for panel in stats['panels']:
if panel['feature_count'] is None:
output.print_md(' - **{0}** -- could not be measured; no '
'features recorded'.format(panel['name']))
continue
bits = ['{0} feature(s)'.format(panel['feature_count'])]
if panel['new_ids']:
bits.append('new IDs {0}'.format(
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
if panel['known_ids']:
bits.append('known IDs {0}'.format(
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
if panel['unregistered']:
bits.append('{0} unregistered'.format(panel['unregistered']))
output.print_md(' - **{0}** -- {1}'.format(
panel['name'], '; '.join(bits)))
if log:
# Journal the same figures. Per-panel id lists are kept so the ledger
# can be replayed without the model; `first_snapshot` distinguishes a
# cold index from a genuine no-change press.
S.append(log, {'event': 'phase-a-update-stats',
'first_snapshot': stats['first_snapshot'],
'newly': newly,
'new_ids': stats['new_ids'],
'known_ids': stats['known_ids'],
'panels': stats['panels']})
# ---------------------------------------------- handing the model over
#
# Both routes below need the same two things: WHERE the copy goes (a plan) and
# HOW to find the model again (its identity). Built once, here, so the cycle
# and the close-model ritual cannot drift into naming files differently.
def _source_block():
"""How another session is to find this model again.
Cloud GUIDs and file path both, when both are available: a cloud model is
reopened from its GUIDs, a file-based one from its path, and which one a
job is was never something to assume. A cloud model reports no usable
local path, which is the ordinary case here, not an error.
"""
src = {'path': source_path, 'title': index.doc_key(doc)}
try:
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
mp = doc.GetCloudModelPath()
src['cloud'] = {
'region': str(mp.Region),
'project_guid': str(mp.GetProjectGUID()),
'model_guid': str(mp.GetModelGUID()),
}
except Exception as exc:
# Not a cloud model, or this Revit does not expose the GUIDs. The file
# path is then the only handle and the runner says so if it is absent.
src['cloud'] = None
src['cloud_error'] = str(exc)
return src
def _write_request(run_id, plan, source, extra=None):
"""The handoff file. Same schema for both routes -- one runner reads it."""
request = {
'run': run_id,
'base': plan['title'],
'layout': layout,
'worksets': worksets,
'detached_dir': detached_dir,
'plan': plan,
'log': log,
'job_folder': job_folder,
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'requested_by_revit': _revit_version(),
'source': source,
}
request.update(extra or {})
req_dir = os.path.join(detached_dir, '_requests')
req_path = os.path.join(req_dir, run_id + '.json')
if not os.path.isdir(req_dir):
os.makedirs(req_dir)
import json
handle = open(req_path, 'wb')
try:
handle.write(json.dumps(request, indent=2).encode('utf-8'))
finally:
handle.close()
# And the identity on its own, where the host pipeline looks for it.
# A cloud model is found by its region and two GUIDs, and NOTHING on this
# machine knows those except a session with the model open -- this one.
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
# later with no Revit anywhere and still find the model to copy. It is
# identity, never state: it says which model, never anything about a copy.
try:
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
try:
cache.write(json.dumps(source, indent=2,
sort_keys=True).encode('utf-8'))
finally:
cache.close()
except Exception:
pass # a lost cache costs a flag, not a snapshot
return req_path
def _spawn(argv):
"""Start a host process that outlives this one. Returns its pid.
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
minutes with nobody watching, and a console window popping up over the
engineer's Revit is the kind of thing that gets a tool switched off.
"""
from System.Diagnostics import Process, ProcessStartInfo
psi = ProcessStartInfo()
psi.FileName = argv[0]
psi.Arguments = S.quote(argv[1:])
psi.UseShellExecute = False
psi.CreateNoWindow = True
psi.WorkingDirectory = REPO_ROOT
return Process.Start(psi).Id
def _job_knowledge_path():
"""The job knowledge file, so the host pipeline reads the same layer.
Handed over as a path rather than as resolved values: the pipeline needs
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
block, and a button that copies six settings across is a button that
forgets the seventh the day one is added.
"""
try:
for label, path, exists in knowledge.sources(doc):
if label == 'job' and exists:
return path
except Exception:
pass
return None
# ------------------------------------------------------- the sandbox cycle
#
# The new route, and the one the commission is about. Nothing here opens,
# saves or closes a model: it writes the handoff and starts a host process
# that does all of it in Revits that are not this one.
run_id = None
if cycling and cycle_stop is None:
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
base = index.doc_key(doc) or 'untitled'
plan = S.plan(detached_dir, base, layout=layout)
started_pid = None
argv = None
try:
req_path = _write_request(run_id, plan, _source_block(),
{'requested_by': 'SnapshotClose button, '
'cycle route'})
argv = [liftexport.find_python(know), CYCLE_TOOL,
'--fresh-detach',
'--request', req_path,
'--detached-dir', detached_dir,
'--pyrevit', _pyrevit_cli(),
'--revit-exe', revit_exe]
if revit_year:
argv.extend(['--revit', str(revit_year)])
if log:
argv.extend(['--log', log])
job_knowledge = _job_knowledge_path()
if job_knowledge:
argv.extend(['--knowledge', job_knowledge])
for command in batch:
argv.extend(['--run', command])
started_pid = _spawn(argv)
except Exception:
started_pid = None
cycle_stop = 'the pipeline could not be started: {0}'.format(
traceback.format_exc().splitlines()[-1])
output.print_md('---')
output.print_md('## The sandbox cycle - running now, beside this session')
if started_pid:
output.print_md(
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
'-- it is not closed, not saved, and not read again.'
.format(run_id, started_pid))
output.print_md('It writes:')
output.print_md('`{0}`'.format(plan['path']))
if plan.get('warning'):
output.print_md('**{0}**'.format(plan['warning']))
output.print_md('then opens that copy in a Revit of its own, runs '
'`{0}`, and closes it again. Its log lands beside the '
'copy and its outcome in `{1}`.'
.format(' + '.join(batch), log))
output.print_md('The command it is running:')
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
if log:
S.append(log, {'event': S.REQUESTED, 'run': run_id,
'path': plan['path'], 'request': req_path,
'route': 'cycle', 'host_pid': started_pid})
else:
output.print_md('**Not started: {0}**'.format(cycle_stop))
if log:
S.append(log, {'event': S.SKIPPED, 'run': run_id,
'route': 'cycle', 'reason': cycle_stop})
elif cycling:
output.print_md('---')
output.print_md('## The sandbox cycle - not run')
output.print_md('Not started: {0}'.format(cycle_stop))
if log:
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
'reason': cycle_stop})
# ------------------------------------------------------------------ phase C
#
# The OLD route, kept: requested here, run later, by a process that outlives
# this one. Everything it needs goes into a request file -- `pyrevit run`
# passes no arguments to the script it runs, so a file named by an environment
# variable is the only handoff there is.
#
# This is the only place `snapshot.after_close` still means anything.
if closing and blocker is None:
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
base = index.doc_key(doc) or 'untitled'
plan = S.plan(detached_dir, base, layout=layout)
launched = None
try:
req_path = _write_request(run_id, plan, _source_block())
argv = [liftexport.find_python(know), DEFER_TOOL,
'--request', req_path,
'--runner', RUNNER,
'--pyrevit', _pyrevit_cli(),
'--wait-pid', str(_revit_pid()),
'--wait-minutes', str(wait_minutes),
# The close-model ritual is the one case where waiting for the
# whole machine to be free is still the right answer: nobody
# is at the keyboard and nothing else is queued behind it.
'--machine-free', 'require']
if revit_year:
argv.extend(['--revit', str(revit_year)])
_spawn(argv)
launched = argv
except Exception:
launched = None
blocker = 'the deferred process could not be started: {0}'.format(
traceback.format_exc().splitlines()[-1])
output.print_md('---')
output.print_md('## Phase C - the detached copy, after Revit exits')
if launched:
output.print_md('Requested as `{0}`. It waits for this Revit '
'(pid {1}) to exit -- and then for the machine to be '
'free of Revit altogether -- then opens the model in a '
'session of its own and writes:'
.format(run_id, _revit_pid()))
output.print_md('`{0}`'.format(plan['path']))
if plan.get('warning'):
output.print_md('**{0}**'.format(plan['warning']))
output.print_md('The command it will run:')
output.print_md('```\n{0}\n```'.format(S.quote(
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
output.print_md('Its outcome is journalled to `{0}` and read out the '
'next time this button is pressed.'.format(log))
if log:
S.append(log, {'event': S.REQUESTED, 'run': run_id,
'path': plan['path'], 'request': req_path,
'route': 'after-close', 'wait_pid': _revit_pid()})
else:
output.print_md('**Not requested: {0}**'.format(blocker))
if log:
S.append(log, {'event': S.SKIPPED, 'run': run_id,
'route': 'after-close', 'reason': blocker})
elif closing:
output.print_md('---')
output.print_md('## Phase C - not run')
output.print_md('No detached copy: {0}'.format(blocker))
if log:
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
'reason': blocker})
# ------------------------------------------------------------------ phase B
output.print_md('---')
if not closing:
if cycling:
output.print_md('Revit stays open. The index is written and stamped, '
'and the cycle is running in processes of its own -- '
'nothing here was closed and your model was never '
'touched.')
else:
output.print_md('Revit stays open, as asked. The index is written and '
'stamped; nothing was closed.')
# A normal completion for the STAY / cycle choices: Revit stays open,
# the snapshot index is on disk. The CLOSE path below records its own
# completion (Revit is about to exit, so this is the last write).
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Revit stays open. Index written: {0}'
.format(report['path'] if report else '?')))
script.exit()
output.print_md('## Phase B - closing Revit')
output.print_md('Revit will ask you about unsaved changes if it has any. '
'That question is yours to answer -- this button never syncs '
'and never saves the model.')
posted = False
# There is no PostableCommand for quitting, so the internal command id is
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
# helper is a version-dependent convenience and this is the one step of the
# button that cannot be retried by pressing it again.
try:
from pyrevit import HOST_APP
HOST_APP.post_command('ID_APP_EXIT')
posted = True
except Exception:
try:
from pyrevit import HOST_APP
from Autodesk.Revit.UI import RevitCommandId
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
if cmd is not None:
HOST_APP.uiapp.PostCommand(cmd)
posted = True
except Exception:
posted = False
if not posted:
output.print_md('**Revit would not accept the close command.** The '
'snapshot is written and stamped; close Revit yourself.')
# The CLOSE path's completion. If the close command was accepted Revit is
# about to exit, so this record is the button's last write; if not, the
# run still produced its snapshot and says so.
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Close posted: {0}. Index written: {1}'
.format(posted,
report['path'] if report else '?')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
# `title:` here -- `uimaker._produce_ui_panels` calls
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
#
# REORGANISED 2026-09-05, on the owner's own instruction: "把 ribbon 重新
# organize 一下,加一个 Modeling pulldown,把 Reinforcing 和 TicketQC 放进去".
# This panel used to hold four large icons side by side (reinforce, LIFT,
# ticketqc, snapshot -- see the 2026-08-30 note this comment replaces). Two
# of those four moved into a new `Modeling.pulldown` (ReinforceSuggest and
# TicketQC, joined there by the new ReinforcingQC), and the third,
# PanelLiftCheck, moved the other way onto Design.panel/DesignHub.pulldown --
# see that bundle's own comment for why. SnapshotClose is the one large icon
# still on this panel: it never belonged to the modelling/ticketing group,
# it closes and archives a session, so it keeps its own button rather than
# joining a pulldown about a different kind of work.
#
# What is left is one pulldown and one large button, side by side. Nothing
# half-built may join them -- that is what Setup and Design are for.
#
# Every title here keeps each line under ~9 characters. A large ribbon button
# is as wide as its widest line of text, so one long title makes one button
# dwarf its neighbours, which is exactly what the owner objected to when
# these four were spread across three panels.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- Modeling
- SnapshotClose
@@ -0,0 +1,20 @@
# Panel order on the tab, left to right.
#
# Without this key pyRevit falls back to whatever order the filesystem hands
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
# looks at a `title:` here, so do not add one expecting it to render.
#
# Four panels, and the split between them is what the owner asked for on
# 2026-08-30: the four finished tools stand together as large icons, the
# set-up commands collapse behind one arrow, the unbuilt design work
# collapses behind another, and GOD_MOD keeps its own corner.
#
# A panel not listed below is not created at all
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
layout:
- Tools
- Setup
- Design
- Lab
+8
View File
@@ -0,0 +1,8 @@
{
"name": "WillTools",
"description": "Precast shop-ticket QC + live vibe-coding bridge",
"author": "will",
"version": "1.0.0",
"rocket_mode_compatible": true,
"default_enabled": true
}
+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',
]
+316
View File
@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""Launch the second, AI extraction pass over a scraped design summary.
OWNER'S RULING, 2026-09-01 (Discord), approving the two-stage-extraction
proposal: "可以,不过别用opus,用dsv4f带vision吧" -- yes, but not opus, use
DSV4F with vision. That REPLACES the opus-via-Claude-Code-CLI path this
module used to launch. `source.extractor` is now stamped 'dsv4f-vision' by
the new pass (see `tools/aiextract_run.py`, which does the stamping -- this
module only launches it); specs written under the earlier rulings still say
'opus' or, before the model was named at all, 'claude-code'. Nothing reads
that field to make a decision (see `ExtractDesign/script.py` and
`specformat._SOURCE_NOTE`), so a reader only ever has to tolerate whichever
of the four values it finds, never branch on it.
WHERE THE OLD OPUS-CLI PATH WENT, AND WHY IT WAS DELETED RATHER THAN KEPT
BEHIND A FLAG
--------------------------------------------------------------------------
The old `PROMPT` / `build_prompt()` / `launch()` here spawned the Claude
Code CLI (`claude -p --model opus`) with a prompt written against an OLDER
spec shape -- it still asked for `spacings_in` / `positions_in` /
`strands_per_row` under `reinforcing.strand_layout`, three fields that
stopped existing the day the spec grew its `columns_in` / `rows` matrix
shape (commit `73fdd9a`, "spec 改成人类可读 YAML + 矩阵 + 去掉 AI") -- because
once the mechanical reader stopped calling this module, nothing ever ran
that prompt again to notice it had drifted. A dead code path that LOOKS
live is worse than an absent one: keeping it "behind a flag" would mean
shipping a second, silently-wrong extraction contract that only gets
exercised the day somebody flips the flag back on, which is exactly the
kind of quiet failure this codebase's other rules (see CLAUDE.md's
"'Could not judge' and 'judged, found nothing' are different answers")
exist to prevent. It is deleted outright. `find_claude()` (below) is
UNRELATED to that decision and stays: Self Check and the GOD_MOD button
both still use it to report whether the Claude Code CLI is installed on
this machine, independent of anything this module extracts.
The AI pass runs as a separate detached CPython process, same as the old
one did. That is still deliberate, for the same two reasons:
* a pushbutton script runs on Revit's UI thread, and a model call taking
a couple of minutes would freeze Revit for a couple of minutes;
* the extraction touches no Revit API at all -- it reads JSON and PNG
files and writes a spec file -- so there is no reason for it to run
inside Revit. `tools/aiextract_run.py` is plain CPython (dsh is talked
to over local HTTP, and IronPython 2.7 has no HTTP client worth
trusting with that).
THE NEVER-OVERWRITE CONTRACT lives in `aiextract_run.apply_ai_fill`, not
here: this module's whole job is gathering paths and starting that process.
See that module's docstring for the merge rules and for the DSV4F pricing
note (off-peak is the owner's own daytime use of the harness; a button
press is inherently daytime, so this never competes with that window).
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
from vibe import designspec
# The CLI is installed per-user and lands in a different place depending on how
# it was installed, so it is looked up rather than assumed. Set the
# CLAUDE_CODE_EXE environment variable to override on an unusual machine.
#
# UNRELATED TO THE AI EXTRACTION PASS below -- see the module docstring.
# `find_claude()` only ever answers "is the Claude Code CLI installed here",
# for Self Check and GOD_MOD.
_CLAUDE_NAMES = ('claude.exe', 'claude.cmd', 'claude.bat', 'claude')
_CLAUDE_DIRS = (
os.path.join(os.path.expanduser('~'), '.local', 'bin'),
os.path.join(os.environ.get('APPDATA', ''), 'npm'),
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'claude'),
os.path.join(os.path.expanduser('~'), 'AppData', 'Roaming', 'npm'),
)
def find_claude():
"""Absolute path to the Claude Code CLI, or None when it is not installed."""
override = os.environ.get('CLAUDE_CODE_EXE')
if override and os.path.isfile(override):
return override
dirs = list(_CLAUDE_DIRS) + os.environ.get('PATH', '').split(os.pathsep)
for folder in dirs:
if not folder:
continue
for name in _CLAUDE_NAMES:
candidate = os.path.join(folder, name)
if os.path.isfile(candidate):
return candidate
return None
def _datum_clause(know):
"""Tell the extractor which rail the strand chain is measured from.
Plant knowledge, not a guess: reading the chain from the wrong end mirrors
the whole pattern, and nothing downstream would look wrong.
"""
from vibe import knowledge as K
datum = K.get(know, 'rails.strand_datum', 'left_rail')
fixed = K.get(know, 'rails.left_rail_is_fixed', None)
where = {
'left_rail': 'the LEFT rail -- the left-hand edge where the concrete '
'starts. The first segment of the chain is that edge to '
'the first strand.',
'right_rail': 'the RIGHT rail -- the right-hand edge where the '
'concrete starts, reading right to left.',
'centerline': 'the CENTERLINE of the piece.',
}.get(datum, datum)
if fixed is True:
rail = ' That rail is the bed\'s fixed rail.'
elif fixed is False:
rail = (' That rail is NOT the bed\'s fixed rail on this job, so do '
'not assume dimensions accumulate away from it.')
else:
rail = (' Nobody has recorded whether that rail is the bed\'s fixed '
'rail, so do not reason about which edge tolerance '
'accumulates from.')
return '{0} (datum = "{1}").{2}'.format(where, datum, rail)
def prepare(doc, design, know=None):
"""Scrape the summary and write the evidence file. Returns its path.
know carries the plant's nomenclature, which decides which sheets make up
this design's packet, and its rails, which decide how the strand chain
reads.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
evidence = designspec.gather_evidence(doc, design, know=know)
path = designspec.evidence_path(doc, design)
designspec.write_json(path, evidence)
return path, evidence
# dsh's local HTTP RPC. Overridable per-machine/testing, same idiom as
# WILLTOOLS_JOBS and CLAUDE_CODE_EXE elsewhere in this file.
DSH_URL_ENV = 'WILLTOOLS_DSH_URL'
DEFAULT_DSH_BASE_URL = 'http://127.0.0.1:3080'
# The model name and timeout are resolved on the CPython side
# (`aiextract_run.resolve_model`) so the real ~/.dsh/settings.yaml can be
# read there -- IronPython has no reason to parse that file too, and a
# second parser is a second place for the two to disagree. Set either
# override here if it ever needs to travel from inside Revit instead
# (job.plant knowledge, say); today neither does.
MODEL_ENV = 'WILLTOOLS_DSV4F_MODEL'
TIMEOUT_ENV = 'WILLTOOLS_DSV4F_TIMEOUT_S'
# Vision model on a long prompt + drawing PNG measured well over 180s to
# produce even a first assistant message on 2026-09-03 -- the old 180s
# default cancelled a session that was still working, never one that had
# failed. Keep in lockstep with the identically-named constant in
# tools/aiextract_run.py.
DEFAULT_TIMEOUT_S = 900
def _repo_root():
"""The checkout root, from this file's own path.
Same idiom as `liftexport.panellift_src()`: three `dirname()` calls off
this file strip vibe/lib/WillTools.extension, landing on the folder that
holds both `WillTools.extension` and `tools`.
"""
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))) # WillTools.extension
return os.path.dirname(here)
def _job_dict(doc, design, know, image_paths):
"""The plain-data job description `tools/aiextract_run.py` reads.
Pure data assembly -- no process, no file write -- so it can be
inspected on its own. `launch()` is the only caller that also writes it
to disk and starts the process.
"""
return {
'design_number': design,
'evidence_path': designspec.evidence_path(doc, design),
'spec_path': designspec.spec_path(doc, design, 'yaml'),
'image_paths': list(image_paths or []),
'datum_clause': _datum_clause(know),
'dsh_base_url': os.environ.get(DSH_URL_ENV) or DEFAULT_DSH_BASE_URL,
'model': os.environ.get(MODEL_ENV) or None,
'timeout_s': int(os.environ.get(TIMEOUT_ENV) or DEFAULT_TIMEOUT_S),
'log_path': designspec.log_path(doc, design),
}
def launch(doc, design, work_dir=None, know=None, image_paths=None):
"""Start a detached process that runs the DSV4F-vision AI pass.
Returns the started `Process`. The window stays open (same as the old
opus-CLI path did) so the prompt, the reply and the merge outcome can be
read; the spec on disk is updated in place -- or left exactly as the
mechanical reader wrote it, with one new `unresolved` line -- regardless
of whether anybody is watching the window.
`image_paths` are PNGs of the design's summary view for the model to
read with its own vision tool (see `tools/aiextract_run.py`'s prompt) --
rendering them is Revit API work and is the caller's job (the
ExtractDesign button), not this module's: this file already has no
other reason to touch a live View.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
from vibe import liftexport
from System.Diagnostics import Process, ProcessStartInfo
python_exe = liftexport.find_python(know)
if not python_exe:
raise IOError(
'No CPython 3 found to run the AI pass with. Set '
'handling.python_exe in this job\'s knowledge file, or the '
'WILLTOOLS_PYTHON environment variable -- the same setting '
'Ticket QC\'s vision escalation uses.')
root = _repo_root()
runner_script = os.path.join(root, 'tools', 'aiextract_run.py')
job = _job_dict(doc, design, know, image_paths)
job_path = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.aijob.json')
designspec.write_json(job_path, job)
runner = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.run.cmd')
log = designspec.log_path(doc, design)
with io.open(runner, 'w', encoding='utf-8') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'set "PYTHONIOENCODING=utf-8"\r\n')
fh.write(u'title AI pass (DSV4F vision) - {0}\r\n'.format(design))
fh.write(u'cd /d "{0}"\r\n'.format(work_dir or root))
fh.write(u'echo Running the DSV4F-vision AI pass for {0} ...\r\n'
.format(design))
fh.write(u'echo.\r\n')
fh.write(u'"{0}" "{1}" --job "{2}" > "{3}" 2>&1\r\n'
.format(python_exe, runner_script, job_path, log))
fh.write(u'type "{0}"\r\n'.format(log))
fh.write(u'echo.\r\n')
fh.write(u'echo [done - spec updated in place: specs\\{0}.yaml]\r\n'
.format(design))
fh.write(u'pause >nul\r\n')
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = 'cmd.exe /c "{0}"'.format(runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.UseShellExecute = True
return Process.Start(psi)
# ------------------------------------------------------- independent check
def cross_check(spec, regex_spec):
"""Compare the extracted spec against the old regex reading of the summary.
Two readings that agree are worth more than either alone. This is not a
correctness proof -- both can be wrong the same way -- but a disagreement
is always worth a human glance before the spec is trusted.
"""
from vibe import ticketqc
rows = []
def row(label, ai, rx):
same = (ai is None and rx is None)
if not same and ai is not None and rx is not None:
try:
same = abs(float(ai) - float(rx)) < 0.5
except Exception:
same = str(ai).strip().upper() == str(rx).strip().upper()
rows.append({'field': label, 'ai': ai, 'regex': rx, 'agree': bool(same)})
row("f'c", designspec.get_path(spec, 'concrete.fc_psi'), regex_spec.get('fc'))
row("f'ci", designspec.get_path(spec, 'concrete.fci_psi'), regex_spec.get('fci'))
row('unit weight', designspec.get_path(spec, 'concrete.unit_weight_pcf'),
regex_spec.get('pcf'))
def mesh(value):
import re as _re
if not value:
return None
return _re.sub(r'\s+', '', str(value).upper()).replace('.0', '')
row('W.W.F. mesh', mesh(designspec.get_path(spec, 'reinforcing.wwf.mesh')),
mesh(regex_spec.get('wwf_mesh')))
ai_groups = designspec.get_path(spec, 'reinforcing.strand_groups', []) or []
ai_sig = ticketqc.strand_signature([
{'qty': g.get('qty'), 'dia': g.get('dia_in'),
'ksi': g.get('grade_ksi'), 'it': g.get('it_lb')} for g in ai_groups])
row('strand groups', ai_sig,
ticketqc.strand_signature(regex_spec.get('strands') or []))
envelope = regex_spec.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
rx = envelope.get('{0}_{1}'.format(bound.upper(), key))
row('{0} {1}'.format(dim, bound),
designspec.get_path(spec, 'envelope.{0}.{1}_ft'.format(dim, bound)),
rx[0] if rx else None)
return rows
+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
+423
View File
@@ -0,0 +1,423 @@
# -*- 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 ntpath
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):
"""Normalize a model path. These are always WINDOWS paths (the bridge
only ever names Revit model files, drive letters and all) even when this
code runs on a Linux dev box, so the normalization has to use `ntpath`
rather than `os.path` -- `os.path.normcase` is a no-op on POSIX and would
silently break the case-insensitivity this promises.
"""
try:
text = '{0}'.format(path or '').strip()
except Exception:
return ''
if not text:
return ''
text = text.replace('/', ntpath.sep).replace('\\', ntpath.sep)
try:
text = ntpath.normcase(ntpath.normpath(text))
except Exception:
pass
return text.rstrip(ntpath.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 + ntpath.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;')
+818
View File
@@ -0,0 +1,818 @@
# -*- 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
# The strand-matrix geometry reader. A sibling module, developed in parallel
# -- guarded, and PERMANENTLY guarded even once it always ships, because a
# machine missing the file must get an honest refusal from build_spec rather
# than an ImportError that takes the whole extraction down with it.
try:
from vibe import strandmatrix
except ImportError:
strandmatrix = None
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 = {}
symbols = []
symbols_skipped = 0
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
# Positions, for the strand-matrix reader: which symbol is drawn
# where. A FilledRegion (the usual strand marker -- see
# knowledge.DEFAULTS['design_summary']) has no .Symbol, so `key`
# above is already its class name; the same key is reused here so
# the two counts describe the same population.
try:
bbox = e.get_BoundingBox(view)
if bbox is None:
symbols_skipped += 1
continue
cx = (bbox.Min.X + bbox.Max.X) / 2.0
cy = (bbox.Min.Y + bbox.Max.Y) / 2.0
w = bbox.Max.X - bbox.Min.X
h = bbox.Max.Y - bbox.Min.Y
symbols.append({'kind': key, 'x': round(cx, 4), 'y': round(cy, 4),
'w': round(w, 4), 'h': round(h, 4)})
except Exception:
symbols_skipped += 1
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
'text_notes': notes, 'dimensions': dims, 'components': components,
'symbols': symbols, 'symbols_skipped': symbols_skipped}
# ------------------------------------------------------------ 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. The strand matrix
is read from GEOMETRY, not guessed at from text -- the drawn strand
symbols and the dimension chain that locates them -- and only when every
identity check on that reading passes: the symbol count equals the
stated strand quantity, each row's spacing matches a dimension chain, and
row depths match a vertical chain. Any one of those failing leaves
columns_in and rows empty with the reason on 'unresolved' rather than a
grid nobody can trust; a wrong grid is worse than an honest blank one,
and the AI pass and a human both fill what stays blank. datum comes from
knowledge (rails.strand_datum) because that much genuinely is known
already, independent of whether the geometry read succeeds.
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
# ---- the strand matrix, read from geometry -------------------------
#
# `strandmatrix.read_layout` is the identity-checked reader: it counts
# the drawn strand symbols against the stated quantity, matches each
# row's spacing to a dimension chain and each row's depth to a vertical
# chain, and refuses -- one sentence saying which identity failed --
# rather than fill in a grid that only half matches. `symbol_kind` is
# plant knowledge (design_summary.strand_symbol) because the drafting
# standard's choice of marker is not universal: a filled region on this
# default, a detail-component family name on another plant. Computed
# here, applied below once `spec['unresolved']` exists to carry a
# refusal reason -- never raised out of this function, because a bad
# geometry read must lose only the matrix, not the whole spec.
matrix_layout, matrix_why = None, 'strandmatrix module not installed'
if strandmatrix is not None:
try:
qty = sum([g.get('qty') for g in spec['reinforcing']['strand_groups']
if g.get('qty') is not None])
view_ev = (evidence.get('summary_views') or [None])[0]
symbol_kind = K.get(know, 'design_summary.strand_symbol', 'FilledRegion')
width_ft = spec['envelope']['width']['max_ft']
matrix_layout, matrix_why = strandmatrix.read_layout(
view_ev, qty, thickness_in=spec.get('thickness_in'),
width_ft=width_ft, symbol_kind=symbol_kind)
except Exception as ex:
matrix_layout = None
matrix_why = 'strand matrix read failed: {0}: {1}'.format(
type(ex).__name__, ex)
if matrix_layout:
layout = spec['reinforcing']['strand_layout']
layout['columns_in'] = matrix_layout.get('columns_in')
layout['rows'] = matrix_layout.get('rows')
layout['total_in'] = matrix_layout.get('total_in')
layout['note'] = matrix_layout.get('note')
# 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
# Appended AFTER extractrules.apply_to so the rule packs never see this
# line -- it is not a note off the drawing, it is this function's own
# refusal, and folding it into `entries` above would let a house-style
# rule pack move or reinterpret it like ordinary drawing text.
if not matrix_layout:
spec['unresolved'].append({'text': 'STRAND PATTERN (matrix)',
'reason': matrix_why})
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
+944
View File
@@ -0,0 +1,944 @@
# -*- 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`, `lc_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 used `table` for exactly that reason, back when the
only editable thing per row was the Include tick. They no longer do: the owner
ruled (2026-09-03) that the ACI factors themselves are editable and that a user
can add a combination of their own, so they emit `lc_table` -- the same grid,
but every factor cell carries a live numeric editor and the block names its
factor columns so the renderer can build an added row. The DEFAULT factors are
public ACI 318 code text (not this job's data) and now live in
`panelanalysisform.LOAD_COMBO_ROWS`, beside the keys they are stored under,
rather than in this file.
UPDATE 2026-09-04, RESISTANCE FACTORS IS THE ONE EXCEPTION TO ALL OF THAT. A
headless render of this page (WPA655) showed every phi editor squeezed to zero
width: that block was a `columns` of two sub-columns ("Developed" |
"Transferring"), each holding `field` blocks, nested inside `col_c` of the
Settings section's own three-way split -- so each phi row's `_two_col_row` (the
renderer's fixed-pixel `label* | symbol(66) | editor(138) | annot(150)` grid,
see `script.py`) needed >450px it never had once two levels of column-halving
were done to it. `_render_table`'s cell handling was generalised for `lc_table`
(any editor kind, not just a tick, keyed per cell) and that generalisation
reads for a plain `table` too -- its grid columns are ordinary (unconstrained)
`ColumnDefinition`s, not `_two_col_row`'s fixed pixels, so a cell's editor gets
real room regardless of how many columns the table has. Resistance Factors is
now a `table`: rows Bending/Compression/Shear, columns Developed/Transferring,
built by `_cell(F(key))` so the existing missing-field `problems` bookkeeping
in `_field()` still runs even though the field block itself is only used for
its `editor`/`key`/`choices`, never rendered directly -- which does mean these
five phi keys carry no Notes-panel hint any more (`_render_field` is what
files a field's hint into the notes; a bare table cell dict has no hook for
that). The Concrete / Rebar-Mesh-Strand grids above stay on `columns`
deliberately -- they are not being squeezed the way Resistance Factors was.
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. Leave blank '
'to leave it unstated.',
False, 'choice', ('ACI 318-19', 'ACI 318-14', 'ACI 318-11')),
# ------------------------------------------- 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),
# Both moduli are DERIVED (`panelanalysisform.DERIVED_FIELDS`, ACI 318
# 19.2.2, Ec = 33 wc^1.5 sqrt(f'c)) and the page keeps them live as
# f'c / f'ci / unit weight are edited until the user types into one
# (issue #22, item 1). The old text here said a rule was still to be
# agreed; it had been in the field model since 2026-09-03. The solve
# still does not read either -- `panellift` computes its own -- and the
# hint says so, so the page mutes them honestly.
'conc_eci_psi': (
'Concrete elastic modulus at release', u"E′ci (psi)",
u"How stiff the concrete is at release strength. Computed by ACI "
u"318 from f′ci and the unit weight (Ec = 33 wc^1.5 sqrt(f′ci)) and "
u"kept up to date as you edit those; type a value to override it. "
u"Not yet used by the solve.",
False, 'num', None),
'conc_ec_psi': (
'Concrete elastic modulus, in-place', u"E′c (psi)",
u"How stiff the concrete is once fully cured. Computed by ACI 318 "
u"from f′c and the unit weight (Ec = 33 wc^1.5 sqrt(f′c)) and kept "
u"up to date as you edit those; type a value to override it. Not "
u"yet used by the solve.",
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),
# ----------------------------------------------- Material Properties/Mesh
'mesh_fy_ksi': (
'Mesh yield strength', 'fy (ksi)',
'The yield strength of the welded wire mesh. Defaults to the '
'common 80 ksi. Shown for the record; not yet used by the solve.',
False, 'num', None),
'mesh_es_ksi': (
'Mesh elastic modulus', 'Es (ksi)',
'How stiff the welded wire mesh is. Defaults to 29000 ksi. 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. Read into the in-place service check; '
'blank leaves that check NOT JUDGED.',
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.',
False, 'choice', ('L/75', 'L/120', 'L/150')),
# ---------------------------------------------- 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),
'cracking_assumption': (
'Cracked or uncracked section assumed', '',
'Whether the stress check assumes the concrete section is cracked '
'or uncracked. Read into the in-place service check as the '
'cracking class.',
False, 'choice', ('Cracked', 'Uncracked')),
'design_criteria.analysis_beta_d': (
u'Sway condition for 2nd-order analysis', u'βd',
'Whether the 2nd-order analysis above treats this panel as braced '
'(non-sway) or unbraced (sway). Leave blank if not applicable.',
False, 'choice', ('Non-Sway', 'Sway')),
'design_criteria.cracking_method': (
'Cracking method', '',
'Which published method decides whether the section is treated as '
'cracked. Leave blank if not applicable.',
False, 'choice', ('Mast Method', 'Uncracked', 'Cracked')),
'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),
# Eriksson prints these as three rows under one "In-Place Stress Limits"
# heading -- Tension (uncracked), Tension (transition / modulus of
# rupture), Compression -- and the review on 2026-09-04 (WPA655) found
# this page missing the first of the three (`inplace_tension_sqrt` is a
# real `panelanalysisform.FIELDS` key, just never surfaced here) and
# labelling the other two by what they compute rather than by Eriksson's
# own row names. All three keep the naming scheme now.
'inplace_tension_sqrt': (
u'Tension, uncracked', u"n·√f′c",
u"The concrete's tensile stress limit once erected, while the "
u"section is still assumed uncracked, as a multiple of √f′c. "
u"Leave blank to use the built-in default.",
False, 'num', None),
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
u'Tension, transition (modulus of rupture)', 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': (
u'Compression', u"n·f′c",
u"The compression limit for the panel once it is standing in "
u"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. Leave blank to use the '
'built-in default.',
False, 'choice', ('Whitney Stress Block', 'Strain Compatibility')),
'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, 'choice', ('None', '1.2 Mcr', '1.33 Mu')),
# ------------------------------------------------ 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_hours', '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_hours'] = (
'Hours from casting to release', '',
'How many hours 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 _table(headers, rows, header_groups=None):
return {'kind': 'table', 'headers': list(headers),
'header_groups': header_groups, 'rows': [list(r) for r in rows]}
def _cell(field_block):
"""A `table` cell dict for one `_field()` result -- the Resistance
Factors table (see the module docstring's 2026-09-04 update) reuses
`_field()` only for its `editor` / `key` / `choices` and the
missing-backing-field `problems` entry it already files, never for the
`field` block itself (a bare table cell has nowhere to put a label or a
hint, so those are simply not repeated here)."""
return {'editor': field_block['editor'], 'key': field_block['key'],
'choices': field_block['choices']}
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.
#
# THESE ARE NOW A FALLBACK, not the source. The live copy is
# `panelanalysisform.LOAD_COMBO_ROWS`, because the factors became editable form
# VALUES and a value needs a key, which is that module's business. These stay
# because this file's whole discipline is that a sibling module mid-edit must
# not take the page down: if the import fails, the table still prints the ACI
# defaults (as plain text, with a named problem saying it cannot be edited)
# instead of vanishing. Keep the two in step -- a host test compares them.
_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, values, problems):
"""One EDITABLE load-combination table, as an `lc_table` block.
Not a plain `table` any more. The owner ruled (2026-09-03) that the load
factors are the user's to change and that a row can be added, and a `table`
cell is only ever a string or a tick box -- so this emits `lc_table`, whose
factor cells each carry an `{'editor': 'num', 'key': ...}` the renderer
turns into a live box, plus the `factors` list the renderer's "Add LC"
button needs to build a new row's cells.
The FACTORS themselves come from `panelanalysisform.LOAD_COMBO_ROWS`, which
is the one place that knows both the ACI default and the key it is stored
under. The local tuples stay as a fallback for the case this file already
handles everywhere else -- the sibling module mid-edit and unimportable --
so a broken import degrades this page to the code defaults with no live
keys rather than to no table at all.
`values` is accepted (and read only for the user's own added rows) rather
than the row tuples the old signature took: the rows are no longer this
file's to decide.
"""
values = values if isinstance(values, dict) else {}
fallback = {'strength': _STRENGTH_COMBOS, 'service': _SERVICE_COMBOS}
factors = list(_COMBO_HEADERS[1:-1])
try:
from vibe import panelanalysisform as paf
factors = list(paf.LOAD_COMBO_FACTORS)
row_defaults = dict((r[0], r) for r in paf.LOAD_COMBO_ROWS.get(name, ()))
row_ids = paf.load_combo_row_ids(name, values)
factor_key = paf.load_combo_factor_key
include_key = paf.load_combo_include_key
except Exception:
problems.append(
'could not read panelanalysisform for the {0} load-combination '
'factors; the table below shows the ACI defaults as plain text '
'and cannot be edited until that module loads.'.format(name))
row_defaults = dict((r[0], r) for r in fallback.get(name, ()))
row_ids = [r[0] for r in fallback.get(name, ())]
def factor_key(nm, rid, col):
return 'load_combo.{0}.{1}.{2}'.format(nm, rid, col)
def include_key(nm, rid):
return 'load_combo.{0}.{1}.include'.format(nm, rid)
table_rows = []
for rid in row_ids:
cells = [str(rid)]
default = row_defaults.get(rid)
for i, col in enumerate(factors):
cells.append({'editor': 'num', 'width': 44,
'key': factor_key(name, rid, col),
'default': (default[i + 1] if default and
i + 1 < len(default) else None)})
cells.append({'editor': 'check', 'key': include_key(name, rid)})
table_rows.append(cells)
return {'kind': 'lc_table', 'name': name,
'headers': list(_COMBO_HEADERS), 'header_groups': None,
'rows': table_rows, 'factors': factors, '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), F('mesh_fy_ksi'), F('mesh_es_ksi')],
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
]))
blocks.append(_rule())
# ---------------------------------------------------------- Prestress Loss
#
# Two columns, not the one wide column this used to be laid out in: a
# single-column `_cols` hands `_two_col_row`'s star label a full page
# width to stretch into, which is what the 2026-09-04 review (WPA655)
# reported as the '%' symbol on `ps_loss_humidity_pct` "floating mid-row
# far from its editor" -- the symbol and editor are still adjacent (see
# `script.py`'s `_two_col_row`), but a page-wide row puts that pair a
# long way from the label a reader's eye is anchored to. Splitting the
# four fields into two narrower columns, the same way every other group
# on this page already does, keeps the whole row -- label, symbol, and
# editor together -- inside one eye span.
blocks.append(_heading('Prestress Losses', 2))
blocks.extend(_group_or_sentence(
values, _PRESTRESS_LOSS_FIELDS,
'No prestress loss assumptions entered for this panel.',
lambda: [_cols([
[F('ps_loss_method'), F('ps_loss_humidity_pct')],
[F('ps_loss_release_hours'), F('ps_loss_erection_days')],
])]))
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', values, problems),
F('design_criteria.min_earth_factor_strength')],
[_heading('Service Combinations', 2),
_combo_table('service', values, 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('cracking_assumption'),
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('inplace_tension_sqrt'),
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),
# A `table`, not the `columns`-of-`field`s this used to be -- the
# 2026-09-04 review (WPA655) found every phi editor rendered with
# zero width. That old layout was two `columns` sub-columns
# ("Developed" | "Transferring") of `field` blocks, nested inside
# `col_c` itself one of three columns -- two levels of halving, each
# `field` needing the renderer's fixed ~450px `_two_col_row` it never
# had a fraction of. `_render_table` puts a cell's editor in an
# ordinary (unconstrained) grid column instead, so there is always
# room. See the module docstring's 2026-09-04 update for why `_cell`
# still routes every key through `F()` first (it is what keeps the
# existing "no backing field yet" `problems` bookkeeping honest).
_table(
[u'φ', 'Developed', 'Transferring'],
[
['Bending', _cell(F('phi_flexure')),
_cell(F('design_criteria.phi_tension_transfer'))],
['Compression', _cell(F('design_criteria.phi_compression')),
'Not applicable once the strand has fully transferred.'],
['Shear', _cell(F('phi_shear')),
_cell(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
+807
View File
@@ -0,0 +1,807 @@
# -*- coding: utf-8 -*-
"""Aggressive Panel Analysis defaults: fill every blank the tool can defend.
WHY THIS EXISTS. Owner, 2026-09-04: "目标是我按一下 WPA655 的 panel analysis,
然后手动输入 loading, 剩下的它就能跑了" -- press the button, type the loading,
everything else already has a number. The form opened that day with 141 of
315 fields blank. His instruction was explicit that BLANK is the failure mode
here, not a wrong-looking guess -- every field with a defensible default gets
one, and every one of them names where it came from. Only true LOADING (floor
loads, wind speed / pressure, seismic Cs, soil) is left for the user, because
those are the numbers this tool cannot know and must not invent.
WHAT THIS MODULE OWNS. `panelanalysisform.prefill()` already fills what the
model can read plus a small `PREFILL_DEFAULTS` table of generic precast
constants (concrete type, rebar grade, mesh grade, the PCI loss-method
seeds). This module is a SECOND, later pass over the same `values` dict --
covering the much larger set of Design Criteria / Structural Model / Handling
fields that table does not touch, most of which do not even have a home in
`panelanalysisform.FIELDS` yet (they live in `pa_criteria._F` and inline in
`pa_structural.page()` instead, under the `design_criteria.*` / `load_opt_*`
/ `wind_*` / `temp_*` / `restraint_*` / `stage.<slug>.<opt>` naming this
codebase already uses). `apply_defaults()` is meant to run AFTER `prefill()`
in the button script, so the ordinary precedence rule holds without this
module doing anything special for it: a value already sitting in `values` --
a model read, a plant `panelanalysis.settings.*` pin, or a
`PREFILL_DEFAULTS` seed -- always wins, and this module only ever fills what
is STILL blank.
SOURCES CITED IN `why`. Three kinds, and every filled entry names one:
* "Eriksson Wall default (<page> page)" -- the value the owner's own
Eriksson Wall runs show for this field on a fresh panel. This is the
fallback for most of the Design Criteria / Handling settings below --
generic precast practice, not one job's data.
* an ACI 318-19 / ASCE 7-22 section number -- code text, not a guess.
* "plant knowledge <dotted.path>" -- a real value read off the merged
knowledge layers (`know`), which always outranks the generic fallback
for the handful of fields a plant is expected to pin (the handling
stage multipliers and strength, the sling angle, the strand rail
datum, `top_in_form`, `stress_ignore_lateral_ecc`, the engineer's own
initials). Nothing here reads the Revit API or a live document --
`know` is just the already-merged dict the button hands in.
WHAT NEVER GETS A DEFAULT. `ps_loss_pct` (the method computes it),
`ext_soil_loads` / `int_soil_loads` (loading), every raw `seismic_cs` /
`seismic_zone_location` field (loading -- the owner ruled on this
explicitly, 2026-09-02: "风的计算还没有你就留着空着,让用户输入"), and
`engineer_initials` when no plant value names one -- a person's identity is
not a default, the same rule CLAUDE.md states for client and job names.
`conc_loads` (the old free-text field) is gone from the form entirely --
concentrated loads are a row table now (`panelanalysisform.CONC_LOAD_TABLE`),
never defaulted, same rule. ASCE wind (`wind_speed_vult`, `wind_risk_
category`, `wind_exposure`, `wind_enclosure`, `wind_asce_version`,
`wind_cc_chapter`, `wind_mwfrs_chapter`, `wind_mean_roof_height_ft`,
`wind_effective_wind_area_ft2`, `wind_pressure_psf`, `wind_suction_psf`) is
gone from this module too -- 2026-09-04 owner ruling moved ASCE site data
out of this form into project config entirely (see
`panelanalysisform`'s module docstring, Wind section); this module never
defaulted the two real-loading fields among them anyway, and now has
nothing left to default for the rest either.
NEVER RAISES. `defaults_for` / `apply_defaults` / `why_of` / `known_keys` /
`unknown_keys` all degrade to an empty result rather than an exception --
a malformed `values` (`None`, not a dict), a malformed `know` ('garbage',
not a dict), or a sibling module mid-edit (an import failure in
`panelanalysisform` / `pa_criteria` / `pa_structural`, all still being
written the same day this file was) each narrow what gets filled instead of
stopping the whole pass. `known_keys()` follows the same "could not judge
narrows, never guesses" discipline CLAUDE.md's `knowledge.same_face()`
example sets: a module that fails to import contributes no keys, not a
guessed set.
`known_keys()` / `unknown_keys()` READ THE LIVE FORM, not a copy of its key
list. `pa_structural.page()` has no flat field table the way
`panelanalysisform.FIELDS` and `pa_criteria._F` do -- its field keys are
scattered through one long `page()` function as inline `_field(...)` calls.
Rather than hand-copy that list here (and drift the day someone edits it),
`_pa_structural_blocks()` calls `pa_structural.page({}, {})` -- documented as
never raising -- and walks the returned block tree for every `kind: 'field'`
entry, the same shape the WPF renderer itself would see. That also recovers
each field's `choices`, feeding the choice-membership check below.
CHOICE VOCABULARY. Where a key has a `choices` list anywhere in the form
(`panelanalysisform.FIELD_CHOICES`, `pa_criteria._F`'s own choices column,
`pa_structural.page()`'s inline `choices=[...]`, or the Handling stage
matrix's per-column `choices` hint in `STAGE_OPTIONS`), the default picked
below is one of those choices -- verified by
`tests/test_pa_defaults.py`, not just asserted here. Two of the owner's
literal example values did not survive that check against the real editors
and were changed; see the module-level `_DECISIONS` note near the bottom of
this file for both.
IRONPYTHON 2.7: no f-strings, `'{0}'.format(...)` throughout, this coding
cookie, no `pathlib` / `dataclasses` / typing syntax. Imports nothing but the
standard library at module scope; every `vibe.*` import is inside a function,
wrapped in `try/except Exception`, exactly so a sibling module mid-edit can
never take this one down.
"""
import datetime
# --------------------------------------------------------------- key sources
def _panelanalysisform():
try:
from vibe import panelanalysisform as paf
return paf
except Exception:
return None
def _pa_criteria():
try:
from vibe import pa_criteria as pc
return pc
except Exception:
return None
def _pa_structural_blocks():
"""[(key, editor, choices), ...] read off a live `pa_structural.page()`
call -- see the module docstring's "READ THE LIVE FORM" note for why
this walks the block tree instead of keeping a second copy of the field
list. Never raises: an import failure or a `page()` that cannot run
(both plausible while a sibling agent edits either file) returns [].
"""
try:
from vibe import pa_structural as ps
except Exception:
return []
try:
blocks, _problems = ps.page({}, {})
except Exception:
return []
out = []
def walk(items):
for b in items or []:
if not isinstance(b, dict):
continue
kind = b.get('kind')
if kind == 'field' and b.get('key'):
out.append((b.get('key'), b.get('editor'), b.get('choices')))
elif kind == 'columns':
for col in (b.get('columns') or []):
walk(col)
elif kind == 'section':
# A collapsed section (seismic / temperature / soil since
# 2026-09-04) carries its fields in `blocks`; they are still
# the form's keys.
walk(b.get('blocks'))
walk(blocks)
return out
def known_keys():
"""Every key the Panel Analysis form is known to carry today, gathered
defensively from every section module this file knows how to ask:
`panelanalysisform.FIELDS`, the Handling stage matrix
(`stage_field_keys()`), `pa_criteria._F`, and a live read of
`pa_structural.page()`'s field blocks. A module that fails to import
only narrows this set -- see the module docstring.
"""
keys = set()
paf = _panelanalysisform()
if paf is not None:
try:
keys.update(f['key'] for f in paf.FIELDS)
except Exception:
pass
try:
keys.update(paf.stage_field_keys())
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
keys.update(pc._F.keys())
except Exception:
pass
for key, _editor, _choices in _pa_structural_blocks():
keys.add(key)
return keys
def unknown_keys():
"""The keys this module WOULD default that `known_keys()` does not (yet)
recognise -- empty once the field-model agents have caught up. Never
raises; a lookup failure inside `known_keys()` already degrades to a
smaller set, so this can only ever over-report, never under-report, a
drift.
"""
known = known_keys()
out = []
for key in _all_default_keys():
if key not in known and key not in out:
out.append(key)
return out
# ------------------------------------------------------------- choice tables
def _choice_registry():
"""key -> tuple(choices), unioned across every source that names one.
A key with no entry here is free text (or a key nothing declares a
choice list for yet) -- `choices_for` returns None for it, and the
default picked for it is not checked against anything.
"""
out = {}
def add(key, choices):
if not choices:
return
cur = out.get(key, ())
merged = tuple(cur) + tuple(c for c in choices if c not in cur)
out[key] = merged
paf = _panelanalysisform()
if paf is not None:
try:
for key, choices in paf.FIELD_CHOICES.items():
add(key, choices)
except Exception:
pass
try:
for stage in paf.STAGES:
for opt_key, _label, _sym, _help, _kind, _drives, choices \
in paf.STAGE_OPTIONS:
add(paf.stage_field_key(stage, opt_key), choices)
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
for key, spec in pc._F.items():
choices = spec[5] if len(spec) > 5 else None
add(key, choices)
except Exception:
pass
for key, _editor, choices in _pa_structural_blocks():
add(key, choices)
return out
def choices_for(key):
"""The unioned choice vocabulary for `key`, or None for free text."""
return _choice_registry().get(key) or None
# ------------------------------------------------------------------ helpers
def _is_blank(value):
if value is None:
return True
try:
return value.strip() == ''
except AttributeError:
return False
def _num(text):
"""A best-effort float from whatever sits in `values`, or None."""
if text is None:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A number as the shortest text the form shows -- '2' not '2.0'."""
if value is None:
return ''
if isinstance(value, bool):
return 'Yes' if value else 'No'
try:
if isinstance(value, int):
return str(value)
except Exception:
pass
try:
f = float(value)
except (TypeError, ValueError):
return str(value)
if f == int(f):
return str(int(f))
text = ('%.10f' % f).rstrip('0').rstrip('.')
return text
def _today_iso():
return datetime.date.today().isoformat()
def _as_dict(value):
return value if isinstance(value, dict) else {}
# ---------------------------------------------------------- static defaults
#
# key -> (value_text, why). Fixed values -- no plant lookup, no computation
# from another field -- for every Design Criteria / Structural Model /
# Handling field the owner named that does not depend on plant knowledge or
# on another field's value. See `_DYNAMIC_DEFAULTS` and `_stage_dynamic_*`
# below for the ones that do.
_ERIKSSON = 'Eriksson Wall default'
def _e(page):
return '{0} ({1} page)'.format(_ERIKSSON, page)
_STATIC_DEFAULTS = (
('design_spec', 'ACI 318-19',
'current governing code edition (ASCE 7-22 companion); the sample '
'Eriksson runs printed ACI 318-14 because they were older files'),
# ---------------------------------------------------- Design Criteria
('design_criteria.deflection_add_bow', 'No', _e('Settings')),
('design_criteria.deflection_additional_bow_in', '0', _e('Settings')),
('design_criteria.deflection_check_cantilever', 'No', _e('Settings')),
('design_criteria.deflection_cantilever_limit', 'L/75', _e('Settings')),
('design_criteria.analysis_beta_d', 'Non-Sway',
"matches the field's own choice vocabulary (Non-Sway / Sway) -- "
"Non-Sway is " + _e('Settings')),
('design_criteria.cracking_method', 'Mast Method', _e('Settings')),
('design_criteria.cracking_limit_sqrt_fc', '7.5', _e('Settings')),
('design_criteria.rvalue_calc_enabled', 'No', _e('Settings')),
('design_criteria.min_reinf_checked', 'No', _e('Settings')),
('design_criteria.stress_failure_if_cracked', 'No', _e('Settings')),
('design_criteria.stress_use_principal_axis', 'No', _e('Settings')),
('design_criteria.inplace_modulus_rupture_sqrt_fc', '12',
u"Eriksson's own \"transition\" tension limit, 12 x sqrt(f'c)"),
('design_criteria.flexure_method', 'Whitney Stress Block',
_e('Settings')),
('design_criteria.flexure_ignored_end_distance_in', '0',
_e('Settings')),
('design_criteria.flexure_cracking_check', 'None', _e('Settings')),
('design_criteria.phi_compression', '0.65', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_tension_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_shear_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.dev_mult_debonded', '2', _e('Settings')),
('design_criteria.dev_mult_cut', '1.3', _e('Settings')),
('design_criteria.dev_mult_mild', '1.3', _e('Settings')),
('dev_mult', '1.3', _e('Settings') + ' -- strand, normal'),
('design_criteria.shear_treat_as_column', 'No', _e('Settings')),
('design_criteria.axial_ignore_tensile_failures', 'No', _e('Settings')),
('design_criteria.axial_ignore_buckling_failures', 'No', _e('Settings')),
('design_criteria.min_earth_factor_strength', '0', _e('Settings')),
('design_criteria.min_earth_factor_service', '0', _e('Settings')),
('second_order', 'Yes', _e('Settings') + ' -- P-Delta'),
('deflection_limit', 'L/150', _e('Settings')),
# ------------------------------------------------------- Load Options
('self_weight_factor', '1', _e('Settings')),
('load_opt_selfweight_ecc_in', '0', _e('Settings')),
('load_opt_offset_from', 'Deflection Section',
'Eriksson calls this the Loading Datum; the sample runs measure '
'point-load offsets from the Deflection Section'),
('load_opt_pattern_live', 'No',
_e('Settings') + ' -- Pattern Live Loads for Flexure is off in the '
'sample runs'),
# -------------------------------------------------------- Temperature
('temp_ext_f', '75', _e('Settings')),
('temp_int_f', '75', _e('Settings')),
('temp_elasticity_mult', '0.5', _e('Settings')),
('temp_alpha_t', '5.5',
u'5.5 × 10⁻⁶ /°F (ACI 209 / PCI), entered as the '
u'×10⁻⁶ mantissa the field asks for'),
# Wind is GONE from this module -- 2026-09-04 owner ruling moved every
# ASCE site-data field (Vult, risk/exposure/enclosure, code version,
# C&C/MWFRS chapter, mean roof height, effective wind area) out of this
# form into project config; see `panelanalysisform`'s module docstring.
# `wind_mode` (the one wind field left) defaults on the form side, in
# `panelanalysisform.prefill` itself, not here.
# ---------------------------------------------------------- Restraints
# ------------------------------------------------------------ Seismic
# 'seismic_apply_to' picks WHAT the (still-blank, user-typed) seismic
# coefficient multiplies -- not a magnitude itself, so it is not the
# loading the owner ruled must stay blank. Its editor carries an
# explicit 'None' choice now, matched here rather than left blank.
('seismic_apply_to', 'None',
"matches the field's own choice vocabulary (None / Out-of-plane / "
'In-plane / Both); ' + _e('Structural Model') + ' -- no seismic '
'multiplier applied in the sample runs'),
# ------------------------------------------------- Prestressing Strands
('strand_rail_offset_in', '0', _e('Reinforcement')),
# -------------------------------------------------------------- Extents
('left_miter', 'No', _e('Concrete Extents')),
('right_miter', 'No', _e('Concrete Extents')),
)
def _all_static_keys():
return [key for key, _v, _w in _STATIC_DEFAULTS]
# ---------------------------------------------------------- dynamic values
#
# key -> function(values, know) -> (value_text, why) | None. None means
# "this module cannot defend a default here right now" -- the key stays
# blank, never a fabricated fallback. See the module docstring for the
# plant-knowledge paths each of these reads.
def _dyn_project_date(values, know):
return _today_iso(), "today's date, the same convention design_date uses"
def _dyn_engineer_initials(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('engineer_initials')
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text, 'plant knowledge panelanalysis.engineer_initials'
def _dyn_stress_ignore_lateral_ecc(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
settings = _as_dict(pa.get('settings'))
value = settings.get('stress_ignore_lateral_ecc')
if value is not None:
text = str(value).strip().lower()
yn = 'No' if text in ('', 'no', 'none', 'false', '0', 'off') \
else 'Yes'
return yn, ('plant knowledge '
'panelanalysis.settings.stress_ignore_lateral_ecc')
return 'No', _e('Settings')
def _dyn_top_in_form(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('top_in_form')
if value is not None:
text = str(value).strip()
if text:
for choice in ('Interior', 'Exterior'):
if text.lower() == choice.lower():
text = choice
break
return text, 'plant knowledge panelanalysis.top_in_form'
return 'Interior', _e('Concrete Extents')
_RAIL_LABELS = {
'left_rail': 'Left edge',
'right_rail': 'Right edge',
'centerline': 'Centerline',
}
def _dyn_strand_rail_location(values, know):
rails = _as_dict(_as_dict(know).get('rails'))
datum = rails.get('strand_datum')
if datum is not None:
label = _RAIL_LABELS.get(str(datum).strip().lower())
if label:
return label, 'plant knowledge rails.strand_datum'
return 'Left edge', _e('Reinforcement')
_DYNAMIC_DEFAULTS = (
('project_date', _dyn_project_date),
('engineer_initials', _dyn_engineer_initials),
('design_criteria.stress_ignore_lateral_ecc',
_dyn_stress_ignore_lateral_ecc),
('top_in_form', _dyn_top_in_form),
('strand_rail_location', _dyn_strand_rail_location),
# `wind_effective_wind_area_ft2` / `wind_mean_roof_height_ft` are GONE --
# see the module docstring's Wind note; both fields left the form.
)
# --------------------------------------------------------- handling stages
#
# `panelanalysisform.stage_field_key(stage, opt)` builds
# 'stage.<slug>.<opt>' from the stage NAME, not the slug -- but this module
# hardcodes the slugs too (matching `_stage_slug`'s
# `name.lower().replace(' ', '_')` exactly) rather than importing the form
# at module scope, so a sibling agent mid-edit on `panelanalysisform.py`
# cannot take the whole default table down with it. `known_keys()` still
# checks these against the live `stage_field_keys()` output, so any drift
# between the hardcoded slug and the real one shows up as an `unknown_keys()`
# entry rather than silently mis-keying the form.
_STAGES = ('Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection')
_STAGE_SLUGS = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'primary_erection',
'Secondary Erection': 'secondary_erection',
}
_STAGE_PLANT_NAME = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'erection',
'Secondary Erection': None,
}
_STAGE_FALLBACK_MULTIPLIER = {
'Stripping': 1.4,
'Transportation': 1.5,
'Primary Erection': 1.4,
}
_STAGE_FALLBACK_STRENGTH_LABEL = {
'Stripping': 'Initial',
'Transportation': 'In-Place',
'Primary Erection': 'In-Place',
}
_STAGE_FALLBACK_MAX_TILT = {
'Stripping': 0,
'Transportation': 0,
'Primary Erection': 90,
}
_STRENGTH_LABELS = {'fci': 'Initial', 'fc': 'In-Place'}
def _stage_key(stage, opt):
return 'stage.{0}.{1}'.format(_STAGE_SLUGS[stage], opt)
def _plant_stage(know, plant_name):
if plant_name is None:
return None
handling = _as_dict(_as_dict(know).get('handling'))
stages = handling.get('stages')
if not isinstance(stages, list):
return None
for entry in stages:
if isinstance(entry, dict) and entry.get('name') == plant_name:
return entry
return None
def _plant_sling_angle(know):
handling = _as_dict(_as_dict(know).get('handling'))
value = handling.get('sling_angle_deg')
return value if value is not None else None
# Static (non-plant-dependent) cells, one set per active stage -- generated
# once into `_STATIC_DEFAULTS`-shaped tuples so they go through the exact
# same blank-check / why_of machinery as everything else. Only
# `load_multiplier`, `concrete_strength` and `sling_angle` are plant-aware
# (see `_stage_dynamic_defaults` below); everything else about a stage is
# the same Eriksson-sample answer regardless of plant.
def _stage_static_defaults():
out = []
for stage in _STAGES:
if stage == 'Secondary Erection':
out.append((
_stage_key(stage, 'include'), 'No',
_e('Handling') + ' -- secondary erection is not run for '
'this panel unless entered'))
continue
out.append((_stage_key(stage, 'include'), 'Yes', _e('Handling')))
out.append((_stage_key(stage, 'lift_type'), 'Static Lift',
_e('Handling')))
stress_limits = 'Handling' if stage == 'Stripping' else 'In-Place'
out.append((_stage_key(stage, 'stress_limits'), stress_limits,
_e('Handling')))
out.append((_stage_key(stage, 'rigging'), 'Auto', _e('Handling')))
out.append((_stage_key(stage, 'ground_reaction'), 'No',
_e('Handling')))
out.append((_stage_key(stage, 'min_tilt_deg'), '0', _e('Handling')))
out.append((_stage_key(stage, 'max_tilt_deg'),
_fmt_num(_STAGE_FALLBACK_MAX_TILT[stage]),
_e('Handling')))
out.append((_stage_key(stage, 'top_crane_carries'), 'No',
_e('Handling')))
return tuple(out)
_STAGE_STATIC_DEFAULTS = _stage_static_defaults()
def _stage_dynamic_defaults(values, know):
out = []
sling = _plant_sling_angle(know)
if sling is not None:
sling_text = _fmt_num(sling)
sling_why = 'plant knowledge handling.sling_angle_deg'
else:
sling_text = '60'
sling_why = _e('Handling')
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
plant_name = _STAGE_PLANT_NAME.get(stage)
pstage = _plant_stage(know, plant_name)
multiplier = None
mult_why = _e('Handling')
if pstage is not None and pstage.get('multiplier') is not None:
multiplier = pstage.get('multiplier')
mult_why = 'plant knowledge handling.stages'
if multiplier is None:
multiplier = _STAGE_FALLBACK_MULTIPLIER[stage]
out.append((_stage_key(stage, 'load_multiplier'),
_fmt_num(multiplier), mult_why))
strength_label = _STAGE_FALLBACK_STRENGTH_LABEL[stage]
strength_why = _e('Handling')
if pstage is not None and pstage.get('strength') in \
_STRENGTH_LABELS:
strength_label = _STRENGTH_LABELS[pstage.get('strength')]
strength_why = 'plant knowledge handling.stages'
out.append((_stage_key(stage, 'concrete_strength'), strength_label,
strength_why))
out.append((_stage_key(stage, 'sling_angle'), sling_text,
sling_why))
return out
def _stage_dynamic_keys():
keys = []
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
keys.append(_stage_key(stage, 'load_multiplier'))
keys.append(_stage_key(stage, 'concrete_strength'))
keys.append(_stage_key(stage, 'sling_angle'))
return keys
# --------------------------------------------------------------- the WHY map
_WHY = {}
for _key, _value, _why in _STATIC_DEFAULTS:
_WHY[_key] = _why
for _key, _value, _why in _STAGE_STATIC_DEFAULTS:
_WHY[_key] = _why
del _key, _value, _why
_WHY['project_date'] = ("today's date, the same convention design_date "
'uses')
_WHY['engineer_initials'] = (
'plant knowledge panelanalysis.engineer_initials, when the plant names '
'one -- a person\'s identity is never guessed, so this key is left '
'blank without a plant value')
_WHY['design_criteria.stress_ignore_lateral_ecc'] = (
'plant knowledge panelanalysis.settings.stress_ignore_lateral_ecc when '
'set, else ' + _e('Settings'))
_WHY['top_in_form'] = ('plant knowledge panelanalysis.top_in_form when '
'set, else ' + _e('Concrete Extents'))
_WHY['strand_rail_location'] = ('plant knowledge rails.strand_datum when '
'set, else ' + _e('Reinforcement'))
for _stage in _STAGES:
if _stage == 'Secondary Erection':
continue
_WHY[_stage_key(_stage, 'load_multiplier')] = (
'plant knowledge handling.stages when a matching stage names a '
'multiplier, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'concrete_strength')] = (
"plant knowledge handling.stages' strength field (fci -> Initial, "
'fc -> In-Place) when present, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'sling_angle')] = (
'plant knowledge handling.sling_angle_deg when set, else ' +
_e('Handling'))
del _stage
def why_of(key):
"""The source sentence for `key`'s default, or None if this module does
not know how to default that key. Independent of any particular
`values` / `know` -- see the module docstring for why: for a
plant-aware key this names the RULE ("plant knowledge X when set, else
Y"), not necessarily which branch a specific call took.
"""
try:
return _WHY.get(key)
except Exception:
return None
def _all_default_keys():
keys = list(_all_static_keys())
keys.extend(key for key, _v, _w in _STAGE_STATIC_DEFAULTS)
keys.extend(key for key, _fn in _DYNAMIC_DEFAULTS)
keys.extend(_stage_dynamic_keys())
return keys
# ------------------------------------------------------------------- the API
def _compute(values, know):
out = []
seen = set()
def emit(key, value_text, why):
if key in seen:
return
seen.add(key)
if not _is_blank(values.get(key)):
return
out.append((key, value_text, why))
for key, value_text, why in _STATIC_DEFAULTS:
emit(key, value_text, why)
for key, value_text, why in _STAGE_STATIC_DEFAULTS:
emit(key, value_text, why)
for key, fn in _DYNAMIC_DEFAULTS:
try:
result = fn(values, know)
except Exception:
result = None
if result is None:
continue
value_text, why = result
emit(key, value_text, why)
try:
stage_dyn = _stage_dynamic_defaults(values, know)
except Exception:
stage_dyn = []
for key, value_text, why in stage_dyn:
emit(key, value_text, why)
return out
def defaults_for(values, know=None):
"""[(key, value_text, why), ...] for every key whose current value in
`values` is blank ('' / None / missing) and that this module knows a
default for. `values` and `know` are read only, never mutated. Never
raises -- a `values` that is not a dict, or a `know` that is not a
dict, both degrade to []/an empty knowledge tree rather than an error.
"""
if not isinstance(values, dict):
return []
try:
return _compute(values, know if isinstance(know, dict) else {})
except Exception:
return []
def apply_defaults(values, know=None):
"""Fills the blanks `defaults_for` finds, IN PLACE on `values`, and
returns the same [(key, value_text, why), ...] it applied. A value
that is no longer blank by the time this writes it (nothing here
should cause that, but the check costs nothing) is left untouched --
this never overwrites a real value. Never raises: `values` that is not
a (mutable) dict returns [] and nothing is written.
"""
if not isinstance(values, dict):
return []
try:
found = defaults_for(values, know)
except Exception:
return []
applied = []
for key, value_text, why in found:
try:
if _is_blank(values.get(key)):
values[key] = value_text
applied.append((key, value_text, why))
except Exception:
continue
return applied
# --------------------------------------------------------------- _DECISIONS
#
# `design_criteria.analysis_beta_d` and `load_opt_offset_from` were checked
# against `pa_criteria`'s and `pa_structural`'s real editors while this file
# was being written and, at that point, neither editor's choice list
# actually contained the owner's own example value (a numeric-only editor
# for the first, `['Panel CG', 'Other']` for the second). Both editors were
# given real choice lists by another agent, concurrently, before this file's
# tests were finalised -- `design_criteria.analysis_beta_d` now offers
# `('Non-Sway', 'Sway')` and `load_opt_offset_from` now offers
# `('Deflection Section', 'Top in Form', 'Bottom in Form')` -- and both
# match the owner's original values exactly, so both are encoded as he
# specified. Left as a note because `choices_for()` reads the LIVE editors
# on every call: if either drifts again, `tests/test_pa_defaults.py::
# test_every_default_with_choices_is_a_valid_choice` fails and names the
# mismatch rather than this comment going stale silently.
#
# dev_mult / second_order Both already carry a DIFFERENT generic default
# in `panelanalysisform.PREFILL_DEFAULTS`
# ('1' and 'No', committed 2026-09-03) that runs
# unconditionally in `prefill()`, before this
# module's `apply_defaults()` would ever see the
# key -- so under the current call order neither
# of this module's defaults (1.3, 'Yes') can ever
# actually fire; `values[key]` is already
# non-blank by the time `apply_defaults` looks at
# it. Encoded as the owner specified anyway
# (2026-09-04 postdates the 2026-09-03 seed), but
# this is a live policy conflict between the two
# tables, not a bug in either -- flagged for
# whoever wires `apply_defaults()` into `prefill()`
# to resolve by call order or by retiring the
# older seed.

Some files were not shown because too many files have changed in this diff Show More