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

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
---
name: Bug report
about: Something ran and gave a wrong or crashing answer
title: ''
labels: bug
assignees: ''
---
<!--
Before filing: is this a WRONG answer, or a REFUSAL to answer?
WillTools refuses rather than guesses, by design -- a "NOT JUDGED" result
with a stated reason is often the tool working CORRECTLY (an unmapped
parameter, a missing knowledge layer, a check that legitimately has nothing
to say about this piece). If the tool told you clearly why it would not
answer, that is very likely not this template -- it's the tool doing what it
is supposed to. File that as a "refused, and I think it shouldn't have"
report instead (see the Question template), because that is knowledge-layer
work, not a code fix.
This template is for: it crashed, it gave two different answers to the same
input, or it silently produced a wrong number with no stated reason.
-->
## What happened
<!-- Paste the exact error, verbatim, in a code block. Do not summarize it or
retype it from memory -- the exact text (including any traceback) is what
gets this fixed fast. -->
```
paste it here
```
## What you expected instead
## Steps to reproduce
1.
2.
3.
## Environment
Run this and paste its ENTIRE output below, unedited -- it is the single
most diagnostic thing you can attach. It is plain text with no color codes,
so it pastes cleanly into this box:
```
python tools/setup_machine.py --check
```
<details>
<summary>setup_machine.py --check output</summary>
```
paste it here
```
</details>
- Windows version:
- `python --version`:
- Revit version (if this involves a ribbon button):
- pyRevit version (if this involves a ribbon button):
## Which button or command
<!-- e.g. "Ticket QC" on the ribbon, or `python -m panellift ...` -->
## No client or job data, please
Screenshots, pasted output, and job files should carry no real client name,
job name, or job number. If a screenshot or a job file shows one, use a
placeholder (`sample_client`, `sample_job`) or ask the owner how to share it
privately instead of pasting it here.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Ask the owner directly
url: https://github.com/will123song/pyrevit-dev/discussions
about: Not sure it's a bug, or want to talk through knowledge layers first?
+19
View File
@@ -0,0 +1,19 @@
---
name: Question
about: Something was unclear, or you're not sure what a result means
title: ''
labels: question
assignees: ''
---
## What you were trying to do
## What the report or the ribbon said
<!-- Paste the relevant text, or attach a screenshot with no client/job
names visible (see the note in the bug report template). -->
## What was unclear about it
<!-- Was it a refusal you think should have been an answer? A wording you
could not parse? A row you did not expect to be blank? Say which. -->
+39
View File
@@ -0,0 +1,39 @@
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 required for tools/ticket_delta.py's tests.
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 owner's machine: this
# distribution ships no .scrub-terms at all, so CI runs the generic
# .scrub-terms.example instead -- the same fallback a colleague's own
# clone uses locally with no real term list.
run: python tools/scrub.py --worktree --terms .scrub-terms.example
+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.
+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.
+305
View File
@@ -0,0 +1,305 @@
# Installing WillTools
Precast engineering tooling: eleven buttons across three ribbon panels — four
finished tools on **Tools**, five set-up commands behind the **Config**
pulldown, two unfinished ones quarantined behind **DesignHub**.
`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 |
**No button that does engineering work needs the network or a local model
server.** Design Extract reads a design packet in-process with a
regex/geometry parser and never leaves Revit.
### 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. **Restart Revit.** Not "pyRevit → Reload" — see [§7](#7-the-one-rule).
A **WillTools** tab appears with three panels, left to right:
| Panel | |
| --- | --- |
| **Tools** | four large buttons: Reinforce, Panel Lift, Ticket QC, Snapshot. |
| **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. Everything behind it is unfinished and says so — its rows are marked `(WIP)`. |
## 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
└── WillTools.tab/ the ribbon, three panels left to right:
├── Tools.panel/ Reinforce · Panel Lift · Ticket QC ·
│ Snapshot — the four finished tools, all
│ large buttons
├── Setup.panel/ Config.pulldown/ — Project Config ·
│ Design Extract · Self Check · Job Recon ·
│ Takeoff
└── Design.panel/ DesignHub.pulldown/ — Gravity (WIP) ·
Lateral (WIP); nothing finished lives here
```
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."
+163
View File
@@ -0,0 +1,163 @@
# 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. The four tools that are finished are the
four large buttons on **Tools**; everything else is one click down behind a
pulldown, which is what keeps them apart.
| Panel | Button | |
| --- | --- | --- |
| Tools | **Reinforce** | Suggests reinforcing for a design from what comparable designs already carry. |
| | **Panel Lift** | PCI Chapter 8 handling check on the piece, from the insert positions the model already holds. |
| | **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 four 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, and a quarantine: both rows below are buttons-and-docs only. |
| | ↳ 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. |
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.
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.
Binary file not shown.
@@ -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,312 @@
# -*- 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
from pyrevit import revit, script, forms
from vibe import designspec, knowledge, latextract, latmodel, latrisa
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."""
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))
script.exit()
# ------------------------------------------------------------------ context
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
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]))
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.')
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.')
@@ -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,17 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: DesignHub
# All three rows are half-built and all three 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 its two siblings 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.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- GravityLoadChase
- LateralExtract
- PanelAnalysis
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,11 @@
# 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 and lateral extraction
# are buttons-and-docs only until he provides worked examples, so they live
# one click down, behind DesignHub, on a panel of their own.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- DesignHub
@@ -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,583 @@
# -*- coding: utf-8 -*-
#
# Turn a design summary into a reviewable spec file.
#
# THE PASS, 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 (ExportImage writes no model data).
#
# 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
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 foldreport as fold
from vibe import scope as scopelib
doc = revit.doc
output = script.get_output()
output.close_others()
if not doc.PathName:
forms.alert('Save the model first - the spec files live beside it.',
exitscript=True)
# --------------------------------------------------------------- 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:
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:
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)
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
# ------------------------------------------------------- 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.')
# ------------------------------------------- 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. This button always '
'writes `regex` (the mechanical reader). Nothing branches on the '
'value; it is shown so a spec written by one reader is not mistaken '
'for a spec written by another.')
@@ -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,174 @@
# -*- 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
from pyrevit import revit, script
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
from vibe import foldreport as fold
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 + '**')
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)
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:
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))
main()
@@ -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,322 @@
# -*- 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
from pyrevit import revit, script, HOST_APP
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:
report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex))
# ------------------------------------------------------------------- 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 - out-of-process tools 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)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks'
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')
# ------------------------------------------------------------------- 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),
designspec.plant_knowledge_path(),
'; {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 _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
return 'FAIL', ('no assembly carries a design number - either the '
'panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so')
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', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
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')
@@ -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,184 @@
# -*- 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'
from pyrevit import revit, script, forms
from vibe import foldreport as fold
from vibe import knowledge
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)
script.exit()
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if 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'))
main()
@@ -0,0 +1,30 @@
# 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
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,24 @@
title: "Panel\nLift"
# 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.
#
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,910 @@
# -*- coding: utf-8 -*-
#
# Handling check for one assembly: stripping, transportation, erection.
#
# What the selection says decides how this runs. One assembly preselected --
# or any member of one -- is checked straight away. Several preselected run
# as a background batch on exactly those pieces. Nothing preselected asks:
# one piece, or a batch across the assemblies the job's DECLARED SCOPE says
# are this engineer's (`vibe.scope`). The batch list is the TOOL choosing, so
# it is scoped and says what it left off; a preselected batch is the USER
# choosing, so it is never filtered.
#
# In the single-panel flow the piece is measured out of the model, you pick the
# inserts that carry each stage by clicking them ON the piece, you say where the
# report goes, and the calculation runs on CPython -- see vibe.liftexport for why
# it is not in here.
#
# The only thing it writes to the model is a temporary isolate of the piece, in
# whatever view you are already in, so that you are clicking inserts on the piece
# rather than hunting them in a view of the whole building. It writes that twice:
# on, and off again. Nothing else is changed -- including the inserts this report
# recommends moving.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Panel\nLift'
import io
import json
import os
from Autodesk.Revit.DB import (AssemblyInstance, ElementId,
OverrideGraphicSettings, Reference,
TemporaryViewMode)
from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType
from Autodesk.Revit.Exceptions import OperationCanceledException
from System.Collections.Generic import List
from pyrevit import revit, script, forms
from vibe import (batchrun, configmodel, knowledge, liftexport, ticketqc,
designspec, tx)
from vibe import scope as scopelib
from vibe.compat import as_text
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
def die(message, title='Panel Lift Check'):
"""Usage error, not a calculation refusal (Claude 08-28).
die() is ONLY for 'the user has not given us something yet' -- no
assembly selected, nothing isolated, nothing in scope to offer. A
refusal to CALCULATE (missing prestress / weight gate / no stages /
no Python / no concrete strength) must go through not_run(), which
leaves a row in the report saying what was not done and why. The
report must survive the dialog.
"""
forms.alert(message, title=title, exitscript=True)
def not_run(message, title='Panel Lift Check'):
"""'Could not be calculated' is a RESULT, not a failure of the piece
(Claude 08-28: exit code non-zero != the panel failed). Print a
NOT RUN row with the reason that tells the user what to do next,
then stop. The five-word discipline from Ticket QC applies here too:
the tool must be able to say what it did not do.
"""
output.print_md('# Panel Lift Check &mdash; NOT RUN')
output.print_md('**NOT RUN** &mdash; the check did not run for this piece.')
output.print_md('> {0}'.format(message))
script.exit()
# ------------------------------------------------- background batch engine
from System.Threading import Thread, ThreadStart
def _design_of(asm, groups):
"""The design key an assembly belongs to, or None if it is in none."""
for key, group in groups.items():
for member in group:
try:
if member.Id == asm.Id:
return key
except Exception:
pass
return None
def _run_batch(preselected=None):
"""Run the full check on many panels in the background.
Called with no argument, the user picks from the assemblies inside the
declared scope, and the page says how many were left off and of what
kind. Given a selection -- several assemblies highlighted before the
button was pressed -- those ARE the batch, whatever their marks: nobody
is asked which pieces they meant and nothing is dropped.
"""
know = knowledge.load(doc)
_summaries, sheets = ticketqc.collect_context(doc)
groups = ticketqc.assemblies_by_design(doc, know) or {}
if preselected:
seen = set()
items = []
for asm in preselected:
try:
asm_key = asm.Id.IntegerValue
except Exception:
asm_key = None
if asm_key is None:
try:
asm_key = asm.Id.Value
except Exception:
continue
if asm_key in seen:
continue
seen.add(asm_key)
items.append([str(asm.Name), asm, _design_of(asm, groups)])
items.sort(key=lambda r: (str(r[2]), r[0]))
else:
# Nobody preselected anything, so the TOOL is choosing what to offer
# -- and it offers what the job's DECLARED SCOPE says is this
# engineer's. What it left off the list is printed, by kind, rather
# than quietly missing. A PRESELECTED batch (the branch above) is
# never filtered: the selection is the request.
the_scope = scopelib.of(know)
items, left_alone = [], []
for design, asm_list in groups.items():
for asm in asm_list:
name = str(asm.Name)
(items if the_scope.includes(name)
else left_alone).append([name, asm, design])
items.sort(key=lambda r: (r[2], r[0]))
scope_line = the_scope.line([r[0] for r in left_alone],
total=len(items) + len(left_alone),
unit='piece')
if not items:
die('There is nothing to offer for a batch.\n\n{0}'
.format(scope_line))
names = [r[0] for r in items]
output.print_md('> ' + scope_line)
picked = forms.SelectFromList.show(
names, multiselect=True,
title='Batch - choose pieces (Ctrl/Shift to multi-select)')
if not picked:
script.exit()
picked_set = set(picked)
items = [r for r in items if r[0] in picked_set]
if not items:
script.exit()
chosen = items
start_dir = knowledge.get(know, 'handling.report_dir') or \
designspec.sub_dir(doc, 'handling')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
python_exe = liftexport.find_python(know)
if not python_exe:
not_run('CPython 3.9 or newer was not found.\n\nSet '
'handling.python_exe in the knowledge file to the full '
'path of python.exe.')
compute = []
total = len(chosen)
with forms.ProgressBar(title='Batch: measuring {0} panels...'.format(total),
cancellable=True) as pb:
for i, (name, asm, design) in enumerate(chosen):
if pb.cancelled:
break
pb.update_progress(i, total)
job, problems = liftexport.export(doc, asm, know, sheets,
design, batch=True)
if job is None:
continue
stages, _rows, stage_problems = liftexport.default_stages(job, know)
problems = list(problems or []) + list(stage_problems or [])
missing = [s['name'] for s in stages
if s.get('enabled', True) and not s.get('fc_psi')]
if problems or missing:
continue
job['stages'] = stages
job_path = os.path.join(start_dir or '',
'{0}.job.json'.format(name))
blob = json.dumps(liftexport.strip_private(job), indent=1,
sort_keys=True)
with io.open(job_path, 'w', encoding='utf-8') as fh:
fh.write(as_text(blob))
compute.append([name, job_path])
pb.update_progress(len(chosen), total)
if not compute:
not_run('Nothing was measured for batch (all refused or '
'cancelled).')
status_path = os.path.join(start_dir or '', 'batch-status.txt')
# The thread that runs the batch outlives this command. pyRevit tears
# the command module's scope down when the command returns, so a thread
# that looks ANY module global up from that scope -- `io`, `os`,
# `liftexport` -- raises UnboundNameException, on a NON-MAIN thread,
# which Revit cannot catch: the whole process dies (measured
# 2026-08-27). The worker therefore lives in vibe.batchrun, whose module
# scope is the session's, not the command's, and every name it needs is
# bound as a default parameter at definition time. `run_one` does the
# same for the two names that are this file's to pass on.
#
# recommend=False, and this is the whole reason a batch is usable.
#
# The position search is 93% of a run -- 12.6 s of a 13.6 s median piece,
# 49 s on the worst. Measured through this exact path on the 184-piece
# reference sweep: 38 minutes with the search on, 2.5 minutes without,
# and about half a minute without it and eight at a time. The verdicts
# are untouched -- 111 OK / 59 WARN / 13 FAIL / 1 refused either way --
# because every stage is still analysed and every check still runs. A
# batch is a triage pass: WHICH pieces need looking at. "Then where?" is
# a question about ONE piece, and the single-piece run answers it in
# thirteen seconds.
#
# The reports say so on the page. A batch of reports whose Recommended
# positions section had quietly vanished would read, 184 times over, as
# 184 pieces with nothing to recommend.
def run_one(name, job_path, out_path, lx=liftexport, px=python_exe):
lx.run(px, job_path, out_path, recommend=False)
# Several at once. Each job is already an isolated CPython subprocess
# writing its own files, so this is a scheduling change, not a numerical
# one -- and two cores are left alone so Revit stays usable while it runs.
at_once = batchrun.worker_count()
worker = batchrun.make_worker(compute, status_path, run_one,
workers=at_once)
Thread(ThreadStart(worker)).Start()
output.print_md('**Batch started in the background.**')
# The scope, on the page, every time. A batch of the pieces this engineer
# owns and a batch of everything in the model look identical once they
# are running.
_scope = scopelib.of(know)
_outside = [name for name, _asm, _d in chosen if not _scope.includes(name)]
output.print_md('> ' + _scope.line([], unit='piece'))
if _outside:
output.print_md('> ' + (_scope.annotation(_outside, unit='piece') or ''))
output.print_md('Panels were measured on the UI thread; each report is '
'now computed in the background ({0} CPython subprocesses '
'at a time, two cores left free), so Revit stays usable.'
.format(at_once))
# Said here as well as in every report. Somebody who starts a batch and
# then opens one of its reports looking for the move list has to find out
# why it is not there, and the place they are looking at the moment they
# wonder is this page.
output.print_md('> **Insert positions are not searched in a batch.** That '
'search is 93% of a run &mdash; it is what made a sweep of '
'this model take 38 minutes instead of half a minute. '
'Every stage is still '
'analysed and every check still runs, so the verdicts are '
'the whole answer to *which pieces need looking at*. Run a '
'piece on its own to be told **where** its inserts should '
'go; each report says so where the positions would be.')
output.print_md('Reports + job files go to:\n\n `{0}`'.format(start_dir))
output.print_md('Progress is written to:\n\n `{0}`'.format(status_path))
script.close()
# -------------------------------------------------- mode: batch / assembly
# The selection decides. One assembly preselected (any member of one
# counts) runs straight through as the single-panel check. Several
# preselected run as a background batch on exactly what is highlighted.
# Nothing preselected asks.
#
# Deliberately off the preselection rather than a list: the piece is the
# subject of the whole run, and picking it from several hundred assemblies
# is worse than clicking it in the model.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not 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)
assemblies = owners
if len(assemblies) > 1:
_run_batch(assemblies)
script.exit()
if not assemblies:
_modes = ['Batch (many pieces)', 'Assembly (one piece)']
_mode = forms.SelectFromList.show(_modes, multiselect=False,
title='Panel Lift Check')
if not _mode:
script.exit()
if _mode.startswith('Batch'):
_run_batch()
script.exit()
die('Select the assembly you want checked, then run this again.\n\n'
'Selecting any member of it works too.')
asm = assemblies[0]
# ------------------------------------------------------------------ read it
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
not_run(problem)
if not (knowledge.get(know, 'handling.lifter_groups', {}) or {}):
not_run('No handling.lifter_groups in the knowledge files.\n\n'
'The check needs to know which family is a face lifter and '
'which is an edge anchor. Add them to\n\n{0}\n\nand run again.'
.format(designspec.plant_knowledge_path()))
with forms.ProgressBar(title='Measuring {0}...'.format(asm.Name)) as pb:
pb.update_progress(0, 3)
_summaries, sheets = ticketqc.collect_context(doc)
design = None
for key, group in ticketqc.assemblies_by_design(doc, know).items():
if any(a.Id == asm.Id for a in group):
design = key
break
pb.update_progress(1, 3)
job, problems = liftexport.export(doc, asm, know, sheets, design)
pb.update_progress(3, 3)
if job is None:
not_run('\n\n'.join(problems) +
'\n\nFix the cause above and run again.')
panel = job['panel']
# Before anything is calculated: does the geometry we read weigh what the
# model says it does? A blockout read wrong is silent, and it makes the
# section modulus wrong in the direction that says a panel is fine.
ok, message, _numbers = liftexport.weight_check(job, know)
if not ok:
not_run(message + '\n\nFix the weight mismatch and run again.',
title='Panel Lift Check - {0}'.format(panel['mark']))
stages, _rows, stage_problems = liftexport.default_stages(job, know)
if not stages:
not_run('No handling.stages in the knowledge files, so there is nothing to '
'check the piece through.')
if not [s for s in stages if s.get('enabled', True)]:
# Some stages are configured and every one of them is switched off. That
# is a different fact from "no stages", and it belongs here rather than as
# a JobError out of the calculation, where it would arrive as a stderr
# line in a dialog about a job file the engineer never opened.
not_run('Every handling stage is switched off in the configuration '
'({0}), so there is nothing to check. Enable at least one stage '
'in Project Config.'
.format(', '.join(s['name'] for s in stages)))
if stage_problems:
not_run('\n\n'.join(stage_problems),
title='Panel Lift Check - plant dunnage setting')
# The audit used to refuse the run. It was guarding an automatic pick nobody
# could see; now every insert is clicked on the piece, so a bad default costs
# a few clicks rather than a wrong answer. It is still worth saying out loud,
# because it means the highlighted defaults are not to be trusted here.
audit = liftexport.stage_audit(stages)
# --------------------------------------------------------- confirm the picks
# The inserts for each stage are picked IN THE MODEL, one stage at a time.
#
# This used to be a checklist of rows. It was wrong twice over: a row is the
# tool's guess about which inserts share a hook, and the label named one family
# when the row held two -- which is how one edge anchor and two face lifters an
# inch apart read as three edge anchors, and a whole support line went missing.
#
# Nobody can audit a checkbox that says 5'-8". Everybody can audit three
# inserts lit up on the piece in front of them. The knowledge rules still
# choose the defaults, so the usual answer is still one click; they just no
# longer get the last word, and they no longer get it silently.
by_mark = dict((l['mark'], l) for l in panel['lifters'])
insert_ids = set(int(m) for m in by_mark)
class OnlyTheseInserts(ISelectionFilter):
"""Only the lifting inserts of this piece can be clicked.
Without it the panel itself, its rebar and its neighbours are all pickable,
and a stray click becomes a support line the piece does not have.
"""
def AllowElement(self, element):
try:
return ticketqc.eid(element) in insert_ids
except Exception:
return False
def AllowReference(self, reference, point):
return False
pick_filter = OnlyTheseInserts()
def elements_of(marks):
found = []
for mark in marks:
try:
el = doc.GetElement(ElementId(int(mark)))
except Exception:
el = None
if el is not None:
found.append(el)
return found
def zoom_state():
"""The active view's zoom, so it can be put back after the fit."""
try:
for uiview in uidoc.GetOpenUIViews():
if uiview.ViewId == uidoc.ActiveView.Id:
corners = uiview.GetZoomCorners()
return (uiview, corners[0], corners[1])
except Exception:
pass
return None
def id_int(element_id):
try:
return element_id.IntegerValue
except Exception:
return element_id.Value
def show_the_piece():
"""Isolate the piece in the view the user is already in, and see into it.
Their view, not one this picks for them and not a new one: the view on
screen is the view they chose to be in.
Isolating is not enough on its own. An insert is cast INSIDE the concrete,
so in any shaded view the panel is standing in front of every one of them
-- you can still click them, because the filter passes the click through
to the insert, but the highlight that says which one you got is painted
behind six inches of opaque concrete. The pick works and shows you
nothing. So everything that is not an insert goes 85% transparent, which
leaves the panel and its openings as an outline to place them against.
A view template can lock the display style -- wireframe is not available
to us here -- but per-element overrides go through it, which is why this
is done insert by insert rather than by changing how the view draws.
Those two things are all this button writes to the model, and it writes
them twice: on, and off again. Everything else here reads.
Returns the callable that puts the view back.
"""
view = uidoc.ActiveView
ids = List[ElementId]()
ids.Add(asm.Id)
others = []
for member in asm.GetMemberIds():
ids.Add(member)
if id_int(member) not in insert_ids:
others.append(member)
ghost = OverrideGraphicSettings()
ghost.SetSurfaceTransparency(85)
try:
with tx('Show assembly'):
view.IsolateElementsTemporary(ids)
for member in others:
try:
view.SetElementOverrides(member, ghost)
except Exception:
pass
except Exception:
die('This view cannot isolate the piece, so there is nothing to pick '
'on. Schedules and sheets have no geometry to hide.\n\nOpen a view '
'that shows {0} and run the check again.'.format(asm.Name))
zoom = zoom_state()
# ZoomToFit and not ShowElements: with everything else hidden the fit IS
# the piece, and ShowElements can raise a modal dialog when it disagrees
# about whether a view shows something.
if zoom is not None:
try:
zoom[0].ZoomToFit()
except Exception:
pass
def restore():
plain = OverrideGraphicSettings()
try:
with tx('Show assembly'):
for member in others:
try:
view.SetElementOverrides(member, plain)
except Exception:
pass
view.DisableTemporaryViewMode(
TemporaryViewMode.TemporaryHideIsolate)
except Exception:
pass
if zoom is not None:
try:
zoom[0].ZoomAndCenterRectangle(zoom[1], zoom[2])
except Exception:
pass
return restore
def pick_inserts(stage, preset):
"""Click the inserts for one stage. None if the user pressed Esc."""
pre = List[Reference]()
ids = List[ElementId]()
for el in elements_of(preset):
try:
pre.Add(Reference(el))
ids.Add(el.Id)
except Exception:
pass
# Said twice on purpose. PickObjects takes the pre-selected references and
# returns them, but does not reliably paint them, and a default the user
# cannot see is a default they cannot accept. Setting the selection first
# puts them on the screen in the ordinary blue.
try:
uidoc.Selection.SetElementIds(ids)
uidoc.RefreshActiveView()
except Exception:
pass
prompt = ('{0} of {1}: select the inserts that carry the piece, then '
'click Finish'.format(stage['name'].upper(), panel['mark']))
try:
try:
refs = uidoc.Selection.PickObjects(
ObjectType.Element, pick_filter, prompt, pre)
except TypeError: # older binding: no pre-selected overload
refs = uidoc.Selection.PickObjects(
ObjectType.Element, pick_filter, prompt)
except OperationCanceledException:
return None
picked = []
for ref in refs:
el = doc.GetElement(ref.ElementId)
if el is None:
continue
mark = str(ticketqc.eid(el))
if mark in by_mark and mark not in picked:
picked.append(mark)
return picked
def tally(marks):
"""Families counted, and the stations they stand at, in order."""
counts = {}
stations = []
for mark in sorted(marks, key=lambda m: by_mark[m]['x']):
lifter = by_mark[mark]
counts[lifter['family']] = counts.get(lifter['family'], 0) + 1
where = ticketqc.fmt_feet(lifter['x'])
if where not in stations:
stations.append(where)
return (', '.join('{0} x{1}'.format(f, c)
for f, c in sorted(counts.items())),
', '.join(stations))
put_it_back = show_the_piece()
support_note = ''
supported = [s for s in stages
if s.get('supports') and s.get('enabled', True)]
if supported:
support_note = ('\n\nNot picked, read from plant knowledge: ' +
'; '.join(
'{0} on dunnage at {1}'.format(
s['name'],
', '.join(ticketqc.fmt_feet(x)
for x in s['supports']))
for s in supported))
forms.alert(
'You will be asked to select the inserts for each stage in turn, on the '
'piece itself:\n\n {0}\n\nWhat the rules expect is highlighted '
'already. Ctrl-click adds, Shift-click removes, then Finish on the '
'options bar. Esc cancels the whole run.{1}{2}'
.format(' -> '.join(
s['name'] if s.get('enabled', True)
else '{0} (not analysed)'.format(s['name']) for s in stages),
'' if not audit else
'\n\nThe defaults need a look on this piece:\n\n ' +
'\n '.join(audit),
support_note),
title='{0} - inserts'.format(panel['mark']))
chosen = []
try:
for stage in stages:
# A stage switched off in the plant config is not analysed, so there
# is nothing to pick for it. It still travels into the job file, and
# panellift still prints it -- as the sentence "not performed", in
# its place among the stages that ran. Asking for inserts here would
# be asking the engineer to configure a check nobody is running.
if stage.get('enabled', True) is False:
chosen.append(stage)
continue
if stage.get('supports'):
chosen.append(stage)
continue
preset = list(stage['lifters'])
while True:
marks = pick_inserts(stage, preset)
if marks is None:
script.exit()
if len(marks) < 2:
forms.alert('{0} needs at least two inserts to be a pick, '
'and {1} were selected.'
.format(stage['name'], len(marks)),
title='Panel Lift Check')
preset = marks or preset
continue
if len(marks) < len(stage['lifters']):
families, stations = tally(marks)
# Fewer inserts than the rules expected is a real pick and
# often the right one -- a crane that cannot reach the top
# row, an insert cast over. It is worth one question because
# a support line dropped by accident is invisible in the
# result: the report is complete, the diagrams are drawn, and
# the piece was checked on a pick it does not have.
if not forms.alert(
'{0}: {1} inserts selected where the rules expected '
'{2}.\n\n {3}\n at {4}\n\nUse these?'
.format(stage['name'], len(marks),
len(stage['lifters']), families, stations),
title='{0} - {1}'.format(panel['mark'],
stage['name']),
ok=False, yes=True, no=True):
preset = marks
continue
stage['lifters'] = marks
chosen.append(stage)
break
finally:
# Esc out of a pick raises through here. Leaving a temporary isolate
# switched on in somebody's view because they changed their mind is not
# something they should have to work out how to undo.
if put_it_back is not None:
put_it_back()
job['stages'] = chosen
missing = [s['name'] for s in chosen
if s.get('enabled', True) and not s.get('fc_psi')]
if missing:
not_run('No concrete strength for: {0}.\n\nThe design summary for '
'{1} did not give an f\'c or f\'ci that these stages could '
'use.'.format(', '.join(missing), design or 'this piece'))
# ------------------------------------------------------------ where it goes
start_dir = knowledge.get(know, 'handling.report_dir') or \
designspec.sub_dir(doc, 'handling')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
out_path = forms.save_file(
file_ext='html', init_dir=start_dir or '',
default_name='{0}.html'.format(panel['mark']),
title='Save the handling report as')
if not out_path:
script.exit()
# ------------------------------------------------------------------- run it
python_exe = liftexport.find_python(know)
if not python_exe:
not_run('CPython 3.9 or newer was not found.\n\n'
'The calculation runs outside Revit so it can be tested '
'without one. Install Python, or set handling.python_exe in '
'the knowledge file to the full path of python.exe.')
# The job file is kept beside the report on purpose. It is the whole input to
# the calculation, so a number anybody disputes can be re-run, by hand, on a
# machine with no Revit on it.
job_path = os.path.splitext(out_path)[0] + '.job.json'
blob = json.dumps(liftexport.strip_private(job), indent=1, sort_keys=True)
with io.open(job_path, 'w', encoding='utf-8') as fh:
fh.write(as_text(blob))
# Whether this run is willing to spend the 13 seconds. ON by default and ON
# after any failure to read the file, exactly like expensive_geometry: an
# unreadable options file must never quietly take half the report away.
search_positions = True
try:
search_positions = bool(configmodel.load_run_options(
designspec.root_dir(doc))['recommend_positions'])
except Exception:
search_positions = True
# The position search is the long part and it can genuinely run for minutes on
# a piece with many movable rows. It runs on Revit's UI thread, so without a
# progress bar being ticked the window is simply frozen, which nobody can tell
# apart from a crash -- and a run that looks crashed gets killed.
#
# The title tells the truth about which of the two it is doing. A bar that says
# "searching insert positions" on a run that is not searching them is the same
# lie as a report section that quietly disappears.
with forms.ProgressBar(title='{0}: {1}... {{value}}s'.format(
panel['mark'],
'searching insert positions' if search_positions
else 'checking the piece (position search off)'),
cancellable=True) as pb:
cancelled = []
def tick(elapsed_ms):
if pb.cancelled:
cancelled.append(True)
pb.update_progress(elapsed_ms // 1000, max(60, elapsed_ms // 1000))
pb.update_progress(0, 60)
code, summary, stderr = liftexport.run(python_exe, job_path, out_path,
on_tick=tick,
recommend=search_positions)
if code == 2 or summary is None:
not_run('The calculation could not run.\n\n{0}\n\n'
'Job file kept at:\n{1}'.format(stderr or 'no error text',
job_path))
# exit 1 is a RESULT (a stage failed its check), not a crash -- it is
# shown as FAIL in the table below, which is 'calculated and failed',
# distinct from the NOT RUN above.
if knowledge.get(know, 'handling.open_report', True):
liftexport.open_file(out_path)
# -------------------------------------------------------------------- report
output.print_md('# {0} &mdash; handling'.format(panel['mark']))
# The scope, on the single-piece report too. This piece was SELECTED, so it
# was never a candidate for being set aside -- but if it is outside the
# declared scope the reader is told that the numbers rest on knowledge nobody
# validated for that kind of piece.
_scope = scopelib.of(know)
output.print_md('> ' + _scope.line([], unit='piece'))
if not _scope.includes(panel['mark']):
output.print_md('> ' + (_scope.annotation([panel['mark']], unit='piece')
or ''))
output.print_md('Design **{0}** &middot; {1} &times; {2} &times; {3}" '
'&middot; {4} lb'
.format(panel.get('design_number') or '-',
ticketqc.fmt_feet(panel['length_ft']),
ticketqc.fmt_feet(panel['width_ft']),
panel['thickness_in'],
'{0:,.0f}'.format(panel['weight_lb'] or 0)))
# A bare Windows path is not a URL: with backslashes and no scheme a
# browser will not navigate to it, so the link rendered but did nothing.
# The text stays the real path -- that is what the engineer copies -- and
# only the href gets the file:/// form.
_href = 'file:///' + out_path.replace('\\', '/')
output.print_md('**{0}** &nbsp; [{1}]({2})'.format(summary['verdict'],
out_path, _href))
# The verdict above is the answer for the stages that RAN. If one was
# switched off, saying only "OK" here claims the piece, and a reader who
# takes the button's one bold word and closes the window has been told the
# panel is fine when a whole handling condition was never looked at.
_not_run = summary.get('not_run') or []
if _not_run:
output.print_md(
'> **{0} of {1} stages were not analysed** &mdash; {2}. The verdict '
'above covers the stages that were.'
.format(len(_not_run), len(summary['stages']),
'; '.join('{0}{1}'.format(
s['name'], ' ({0})'.format(s['reason'])
if s.get('reason') else '') for s in _not_run)))
# WHICH END IS UP, when the ruled mechanism did not answer it.
#
# `knowledge.up_end_from_origin` is the project's flagship declared-coverage
# refusal, and `liftexport.erected_orientation` falls back to global elevation
# when it declines. The fallback is ALLOWED -- global elevation is the sound
# mechanism for a piece erected on end, which is the case the rule hands over
# -- but it may never be silent, or it is a guess wearing the refusal's
# credibility. The key is written only when the fallback answered, so this
# block never fires on a piece the rule settled.
_basis = summary.get('basis')
if _basis:
output.print_md(
'> **Which end is up came from a fallback, not the rule.** Read from '
'{0}. {1}<br>What it decides: {2}. Stripping and transportation are '
'analysed flat and are unchanged by it.'
.format(_basis.get('basis'), _basis.get('why') or '',
_basis.get('affects') or 'the erected stage'))
for problem in problems:
output.print_md('> {0}'.format(problem))
def _stage_row(s):
"""One row of the summary table. A stage that was not analysed gets
words across the numeric columns, not dashes: a dash in a column of
numbers is read as a number that came out small."""
if not s.get('analysed', True):
return [s['name'], '-', '-', 'not analysed', '-', '-', 'NOT RUN']
return [s['name'], '{0:.2f}'.format(s['multiplier']),
'{0:,.0f}'.format(s['fc_psi']),
'-' if s['peak_tension_psi'] is None
else '{0:,.0f}'.format(s['peak_tension_psi']),
'{0:,.0f}'.format(s['allowable_psi']),
'-' if s['ratio'] is None else '{0:.0%}'.format(s['ratio']),
s['verdict']]
output.print_table(
[_stage_row(s) for s in summary['stages']],
columns=['stage', 'mult', "f'c", 'peak psi', 'allowable', 'of allow',
'result'])
# What was actually picked, in writing. The numbers above mean nothing without
# it, and it is the one part of the run that came from a human.
#
# A stage carried on dunnage was not picked and has no 'lifters' key at all --
# it is dropped rather than emptied, because panellift refuses a stage that
# gives both a support line and an insert. So it gets its own row saying where
# it sat, rather than an empty one implying nobody chose anything.
output.print_md('## Inserts picked')
rows = []
for s in chosen:
if s.get('enabled', True) is False:
rows.append([s['name'], '-', 'not analysed - no pick was made', '-'])
elif s.get('supports'):
rows.append([s['name'], '-', 'on dunnage (plant knowledge)',
', '.join(ticketqc.fmt_feet(x) for x in s['supports'])])
else:
marks = s.get('lifters') or []
rows.append([s['name'], str(len(marks))] + list(tally(marks)))
output.print_table(rows, columns=['stage', 'n', 'inserts', 'at'])
rec = summary.get('recommendation') or {}
if rec.get('searched') is False:
# `searched` is written by panellift ONLY when the search was skipped, so
# `is False` is the whole test and a summary from a build that predates
# the flag reads as "it ran" -- which it did. Printed under the heading it
# would have filled, not dropped: a missing section reads as a piece with
# nothing to recommend, and this piece was never asked.
output.print_md('## Recommended insert positions')
output.print_md(
'**NOT COMPUTED** &mdash; the position search is switched off for '
'this run (`recommend_positions` in Project Config &rarr; run-time '
'options). Every stage above was checked on the inserts exactly as '
'drawn and the verdict stands; what is missing is only where they '
'should go instead. Switch it back on and run the piece again to get '
'it.')
elif rec.get('note') and not rec.get('moves'):
output.print_md('> {0}'.format(rec['note']))
elif rec.get('moves'):
output.print_md('## Recommended insert positions')
if rec.get('worth_moving'):
output.print_md(
'Moving them takes the governing stage from **{0:.0%}** of '
'allowable to **{1:.0%}**.'.format(rec['as_built_ratio'],
rec['recommended_ratio']))
else:
output.print_md('The inserts are already where the search would put '
'them.')
output.print_table(
[[m['mark'], m['family'],
ticketqc.fmt_feet(m['from_x_ft']), ticketqc.fmt_feet(m['from_y_ft']),
ticketqc.fmt_feet(m['to_x_ft']), ticketqc.fmt_feet(m['to_y_ft']),
'-' if abs(m['dx_in']) < 0.005 else '{0:+.2f}'.format(m['dx_in']),
'-' if abs(m['dy_in']) < 0.005 else '{0:+.2f}'.format(m['dy_in']),
'pinned' if m['pinned'] else ('move' if m['moved'] else '')]
for m in rec['moves']],
columns=['insert', 'family', 'x', 'y', 'to x', 'to y',
'dx in', 'dy in', ''])
for row in rec.get('rows') or []:
if row.get('note'):
output.print_md('- {0}: {1}'.format(
ticketqc.fmt_feet(row['to_x_ft']), row['note']))
output.print_md(
'> The recommendation is one position per insert, solved for every '
'stage at once. **x** is searched to minimise the governing '
'utilisation; **y** is geometry only &mdash; a beam model has no '
'transverse mechanics, so whether the piece hangs level is stated '
'from the layout and not calculated.')
for stage in summary['stages']:
bad = stage['findings']
if not bad:
continue
output.print_md('### {0}'.format(stage['name']))
for f in bad:
output.print_md('- **{0}** `{1}` &mdash; {2}'
.format(f['severity'], f['code'], f['message']))
output.print_md('---')
output.print_md('Report: `{0}` \nJob file: `{1}`'.format(out_path, job_path))
@@ -0,0 +1,11 @@
title: Reinforce
# 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. It is the only large button on this ribbon that does
# not break, and it fits inside the width the two-line ones already take.
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,425 @@
# -*- 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'
# 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
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 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."""
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.
# ------------------------------------------------------------ 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:
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:
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))
@@ -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,838 @@
# -*- 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 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):
forms.alert(message, title=TITLE, exitscript=True)
if doc is None:
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
# ------------------------------------------------------ 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:
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.')
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]})
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.')
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.')
@@ -0,0 +1,24 @@
title: "Ticket\nQC"
# 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.
#
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,742 @@
# -*- 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.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Ticket\nQC'
import os
from Autodesk.Revit.DB import AssemblyInstance, ElementId
from pyrevit import revit, script, forms
from vibe import ticketqc as Q
from vibe import configdump
from vibe import designspec, knowledge
from vibe import scope as scopelib
from vibe import ticketchecks as TC
from vibe import ticketvision as TV
from vibe import ticketpage as TP
from vibe import foldreport as fold
from vibe import compat
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
# ------------------------------------------------------------------ context
# The published checklist has to exist before anything else here is worth
# doing: every row this button ever prints comes off it. On a knowledge-less
# trial install (tools/make_dist.py ships no knowledge/catalogue.yaml) this
# is the ordinary, expected state -- refuse loudly and say so, rather than
# rendering a report with zero rows that would look exactly like a piece with
# nothing wrong on it. See vibe.ticketpage's CHECKLIST_UNAVAILABLE_REASON.
if TP.CHECKLIST_UNAVAILABLE_REASON:
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title='Ticket QC',
exitscript=True)
# The knowledge file has to come first: it holds the mapping from role to this
# office's parameter names, and without it nothing can even be grouped.
know = knowledge.load(doc)
# A project config that has gone missing means this run would use a
# configuration nobody chose, and look entirely normal doing it.
for problem in knowledge.config_problems(doc, know):
forms.alert(problem, title='Ticket QC', exitscript=True)
missing, not_applicable = knowledge.unmapped_roles(know)
if missing:
forms.alert(
'These parameter roles are not mapped in knowledge.yaml, so the '
'checks that need them cannot run:\n\n {0}\n\n'
'Edit the "parameters:" section of\n{1}\n\n'
'Run Self Check for the full picture.'
.format('\n '.join(missing), designspec.knowledge_path(doc)),
title='Ticket QC', exitscript=('panel.design_number' in missing))
# ------------------------------------------------------------- the selection
# The selection decides, exactly as Panel Lift Check does: assemblies picked
# straight through, a picked member resolves to its owning assembly, and
# nothing picked means a checklist of every assembly. The one deliberate
# difference from Panel Lift Check: there is no "batch or single" question
# here -- Ticket QC is always a batch of one or more pieces.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not 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)
assemblies = owners
specs = designspec.list_specs(doc) if doc.PathName else {}
summaries, _sheets = Q.collect_context(doc)
grouped = Q.assemblies_by_design(doc, know=know)
# ------------------------------------------------------------- the scope
#
# The DECLARED SCOPE, off the job knowledge. Two different things are done
# with it, and the difference is the whole point:
#
# * the CHECKLIST this button offers when nothing is preselected is the
# tool choosing what to look at, so it offers what the scope declares.
# What it left off the list is stated on the report, by kind.
# * a piece the USER preselected is judged whatever its prefix. The
# selection is the request, and dropping it would be the silent
# exclusion this whole tool exists to refuse. It is judged and the
# report says the verdict rests on knowledge nobody validated for that
# kind of piece.
the_scope = scopelib.of(know)
scope_line = None # what was left off the checklist
scope_annotation = None # what was judged outside the scope anyway
if not assemblies:
# Nothing preselected: a checklist of every assembly, usable at 238
# pieces -- mark, design number, and whether a spec or only a regex
# summary backs it (the information the old design dialog showed,
# moved onto the pieces).
items = []
for design, asm_list in sorted(grouped.items()):
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
if design in specs:
state = 'spec'
elif design in summaries:
state = 'regex fallback'
else:
state = 'NO spec/summary'
items.append((asm, '{0} - {1} - {2}'
.format(asm.AssemblyTypeName, design, state)))
items.sort(key=lambda r: r[1])
offered, left_alone = [], []
for row in items:
(offered if the_scope.includes(row[0].AssemblyTypeName)
else left_alone).append(row)
scope_line = the_scope.line(
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
unit='piece')
items = offered
if not items:
forms.alert('There is nothing to check.\n\n{0}'.format(scope_line),
title='Ticket QC', exitscript=True)
names = [r[1] for r in items]
picked = forms.SelectFromList.show(
names, multiselect=True, title='Ticket QC - choose pieces',
button_name='Check tickets')
if not picked:
script.exit()
picked_set = set(picked)
assemblies = [r[0] for r in items if r[1] in picked_set]
if not assemblies:
script.exit()
if not grouped and not assemblies:
forms.alert(
'No assembly carries a value for the "panel.design_number" role '
'(mapped to: {0}).\n\nEither the mapping is wrong or this model does '
'not group pieces that way.'
.format(Q.role_label(know, 'panel.design_number')), exitscript=True)
# --------------------------------------------------------------------- run
# Per-piece design intent (Phase 3): the picked pieces may span designs, so
# each assembly's design number is looked up on its own -- not under one
# design's summary. Progress is visible and cancellable: context is
# collected once (not per piece), each piece runs in turn, and the bar
# advances per piece so the user sees movement rather than a frozen window.
# The first piece of an untouched design pays Revit's one-time geometry
# generation, which can take a while -- the title says so.
context = Q.collect_context(doc)
results, missing = [], []
total = len(assemblies)
bar_title = 'Checking tickets ({0} pieces) - first of a design can be slow'
with forms.ProgressBar(title=bar_title.format(total),
cancellable=True) as pb:
for i, asm in enumerate(assemblies):
if pb.cancelled:
break
pb.update_progress(i, total)
batch, miss = Q.run_for_assemblies(
doc, [asm], specs=specs, know=know, context=context)
results.extend(batch)
missing.extend(miss)
pb.update_progress(total, total)
# ------------------------------------------- the escalation, when asked for
# Checks no comparison can settle (5.5) escalate to a small model reading one
# rendered view. Its answer is NEVER a pass and NEVER a fail: the checklist
# row stays as it was and a WARN says a person must look
# (docs/ticket-checklist.md section 8bis). Kept in `model_read`, never in
# `findings`, so nothing computed and nothing read can be mistaken for each
# other. OFF unless the run option says otherwise -- a model call spends
# money and leaves the building.
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
for r in results:
r['model_read'] = []
if _vision_on and results:
with forms.ProgressBar(title='Escalating to a model reading the drawing',
cancellable=True) as pb:
for i, r in enumerate(results):
if pb.cancelled:
break
pb.update_progress(i, len(results))
try:
r['model_read'] = TV.read_for_ticket(
r['ticket'], know, designspec.root_dir(doc))
except Exception as _ex:
r['model_read'] = [TV.not_run_row(
'AI-BARMARK', 'the drawing',
'the escalation itself failed ({0})'
.format(type(_ex).__name__))]
# ------------------------------------------------------------------ report
#
# THE CHECKLIST TABLE IS THE PAGE. Everything else is appendix.
#
# Owner, 2026-08-30, after reading a real run:
#
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
#
# So: the tables first and big; the design intent, the scope, the knowledge
# path, the tally and the caveats after them, folded. The fold rule is not
# this button's -- it lives in `vibe.foldreport` and it is the standing
# pattern for every report this tool prints: default folded, a section that
# carries a problem opens itself, and a folded header still says in one line
# what is inside it. The reader's contract is that if nothing below the
# tables is open, nothing below the tables needs him.
#
# WHY THE HTML IS HAND-WRITTEN AND LOOKS DATED
# ---------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine
# in WinForms, in IE7 document mode unless the host sets
# FEATURE_BROWSER_EMULATION, which pyRevit does not. `<details>` /
# `<summary>` DO NOT EXIST here 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
# and works in every mode this control can be in.
#
# The checklist table itself uses NO script and NO HTML5 -- a plain <table>
# with inline colours. If the toggle is dead, the product is still on the
# page, in full, in colour. That is the whole point of putting it outside
# the fold machinery.
#
# WHAT THE COLOURS SAY (vibe.ticketpage.state_color owns the grouping):
# OK green
# WARN, FOR ENGINEER yellow
# NOT RUN, FAIL RED -- both of them, deliberately. An unrun check
# tells you nothing about the piece, so it must never
# look calmer than a failed one. Silence is not a pass.
# Muting: what this job has DECLARED it does not want asked, with a reason
# (`checks.muted` in the job knowledge, edited on the Project Config screen).
# It filters ROWS ONLY. `r['findings']` is untouched from here to the JSON
# writer and to the clean/failing/warning tally, so a muted check that
# failed still fails the ticket and still lands on disk.
# THE MUTES, RESOLVED TO MASTER IDS AS THEY ARE READ.
#
# Owner, 2026-08-31: "以后引用需要前面加 plant-member。" A mute may be written
# as a bare master id (`3.3` -- every existing one is) or qualified against a
# plant's own board (`sample_plant-wall_panel 3.3`). Both end up keyed by the
# MASTER id, which is the whole point: when the plant renumbers its board
# tomorrow, the mute follows the QUESTION and not the POSITION. A ruling can
# no longer be orphaned by a renumbering.
_plant_id = knowledge.plant_id(doc)
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
plant=_plant_id)
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
# Applicability: what a KNOWLEDGE LAYER declares is not a question about this
# kind of piece, so the check was never run (`checks.not_applicable`). It is
# not muting and it is not scope -- see the block over
# `ticketpage.apply_applicability` for where each of the five states sits.
#
# WHICH LAYER DECLARED IT is computed rather than typed. `declared_by` in the
# entry says who RULED; this says which FILE the tool read it from, which is
# the file somebody has to edit to change it. It is resolved once per press,
# not per row -- it re-reads the layer files.
try:
_layers = configdump.layer_values(doc)
except Exception:
_layers = []
def _declaring_layer(item, condition):
if not _layers or not condition:
return None
# KEYS, NOT A DOTTED PATH: a checklist row number contains a dot.
for branch in ('not_applicable', 'only_applies_to'):
found = knowledge.declaring_layer(
_layers, ['checks', branch, item, condition])
if found:
return found
return None
for r in results:
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
# A model read never sets a status. It only marks the row it looked at,
# so a reader following that row knows to read the quarantined section
# below rather than mistaking silence for a verdict.
_rows = TV.annotate_rows(_rows, r.get('model_read'))
# THE SAME RESOLUTION `run_all` USED. Two independent resolutions of
# "does this row apply" is how a page comes to describe a run that did
# not happen, so both sides call one function.
_decisions, _classification, _member, _profile = \
TC.applicability_decisions_for(r['ticket'], know)
for _num in _decisions:
_d = _decisions[_num]
if _d.get('not_applicable'):
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
r['applicability_notes'] = _na_notes
r['applicability_problems'] = TP.applicability_problems(_decisions)
r['classification'] = _classification
r['member'] = _member
r['profile'] = _profile
r['off_board'] = TP.off_board(_decisions)
_kept, _muted_notes = TP.apply_muting(_rows, muted)
r['rows'] = _kept
# The same rows BEFORE muting. Nothing on the page reads this; it is what
# lets `qc-run.json` record the state a muted row really reported as a
# STATE rather than as a sentence buried in an English note. A grey row
# whose original answer a machine cannot read back has thrown the answer
# away, which is the thing muting is specifically not allowed to do.
r['rows_unmuted'] = _rows
r['muted_notes'] = _muted_notes
# LAST, AND IT ONLY REORDERS AND LABELS. The stable master id stays at
# `row[0]` -- `checklist_details`, the note folds, the JSON and every
# rule key on it -- and the plant's own number rides at `row[5]`.
r['rows'] = TP.profile_view(r['rows'], _profile)
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
r['details'] = TP.checklist_details(r['findings'])
r['counts'] = TP.checklist_tally(r['rows'])
def _loudness(r):
"""(fails, not-runs, warns, for-engineer) -- how much this ticket wants."""
c = r['counts']
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
c.get('FOR ENGINEER', 0))
loud, all_green = [], []
for r in results:
(loud if any(_loudness(r)) else all_green).append(r)
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
-_loudness(r)[2], r['ticket']['mark']))
clean, failed, warned = [], [], []
tally = {}
for r in results:
bad = [f for f in r['findings'] if f.severity != 'OK']
for f in bad:
tally[f.code] = tally.get(f.code, 0) + 1
if not bad:
clean.append(r)
elif any(f.severity == 'FAIL' for f in bad):
failed.append(r)
else:
warned.append(r)
output.add_style(TP.CHECKLIST_STYLE)
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
# ---- the headline, then the tables. Nothing folded above this line.
output.print_md('# Shop ticket QC')
output.print_md('Model **{0}** &middot; {1} ticket(s) across {2} design '
'number(s) &middot; {3} clean &middot; {4} failing '
'&middot; {5} warning only'
.format(doc.Title, len(results),
len(set([r['design'] for r in results])),
len(clean), len(failed), len(warned)))
output.print_html(TP.checklist_key_html())
for r in loud:
t = r['ticket']
head = '**{0}** [{1}] {2} x {3} &nbsp;&mdash;&nbsp; {4}'.format(
t['mark'], r['design'],
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
TP.checklist_headline(r['counts']))
if t.get('sheet') is not None:
head += ' &nbsp; ' + output.linkify(t['sheet'].Id, 'open ticket')
if t.get('panel_id'):
head += ' &nbsp; ' + output.linkify(ElementId(t['panel_id']), 'panel')
output.print_md(head)
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
# A ticket whose every row is green needs no table -- there is nothing on it
# to read. It gets a line, and the line says the table was all green rather
# than that the ticket was "clean", which is a different and weaker claim.
if all_green:
# "green" is a claim, so it is only made when it is true. A piece whose
# quiet rows include one a knowledge layer switched off has not had every
# question answered, and saying so here is cheaper than a reader finding
# out from the section below.
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
for r in all_green])
output.print_md('**{0} ticket(s) with nothing to read on the checklist:** '
'{1}{2}'
.format(len(all_green),
', '.join([r['ticket']['mark']
for r in all_green]),
(' &mdash; and {0} row(s) across them were NOT '
'ASKED because a knowledge layer declares they '
'do not apply; see below'.format(_quiet))
if _quiet else ' &mdash; every row green'))
# ------------------------------------------------- everything else, folded
#
# Built as `foldreport.Section`s so the same content renders as HTML here and
# as plain text into the file below -- one source, so the page and the file
# cannot say different things.
sections = []
def _section(key, title, summary, blocks, problem=None):
if not blocks:
return
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
problem=problem))
# -- pieces that could not be checked. Always a problem when it is not empty:
# 'could not be checked' is a result, not an absence.
if missing:
_section('missing', 'Pieces that could not be checked',
'{0} piece(s)'.format(len(missing)),
[(fold.MD, '- **{0}** (id {1}): {2}'
.format(m.get('mark', '?'), m.get('id', '?'),
m.get('why', 'unknown')))
for m in missing],
problem='{0} piece(s) were not judged at all'.format(len(missing)))
# -- weaker readings. A caveat that actually bites, so it opens itself.
regex_designs = sorted(set([r['design'] for r in results
if r['spec_source'] == 'regex']))
if regex_designs:
_section('regex', 'Designs read with regexes, not an extracted spec',
', '.join(regex_designs),
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
'read with regexes instead. That reading is weaker '
'-- run **Extract Design** on them.'
.format(', '.join(regex_designs)))],
problem='{0} design(s) are being judged against a weaker '
'reading'.format(len(regex_designs)))
# -- WHICH PROFILE RAN, and what it switched off.
#
# Required once per run, per piece, and as loud as the scope line: a
# mistaken applicability rule is a safety check disabled in silence, and the
# only thing standing between that and a signed report is this section. So it
# names the classification AND the rows, and it opens itself whenever
# anything was switched off or any declaration was refused -- unlike muting,
# which folds when it is tidy, because a mute leaves the check running.
_na_notes, _na_problems, _profile_lines = [], [], []
_off_board = []
_seen_na = set()
for r in results:
# WHICH BOARD, THEN WHICH KIND OF PIECE. The profile line says what the
# numbers in the table even mean; the classification line says how the
# piece was placed within it. A renumbered table with neither has shown
# the reader a checklist without telling him whose.
_profile_lines.append(TP.profile_line(
r['ticket'].get('mark') or '(no mark)', _plant_id,
r.get('member'), r.get('profile'), r.get('off_board')))
for _num in (r.get('off_board') or []):
if _num not in _off_board:
_off_board.append(_num)
_profile_lines.append(TP.classification_line(
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
r.get('applicability_notes')))
for note in (r.get('applicability_notes') or []):
if note[0] not in _seen_na:
_seen_na.add(note[0])
_na_notes.append(note)
for problem in (r.get('applicability_problems') or []):
if problem not in _na_problems:
_na_problems.append(problem)
_pp = (r.get('profile') or {}).get('problem')
if _pp and ('profile', _pp) not in _na_problems:
_na_problems.append(('profile', _pp))
for _written, _why in _mute_problems:
_entry = ('mute {0!r}'.format(_written), _why)
if _entry not in _na_problems:
_na_problems.append(_entry)
_na_blocks = [(fold.MD, line) for line in _profile_lines]
if _na_notes:
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
_na_blocks.extend([(fold.MD, line)
for line in TP.applicability_lines(_na_notes)])
if _na_problems:
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
'rows RAN:**'))
_na_blocks.extend([(fold.MD, '**{0}** &mdash; {1}'.format(num, text))
for (num, text) in _na_problems])
_na_blocks.append(
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
'is not muting (a muted check runs and is repainted grey), not '
'scope (which sets aside a whole piece), and not dormancy (a '
'check whose enabling knowledge is unset). It is a knowledge '
'layer declaring that this question is not about this kind of '
'piece. A row that FAILED is never switched off -- the '
'declaration is declined and says so. A row scoped to a panel '
'type, on a piece nothing could classify, is NOT RUN in red, '
'never quiet: nobody could ask it._'))
_section('applicability', 'Which checklist ran on each piece, and what it '
'did not ask',
'{0} row(s) not asked; {1} declaration(s) refused'
.format(len(_na_notes), len(_na_problems)),
_na_blocks,
problem=TP.applicability_problem(_na_notes, _na_problems))
# -- muted on this job. Folded when the muting is tidy; open when it is not.
_all_muted = []
_seen_muted = set()
for r in results:
for note in r['muted_notes']:
if note[0] not in _seen_muted:
_seen_muted.add(note[0])
_all_muted.append(note)
if _all_muted:
_section('muted', 'Checklist items muted on this job',
'{0} item(s): {1}'.format(
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
[(fold.MD, '_Muting does not remove a row -- it stays on the '
'table, repainted grey as MUTED, with this note '
'saying what the check really reported. The check '
'still runs every time; only the presentation '
'changes. It never removes a finding: everything '
'below still reaches the machine-readable JSON '
'(`qc-run.json`, written every press) and still '
'counts in the header line, and a muted item that '
'FAILED keeps its own red on the table -- muting '
'never softens a failure._')],
problem=TP.muting_problem(_all_muted))
# -- the issue tally. Routine; folded.
if tally:
_section('tally', 'Issues by code',
'{0} code(s) across {1} ticket(s)'
.format(len(tally), sum(tally.values())),
[(fold.TABLE, ['issue', 'tickets'],
[[c, str(n)] for c, n in
sorted(tally.items(), key=lambda kv: -kv[1])])])
# -- what each design is being held to. Configuration: folded, by the rule.
seen, intent_rows = [], []
for r in results:
intent = r['intent']
if intent['name'] in seen:
continue
seen.append(intent['name'])
env = intent.get('envelope') or {}
def bound(key, _env=env):
return _env[key][1] if key in _env else '-'
intent_rows.append([
intent['name'], r['spec_source'],
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
Q._fmt_num(intent.get('pcf')),
Q.strand_signature(intent.get('strands') or []),
intent.get('wwf_mesh') or '-',
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
])
_section('intent', 'Design intent being enforced',
'{0} design(s), from {1}'.format(
len(intent_rows),
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
[(fold.TABLE,
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
'length', 'width'], intent_rows)])
# -- scope and knowledge. The context every report carries; folded unless
# the scope actually removed or annotated something.
if scope_annotation is None:
scope_annotation = the_scope.annotation(
[str(a.AssemblyTypeName) for a in assemblies
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
_scope_blocks = [(fold.MD, '> ' + (scope_line or
the_scope.line([], unit='piece')))]
if scope_annotation:
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
for _note in the_scope.declaration_lines():
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
if doc.PathName:
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
.format(designspec.knowledge_path(doc))))
_section('scope', 'Scope and knowledge',
'what this run was and was not asked to judge',
_scope_blocks,
problem=(scope_annotation or None))
# -- lifting/handling provenance. Only when HANDLE-* actually fired.
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
_section('handling', 'How the lifting/handling result was obtained',
'`HANDLE-*` rows on this run',
[(fold.MD,
'A current **Panel Lift Check** or sweep result is reused when '
'one is on file; otherwise a default-stage check runs on the '
'spot, rate limited so this does not hang on a large design. '
'`HANDLE-STALE` means a saved result no longer matches the '
'piece and was not used. A result not tagged "reviewed Panel '
'Lift Check" is good enough to find a problem, not good enough '
'to sign -- see docs/lift-check.md.')])
# ------------------------------- read by a model, quarantined from the above
# Its own section, its own columns, and a column that names what read it. A
# computed verdict and a model-read one fail differently -- a wrong computed
# answer is a bug, a wrong read is a hallucination -- so a reader has to be
# able to tell them apart at a glance. They are never in the same table, and
# this section opens itself: a model read is never a verdict, so somebody has
# to look at it.
_read = [(r['ticket']['mark'], f)
for r in results for f in (r.get('model_read') or [])]
if _read:
_head, _read_rows = TV.report_block(_read)
_section('modelread', 'Read by a model (never a verdict)',
'{0} row(s) a person must judge'.format(len(_read_rows)),
[(fold.MD, _head),
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
problem='a model was asked to look at {0} thing(s); nothing '
'here is a pass or a fail'.format(len(_read_rows)))
elif _vision_on:
output.print_md('> The vision escalation was on and produced nothing, '
'which should not happen - every escalation reports a '
'reason even when it does not run.')
# ---- THE MACHINE-READABLE COPY. Written on EVERY press, escalation or not.
# The same separation as the page, on disk, for anything that reads the run
# programmatically: two keys, never one list, and `computed` on every entry
# so even a consumer that merges them cannot lose the difference. When no
# model was consulted `model_read` is an empty list -- which is itself the
# answer to "was one?", and is why this no longer waits for a vision row:
# the muted section promises every run's findings reach this file, and
# vision defaults off.
#
# AND THE DISPOSITION, added 2026-08-31. Until then this file held findings
# and NOTHING ELSE -- no checklist rows, no mutes, no `run_all` log -- so a
# consumer reading it could not tell a row that PASSED from a row NOBODY
# ASKED. Both were an absence of findings. That is this project's cardinal
# failure reproduced inside its own output, and `tickets` below is the fix:
# every checklist row of every piece, with its state and, where it produced
# no verdict, its reason verbatim. See the block above `qc_json`.
#
# NOTHING HERE CHANGES WHAT A HUMAN SEES. The page is already printed by this
# point and `last-ticketqc.txt` below is written from the same `r['rows']`
# this reads; `qc_json` builds a new document and never touches a row, a
# finding or a note.
#
# NOTE ON MUTING: this is built from `r['findings']`, which muting never
# touches. A muted checklist row is a rendering decision; the finding
# behind it still reaches this file -- and now the ROW does too, in grey,
# saying what the check really reported.
try:
_doc = TV.qc_json([f for r in results for f in r['findings']],
[f for r in results
for f in (r.get('model_read') or [])],
tickets=results,
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
_path = TV.write_run_json(designspec.root_dir(doc), _doc)
output.print_md('_Machine-readable: `{0}`_'.format(_path))
except Exception as _ex:
output.print_md('_(the JSON copy could not be written: {0})_'
.format(type(_ex).__name__))
# ---- THE EVIDENCE FILE. Migration step 1, docs/architecture-migration.md.
#
# Everything this press MEASURED, as a versioned JSON document with no live
# Revit object in it, beside the model at `_ticketqc/evidence/run-<stamp>.json`.
# One file per press, never one per piece.
#
# NOTHING READS IT. Not this button, not `ticketchecks`, not a runner -- the
# reader is step 2. That is why this can ship on its own: a file nobody reads
# cannot change what the press above reported. No judgement moved, `read_ticket`
# is untouched, and `Q.ticket_to_json` builds a new dict rather than editing
# the ticket the checks were run against.
#
# It carries no findings, deliberately. `qc-run.json` above owns the verdicts;
# this file owns the inputs, and keeping the two apart is what will let step 2
# re-judge one against the other.
#
# Wrapped exactly as the JSON copy above is: a failure here prints a line and
# loses nothing. The report is already computed by this point.
try:
_ev_doc = Q.run_evidence(results, doc=doc)
_ev_path = Q.write_evidence(
designspec.sub_dir(doc, Q.EVIDENCE_FOLDER), _ev_doc)
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
.format(_ev_path))
except Exception as _ex:
output.print_md('_(the evidence file could not be written: {0})_'
.format(type(_ex).__name__))
# ---- FOLDED IS NOT GONE. The whole page, nothing folded, every checklist
# table in full text with long notes wrapped rather than cut, written to a
# file every press. A fold that will not open then costs a file open, never
# a lost sentence.
full_path = None
try:
full_path = os.path.join(designspec.root_dir(doc), 'last-ticketqc.txt')
_lines = ['SHOP TICKET QC -- ' + str(doc.Title), '']
for r in loud + all_green:
_t = r['ticket']
_lines.append('')
_lines.append('{0} [{1}] {2}'.format(
_t['mark'], r['design'],
TP.checklist_headline(r['counts']).replace('&middot;', '-')))
_lines.append('-' * 78)
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
_lines.append('')
_lines.append(fold.text(sections, show_all=True))
payload = compat.as_text('\n'.join(_lines))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
except Exception as _exc:
full_path = None
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))
if sections:
opened = fold.sections_with_problems(sections)
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(sections)))
else:
output.print_md('**Nothing below the tables needs you.** Every section '
'is folded; click a header to open one.')
if full_path:
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
.format(full_path))
output.print_html(fold.html(sections))
@@ -0,0 +1,19 @@
# 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.
#
# THE FOUR FINISHED TOOLS, all as large icons, in the owner's own order
# (2026-08-30: "reinforce, LIFT, ticketqc, snapshot 四个是大图标, 放在一起").
# 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:
- ReinforceSuggest
- PanelLiftCheck
- TicketQC
- SnapshotClose
@@ -0,0 +1,19 @@
# 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.
#
# Three 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.
#
# A panel not listed below is not created at all
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
layout:
- Tools
- Setup
- Design
+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',
]
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""Run a batch of job files on a background thread without ever throwing.
WHY THIS MODULE IS THE ONLY PLACE THIS CODE MAY LIVE.
A pyRevit button command can return while a thread it started is still
running, and pyRevit then tears down the command script's module scope.
A name looked up from that scope -- a module-level import such as `io`,
`os` or `liftexport` -- is gone. The lookup misses, and the miss happens
on a NON-MAIN thread, and Revit has no way to catch an exception on a
non-main thread: an unhandled one ends the whole process (measured
2026-08-27: UnboundNameException in a Panel Lift Check batch thread,
~7 s after the command returned; the event log named IronPython
lookup-of-global as the killer).
A thread cannot be made safe by wrapping each call -- the miss happens at
name lookup, before any body runs. It is only safe with NO global name
lookup at all. Everything this worker needs arrives as a default
parameter, bound at definition time (when the scope it was defined in is
still alive), and the whole body is wrapped so nothing can escape. A lost
log line is acceptable; a dead Revit is not.
ONE PRINCIPLE, stated because it was learned the hard way: a function
whose contract is "never raises" must keep EVERY statement inside the
protection -- including the first one. `list(compute)` can raise before
any try; so can unpacking a row; so can `os.path.splitext`. The outer
try/except below exists for exactly those, and the tests feed the worker
malformed inputs to prove it stays silent.
The free functions below are what the tests exercise: build the same
callable, inject a job that raises and a status path that cannot be
written, and assert it still does not raise. IronPython 2.7 is assumed:
no f-strings, no pathlib.
MANY AT ONCE, AND WHY THAT IS SAFE HERE.
Each job is already an isolated CPython subprocess that reads one job
file and writes its own report, log and summary beside it. Nothing is
shared between two of them -- not a file, not a number, not a random
seed -- so running eight at once is a scheduling change and not a
numerical one: the reports come out identical, only sooner. Measured
2026-08-30 on the 184-piece reference sweep: 42 minutes serial, 8.6
minutes on eight workers.
What running many at once DOES touch is coordination, and coordination
is exactly the thing this module is paranoid about. So the extra
machinery obeys the same rule as everything else here: the queue, the
lock and the thread factory are all created and bound in `make_worker`,
on the UI thread, while the defining scope is alive; the functions the
threads run are defined there too, with every name they need bound as a
default parameter. A worker thread looks nothing up. `pump` carries its
own outer try, because an exception escaping THERE is the same dead
Revit as one escaping `worker` -- there are now N threads that can kill
the process instead of one.
"""
# Most jobs a batch will compute at the same time, whatever the machine has.
# Eight subprocesses is already more than the sweep needs to fit inside a
# coffee break, and the number exists to stop a 64-core workstation from
# starting 62 of them.
MAX_WORKERS = 8
# Cores left for everything else. Revit is open, the user is still working in
# it, and a batch that makes the model unusable is a batch nobody starts.
SPARE_CORES = 2
def worker_count(os=None, cap=None, spare=None):
"""How many jobs to compute at once: min(cores - 2, 8), and never 0.
Called on the UI thread, before any thread exists, and the answer is then
bound as a default parameter -- so a background thread never asks this
question and never touches `os.environ`.
NUMBER_OF_PROCESSORS is read before `os.cpu_count`, because it is the one
that exists on IronPython 2.7 (Windows sets it for every process) and
`cpu_count` does not arrive until CPython 3.4. A machine that answers
neither gets 1, which is exactly today's behaviour.
"""
import os as _os
os = os or _os
cap = MAX_WORKERS if cap is None else cap
spare = SPARE_CORES if spare is None else spare
cores = 0
try:
cores = int(os.environ.get('NUMBER_OF_PROCESSORS') or 0)
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
try:
cores = int(os.cpu_count() or 0) # CPython 3.4+ only
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
return 1
count = cores - spare
if count < 1:
count = 1
if count > cap:
count = cap
return count
def make_worker(compute, status_path, run_job, io=None, os=None,
workers=None, threading=None):
"""A zero-argument callable that runs a batch and never raises.
compute -- list of [name, job_path] rows, as the batch engine in
Panel Lift Check builds them. The list is copied, so the
caller may reuse its own after handing it over.
status_path -- where progress lines are appended.
run_job -- callable(name, job_path, out_path) that computes one
report. It may raise; the worker records an ERROR line
and continues with the next job.
workers -- how many jobs to compute at once. None asks the machine
(see `worker_count`). 1 is the old behaviour exactly:
one job after another, on the calling thread, no extra
thread created at all.
io, os,
threading -- injected for tests (a fake io whose open raises, say).
Defaults to the real modules, bound NOW -- at def time,
while this module's scope is alive, which is the whole
point: a background thread must never look anything up
from a scope that may have been torn down.
"""
import io as _io
import os as _os
import threading as _threading
io = io or _io
os = os or _os
threading = threading or _threading
if workers is None:
workers = worker_count(os)
# One lock for the queue AND the status file. They are never held at the
# same time -- take() releases before run_job starts, note() takes it
# after -- so one lock cannot deadlock against itself, and two locks
# would be two things to reason about for no gain.
try:
lock = threading.Lock()
except Exception: # pylint: disable=broad-except
lock = None
workers = 1 # no lock, no sharing: run it the old way
def _grab(lock=lock):
try:
if lock is not None:
lock.acquire()
except Exception: # pylint: disable=broad-except
pass
def _drop(lock=lock):
try:
if lock is not None:
lock.release()
except Exception: # pylint: disable=broad-except
pass
def note(line, io=io, status_path=status_path, mode='a',
grab=_grab, drop=_drop):
"""One line into the status file. Never raises, ever.
acquire, write and release are three separate guards on purpose: a
release() on a lock that was never acquired raises, so it cannot sit
in a `finally` that runs after a failed acquire.
"""
grab()
try:
with io.open(status_path, mode, encoding='utf-8') as fh:
fh.write(line + '\n')
except Exception: # pylint: disable=broad-except
pass # cannot write the log -> stay silent
drop()
def take(queue, grab=_grab, drop=_drop):
"""The next [name, job_path], or None when the batch is empty."""
grab()
row = None
try:
if queue:
row = queue.pop(0)
except Exception: # pylint: disable=broad-except
row = None
drop()
return row
def pump(queue, take=take, note=note, run_job=run_job, os=os):
"""Drain the shared queue. Runs ON a worker thread, so it never
raises: an exception here ends the process the same way one in
`worker` does, and there are now several of these."""
try:
while True:
row = take(queue)
if row is None:
return
try:
name, job_path = row
out_path = os.path.splitext(job_path)[0] + '.html'
run_job(name, job_path, out_path)
line = 'DONE {0}'.format(name)
except Exception as exc: # pylint: disable=broad-except
# A row that would not even unpack still gets a line.
# Dropping it -- which the serial version did, by letting
# the outer guard swallow the whole rest of the batch --
# is a piece that silently never ran.
try:
line = 'ERROR {0}: {1}'.format(row[0], exc)
except Exception: # pylint: disable=broad-except
line = 'ERROR (unreadable row): {0}'.format(exc)
note(line)
except Exception: # pylint: disable=broad-except
pass
def worker(compute=compute, note=note, pump=pump, threading=threading,
workers=workers):
try:
queue = list(compute)
# The count is on the header line because a batch that is running
# eight at a time and one that is running one look identical from
# outside, and they are 42 minutes apart.
note('BATCH RUNNING ({0} panels), {1} at a time'
.format(len(queue), workers), mode='w')
helpers = []
# A counted `while` rather than `range(...)`: one fewer name this
# thread has to resolve through a scope that may be gone. The
# calling thread is worker number one, so it starts workers - 1.
started = 1
while started < workers:
started += 1
try:
thread = threading.Thread(target=pump, args=(queue,))
try:
thread.daemon = False
except Exception: # pylint: disable=broad-except
pass
thread.start()
helpers.append(thread)
except Exception: # pylint: disable=broad-except
# Could not start another thread. The ones already going
# plus this one still finish the batch, just slower.
break
pump(queue) # this thread pulls its share too
for thread in helpers:
try:
thread.join()
except Exception: # pylint: disable=broad-except
pass
note('BATCH FINISHED')
except Exception: # pylint: disable=broad-except
# "Never raises" includes the first statement. list(compute) can
# raise before any of the inner guards run; this one catches it.
pass
return worker
+415
View File
@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
# sample_job. The iron rule is: read-only inside live, never write.
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
The rule this enforces is the owner's, and it was a memory until now: the
LIVE model is his, and nothing here may read it, run in it, or touch it. The
sandbox -- a timestamped detached copy, opened in a Revit session of its own
-- is the only session an agent is allowed to drive.
WHY IT HAS TO BE MECHANICAL
----------------------------
pyRevit's routes server picks its port by walking UP from the configured one
past every port a running Revit has already taken
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
session I meant"; it means "whichever Revit started first". Restart the
sandbox while the live session is up and the live session is now on 48884 and
answering. Every request an agent then sends -- `status`, `exec`, a whole
sweep -- lands in the model it must never touch, and every one of them looks
completely normal.
Nothing about that is detectable from the client side: a `status` that says
`ok` and names the right title is exactly what a correct sandbox says (the
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
note). So the refusal has to be made where the document actually is.
WHY A TITLE ALONE CANNOT DECIDE IT
-----------------------------------
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
leaves the model's own name -- and therefore `doc.Title` -- constant, because
the job folder is keyed on the title and a title that moves daily moves the
job folder with it. The consequence lands here: the sandbox copy's title is
IDENTICAL to the live model's. A denylist of titles would refuse both, which
is a bridge that refuses everything.
So the title is the ACCUSATION and the path is the ALIBI. A document whose
title is on the denylist is refused unless it can prove it is a copy:
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
plus anything in `bridge.allow_roots`), or
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
Revit process's own environment -- which only the pipeline that
launched the sandbox can have set.
The live cloud model has no local `PathName` at all (it is
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
so it can produce neither alibi and is refused. That is the intended outcome
and it is not an accident of string matching.
WHAT IS NOT HERE, DELIBERATELY
-------------------------------
No file reading, no `knowledge.load`, no environment lookups except the two
helpers at the bottom that are handed their own input. Everything is a pure
function of arguments so `startup.py` can be tested at the function level
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
around by parsing source text, which is a worse test than this one.
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
------------------------------------------------------------
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
cloud model outright. Hours later the owner reversed it, in his own words:
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
rule is: read-only inside live, never write." -- the
bridge may run on the live model now; the iron rule is it may never write to
it. `bridge.readonly_titles` is that rule. A title on it answers every read
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
transaction or arbitrary code, so they are reads by construction and never
consult this at all) and `/exec` -- the only route that can write -- refuses
when the request carries a transaction, or when the snippet text matches
`WRITE_PATTERNS` below.
`refuse_titles` and `readonly_titles` are NOT layered. A title on
`refuse_titles` is refused before anything here is reached; a title only on
`readonly_titles` is never refused outright, only stopped from writing.
THIS IS A GUARD RAIL, NOT A SANDBOX
-------------------------------------
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
not a permissions model. It catches the ordinary way `/exec` writes -- an
explicit transaction, or a snippet that calls a mutating Revit API method by
its literal name -- because that is what an ordinary agent request looks
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
setter (Revit exposes several that mutate without any method call at all),
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
code that reaches a write through a helper this list has not been told
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
A determined snippet gets through. This module only stops the request an
agent following instructions would send.
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
pathlib, `os.path` throughout.
"""
# pylint: disable=broad-except,invalid-name
import os
import re
from vibe.snapshotmeta import MODEL_EXT
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
# was launched on in its own environment. A session started from the taskbar
# does not, and cannot be made to look as though it did without somebody
# deciding to.
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
# The denylist, semicolon-separated, for the case where the knowledge layers
# cannot be read. It is a way to STATE the answer, not a way to switch the
# guard off -- there is deliberately no off switch, because an off switch is
# the hole this module exists to close.
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
# for a machine whose knowledge layers will not load. Stating the answer
# here does not widen what the bridge may do -- read-only is already the
# more permissive of the two lists.
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
def normalize_title(text):
"""A title as it is compared: no model extension, trimmed, case-folded.
`doc.Title` is sometimes reported with the extension and sometimes
without, depending on version and on whether the document was opened from
a path or from the cloud, so both have to compare equal or the denylist
works on one machine and not the next.
"""
try:
text = '{0}'.format(text or '').strip()
except Exception:
return ''
if text.lower().endswith(MODEL_EXT):
text = text[:-len(MODEL_EXT)]
return text.strip().lower()
def split_titles(raw):
"""A `;`-separated (or list) denylist into a clean list of strings.
Accepts what a YAML layer produces (a list), what an environment variable
produces (one string), and a single string in the YAML -- the last is a
predictable mis-edit and reading it as a one-item list beats reading it as
a list of characters.
"""
if raw is None:
return []
if isinstance(raw, (list, tuple)):
items = list(raw)
else:
items = '{0}'.format(raw).split(';')
out = []
for item in items:
try:
text = '{0}'.format(item).strip()
except Exception:
continue
if text:
out.append(text)
return out
def _norm_path(path):
try:
text = '{0}'.format(path or '').strip()
except Exception:
return ''
if not text:
return ''
text = text.replace('/', os.sep).replace('\\', os.sep)
try:
text = os.path.normcase(os.path.normpath(text))
except Exception:
pass
return text.rstrip(os.sep)
def is_under(path, root):
"""True when `path` sits inside `root`. Case- and separator-insensitive.
A prefix test on its own says `C:\\snapshots-old\\x` is under
`C:\\snapshots`, so the separator is required after the root.
"""
path = _norm_path(path)
root = _norm_path(root)
if not path or not root:
return False
return path == root or path.startswith(root + os.sep)
def same_model(path_a, path_b):
"""True when two paths name the same model file, as strings."""
a, b = _norm_path(path_a), _norm_path(path_b)
return bool(a) and a == b
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
"""True when `title` is on `listed` and this document cannot prove it is
a sandbox copy. Shared by `refusal()` (refuse_titles) and
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
No document open (`title` falsy after normalising) is never "on the
list": there is nothing to protect and nothing to touch.
"""
banned = [normalize_title(t) for t in split_titles(listed)]
banned = [t for t in banned if t]
if not banned:
return False
here = normalize_title(title)
if not here or here not in banned:
return False
# The title is on the list. Can this document prove it is a copy?
if sandbox_model and same_model(path, sandbox_model):
return False
for root in (sandbox_roots or ()):
if root and is_under(path, root):
return False
return True
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
config_error=None):
"""None when the bridge may serve this document; one sentence when not.
`config_error` is how a caller says "I could not read the denylist". That
is NOT the same answer as "the denylist is empty", and it is refused --
a bridge that cannot prove it is not in the live model has not proved
anything. The refusal names the two ways to answer it, so the fix is in
the message rather than in somebody's memory.
"""
if config_error:
return ('REFUSED: this bridge cannot tell whether the open document '
'is the live model -- the refuse-titles configuration could '
'not be read ({0}). Fix the job/plant knowledge layer, or '
'state the answer in {1} (semicolon separated), then restart '
'Revit. Nothing was read or run.'
.format(config_error, ENV_TITLES))
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
sandbox_model):
return None
return ('REFUSED: the open document is "{0}", a title on '
'bridge.refuse_titles -- the live model this bridge must never '
'touch. Nothing was read, run or changed. Its file is {1}, which '
'is under no configured snapshot root and is not {2}. If this '
'really is a detached snapshot, open it from under '
'snapshot.detached_dir, or launch its Revit with {2} set to its '
'path.'
.format(title, path or '(no local file -- a cloud model)',
ENV_SANDBOX))
# --------------------------------------------------------------- read-only
#
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
# Grounded in what this codebase's own write idioms actually look like:
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
# needs a Transaction" and "never reload" sections name the rest. Matched
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
WRITE_PATTERNS = [
('Transaction(', re.compile(r'\bTransaction\s*\(')),
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
('doc.Save(', re.compile(r'\.Save\s*\(')),
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
('Purge', re.compile(r'\bPurge\w*\s*\(')),
('.Rename(', re.compile(r'\.Rename\s*\(')),
('WorksharingUtils / SynchronizeWithCentral',
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
]
def write_signature(code):
"""The name of the first write-capable pattern found in `code`, or None.
A plain regex scan over the snippet text -- not a parser, and not a
defence against an obfuscated call. See the module docstring.
"""
text = '{0}'.format(code or '')
for name, pattern in WRITE_PATTERNS:
if pattern.search(text):
return name
return None
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
sandbox_model=None, tx_name=None, code=None):
"""None when `/exec` may run this request; one sentence when not.
Only reached once `refusal()` above has already let the request through
-- a title on `bridge.refuse_titles` is refused outright and this is
never consulted. `readonly_titles` is the softer rule: the document may
still be read, but nothing that could write to it may run.
"""
if not _on_list_and_not_exempt(title, path, readonly_titles,
sandbox_roots, sandbox_model):
return None
if tx_name:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This request asked to run '
'inside a transaction ("{1}"), so it was not run. Nothing '
'was changed.'.format(title, tx_name))
hit = write_signature(code)
if hit:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This snippet contains '
'{1}, which can write to the model, so it was not run. '
'Nothing was changed.'.format(title, hit))
return None
def payload(title, reason):
"""The refusal as a routes response body.
`ok: False` so every existing client already treats it as a failure, plus
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
grabbed the port" apart from "the sandbox is not up yet" -- two failures
that look identical from the outside and need opposite responses.
The title is reported even though the request is refused: "which model is
open" is the one question a refusal still has to answer, or nobody can
work out which session they reached.
"""
return {'ok': False, 'refused': True, 'doc_title': title,
'error': reason}
def config_from(know, getter, environ=None):
"""(titles, roots) out of the knowledge tree and the environment.
`getter` is `knowledge.get` passed in rather than imported, so this module
keeps its promise of importing nothing that reads a file. The environment
is a UNION with the configured values, never a replacement: a denylist you
can shrink by unsetting a variable is not a denylist.
"""
environ = os.environ if environ is None else environ
titles = []
roots = []
try:
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_TITLES)))
except Exception:
pass
try:
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
detached = getter(know, 'snapshot.detached_dir', None)
if detached:
roots.append(detached)
except Exception:
pass
try:
roots.extend(split_titles(environ.get(ENV_ROOTS)))
except Exception:
pass
seen = set()
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
seen = set()
roots = [r for r in roots if not (_norm_path(r) in seen or
seen.add(_norm_path(r)))]
return titles, roots
def readonly_titles_from(know, getter, environ=None):
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
as its own function, returning one list rather than folding a third value
into `config_from`'s tuple, so every existing caller of `config_from`
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
roots are shared with `config_from` -- a copy is a copy whichever list is
being checked against -- so this does not read `bridge.allow_roots` or
`snapshot.detached_dir` again; callers pass the roots `config_from`
already returned.
"""
environ = os.environ if environ is None else environ
titles = []
try:
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
except Exception:
pass
seen = set()
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
+31
View File
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""The small Python 2 / Python 3 bridge.
We do not choose the interpreter -- pyRevit does, from the engine configured
for the host. This extension was written on Revit 2025 with IronPython 2.7,
but the same folder may be dropped on a machine whose pyRevit is set to
IronPython 3 or CPython 3, where `unicode` and `basestring` are a NameError.
A NameError at module scope takes the whole button down with a traceback the
user cannot act on, so the two names are resolved once, here.
Everything else in this extension is already written in the intersection of
the two languages: no f-strings, no print statement, no implicit integer
division, no pathlib. Keep it that way and the engine stops mattering.
"""
try: # Python 2 (IronPython 2.7)
text_type = unicode # noqa: F821
string_types = (str, unicode) # noqa: F821
except NameError: # Python 3 (IronPython 3, CPython 3)
text_type = str
string_types = (str,)
def as_text(value, encoding='utf-8'):
"""Return value as the interpreter's unicode text type."""
if isinstance(value, text_type):
return value
if isinstance(value, bytes):
return value.decode(encoding)
return text_type(value)
+768
View File
@@ -0,0 +1,768 @@
# -*- coding: utf-8 -*-
"""The whole configuration of one project, written to one file.
Knowledge arrives in five layers -- built-in defaults, the catalogue, the
plant, the job, and the project config file -- and every one of them but the
defaults lives somewhere nobody browses to: the tool's own install, APPDATA,
a folder beside the model, and wherever the engineer chose to put the config
file. That is the right way to store it and the wrong way to hand it to
somebody. A reviewer asking "what was this checked against?" should get one
file, not a tour of three machines.
So this flattens the layers into a single document that says, for every
setting, what the value is and which layer it came from. `layer_values()` is
where the five are enumerated, and it is also where the one subtlety lives:
the project config layer is left out of the provenance by default, for the
reason set out in that function's own docstring. The knowledge section
is written in the same shape a knowledge.yaml has, which means the file is not
only a record: save it as the job's knowledge file and it reproduces exactly
the configuration it was taken from.
Written for IronPython 2.7: no f-strings, no pathlib, no OrderedDict literals.
"""
# pylint: disable=import-error,invalid-name,broad-except
import datetime
import os
import re
from vibe import knowledge
from vibe.compat import string_types, text_type
# The order sections come out in -- the order somebody setting a project up
# would work through them, which is not alphabetical. Anything not listed
# follows, sorted, so a new knowledge key never silently vanishes from a dump
# written by an older build.
SECTION_ORDER = (
'nomenclature', 'parameters', 'families', 'orientation', 'rails',
'legend', 'sheet', 'concrete', 'lifters', 'handling', 'interference',
'catalogue',
)
SECTION_NOTES = {
'nomenclature': 'How a design number is written, and what reduces to what',
'orientation': 'Which end of a piece is the top once it is erected',
'rails': 'The casting bed: which rail is fixed, how wide the bed is',
'parameters': "This office's Revit parameter name for each role the "
'tool reads',
'families': 'Which family names count as strand, mesh, or a lifting insert',
'lifters': 'Part number -> catalogue designation, and the checks on top',
'handling': ('Lifting and handling: stages, insert roles, where reports '
'go. rig_mode and dunnage are decided in the decisions '
'section at the top; a stage with no rig_mode here inherits '
'the default.'),
'interference': 'The clash check between members of one assembly',
'catalogue': 'Published hardware ratings. Not job data -- ships with the '
'tool',
'legend': 'The strand-pattern legend a ticket has to carry',
'sheet': 'What is compared against the printed ticket',
'concrete': 'Release strength rules',
}
def sections(know):
"""Knowledge section names, in reading order, nothing dropped.
Except the underscore keys. `_config_missing` and `_config_problems` are
notes load() leaves for config_problems ABOUT this run -- not settings --
and writing them into the file would make a transient complaint look like
a permanent setting, and hand it back to the next merge as one.
"""
listed = [key for key in SECTION_ORDER if key in know]
rest = sorted(key for key in know
if key not in SECTION_ORDER and not str(key).startswith('_'))
return listed + rest
# ------------------------------------------------------------ a YAML writer
_PLAIN = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_ ./+-]*$')
_RESERVED = ('true', 'false', 'null', 'yes', 'no', 'on', 'off', '~')
def _scalar(value):
if value is None:
return 'null'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, float):
text = repr(value)
return text[:-2] if text.endswith('.0') else text
if not isinstance(value, string_types):
return str(value)
text = text_type(value)
if not text:
return "''"
if text.lower() in _RESERVED or not _PLAIN.match(text) \
or text != text.strip() or text.endswith(':'):
return "'" + text.replace("'", "''") + "'"
# A bare number-looking string has to be quoted or it reads back as a
# number, and a part number like 4.10 would come back as 4.1.
if re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', text):
return "'" + text + "'"
return text
def _is_flat(node):
"""A list of plain scalars, short enough to sit on one line."""
if not isinstance(node, list) or not node:
return False
if any(isinstance(v, (dict, list)) for v in node):
return False
return sum(len(_scalar(v)) + 2 for v in node) <= 66
def emit(node, indent=0):
"""A dict/list/scalar tree as YAML lines. Deterministic key order."""
pad = ' ' * indent
out = []
if isinstance(node, dict):
if not node:
return [pad + '{}']
for key in sorted(node):
value = node[key]
label = pad + _scalar(key) + ':'
if isinstance(value, dict) and value:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, list) and value:
if _is_flat(value):
out.append('{0} [{1}]'.format(
label, ', '.join(_scalar(v) for v in value)))
else:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, (dict, list)):
out.append('{0} {1}'.format(label,
'{}' if isinstance(value, dict)
else '[]'))
else:
out.append('{0} {1}'.format(label, _scalar(value)))
return out
if isinstance(node, list):
for item in node:
if isinstance(item, dict) and item:
lines = emit(item, indent + 1)
# The first key rides on the dash, the rest line up under it.
out.append(pad + '- ' + lines[0].strip())
out.extend(lines[1:])
elif isinstance(item, list) and item:
lines = emit(item, indent + 1)
out.append(pad + '-')
out.extend(lines)
else:
out.append(pad + '- ' + _scalar(item))
return out
return [pad + _scalar(node)]
# --------------------------------------------------------------- provenance
def _layer_rows(doc, include_config=False):
"""[(label, path, exists, data, unreadable)] -- what layer_values() and
layer_problems() are both built out of.
`unreadable` is `None` for an ordinary layer (absent, or present and read
clean) and the parser's message (a non-empty string) for a layer whose
file IS there and would not parse.
This used to be `layer_values()` itself, calling
`knowledge._merge_file(holder, path)` with NO `layer` argument -- which
means `_note_layer_unreadable` returned immediately (`if not layer: ...`)
and a file that exists and will not parse came back as `data = {}`,
indistinguishable from a file that genuinely sets nothing. The five-layer
merge in `knowledge.load()` was never fooled by this -- it always passes
the token -- so a real run's `_plant_unreadable` marker was sitting right
there in `know`; only THIS dump's own, separate re-read of the files
(needed to say which layer a value came from) was blind to it. Threading
the token through here, and popping the marker back out of `data` before
handing it to a caller, fixes that without changing what a marker-free
`data` dict means to `origins()` / `overridden()` / every existing caller
of `layer_values()`.
"""
from vibe import designspec
layers = [('defaults', None, True,
knowledge._deep_copy(knowledge.DEFAULTS), None)]
# The plant layer is per plant, and which plant is a key in the JOB layer
# -- so it has to be resolved through the same pre-pass load() uses, or
# this dump attributes settings to a file the run never read.
# The token doubles as the label for these three -- 'catalogue', 'plant'
# and 'job' are both what `_FILE_LAYERS` calls the layer and what a
# human-readable line calls it.
for label, path in (('catalogue', knowledge.catalogue_path()),
('plant',
designspec.plant_knowledge_path(
knowledge.plant_id(doc))),
('job', designspec.knowledge_path(doc))):
layer = label
data = {}
unreadable = None
exists = bool(path and os.path.isfile(path))
if exists:
holder = {}
knowledge._merge_file(holder, path, layer)
marker = holder.pop(knowledge._unreadable_key(layer), None)
if marker:
unreadable = marker[1]
data = holder
layers.append((label, path, exists, data, unreadable))
if include_config:
path = designspec.active_config_path(doc)
if path:
data = {}
unreadable = None
exists = os.path.isfile(path)
if exists:
# Through _merge_config_file, not _merge_file: the file's
# `decisions:` block is mapped back onto the keys really read,
# and it beats the tree below it inside the same file. A
# provenance view that read only the tree would name the wrong
# value for the eight settings that have both spellings.
# _merge_config_file already threads 'config' through to
# _merge_file on its own (`notes=merged`), so the marker
# lands on `holder` here the same way it does for the other
# three layers -- just popped back out below instead.
holder = {}
knowledge._merge_config_file(holder, path)
marker = holder.pop(knowledge._unreadable_key('config'), None)
if marker:
unreadable = marker[1]
data = holder
layers.append(('config', path, exists, data, unreadable))
return layers
def layer_values(doc, include_config=False):
"""[(label, path, exists, data)] for every layer, in the order they merge.
Read separately from load() so the dump can say which layer a value came
from. Saying only what a setting ended up as leaves the reader unable to
tell a deliberate plant rule from a default nobody has looked at.
The project config layer is deliberately NOT included by default. This is
what writes it: folding the previous version of the file into the
provenance would stamp every setting as coming from the file about to be
overwritten, and the answer would be true and useless. What a reader wants
is which of the underlying layers each setting really came from.
`include_config=True` adds it, for the one caller that needs the opposite
answer: an editor showing what is in force has to show the layer that is
WINNING, and while a config file is live that is the config file. Leaving
it out there would show somebody a plant value that nothing is reading.
The flag is additive rather than a change of default so the dump's own
provenance keeps meaning what it has always meant.
`data` never carries an `_<layer>_unreadable` marker -- `_layer_rows()`
pops it back out before this wraps the row. A caller that wants to know
whether a layer was unreadable wants `layer_problems()`, not a key it has
to know to go looking for inside `data`.
"""
return [(label, path, exists, data)
for label, path, exists, data, _unreadable
in _layer_rows(doc, include_config)]
def layer_problems(doc, include_config=False):
"""[(label, path, message)] for every layer whose file is on this machine
and would not parse -- in merge order, empty in the ordinary case.
This is the dump's own version of `knowledge.unreadable_layers()`: same
question, asked against the fresh per-file read `layer_values()` does
rather than against an already-merged `know` tree, because the dump
reads the layers itself (see `layer_values()`'s docstring) and has to be
right on its own -- a caller here may not have a merged tree with the
markers already on it.
"""
return [(label, path, unreadable)
for label, path, _exists, _data, unreadable
in _layer_rows(doc, include_config)
if unreadable]
def _paths(node, prefix=''):
"""Every leaf in a tree as a dotted path. Lists count as one leaf."""
found = []
if isinstance(node, dict):
for key in node:
found.extend(_paths(node[key],
prefix + '.' + str(key) if prefix else str(key)))
elif prefix:
found.append(prefix)
return found
def origins(doc):
"""{dotted key: the label of the LAST layer that set it}."""
out = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
out[path] = label
return out
def overridden(doc):
"""[(key, [(layer, value), ...])] for settings a later layer CHANGES.
The interesting rows: where a plant rule beats a default, or a job beats
its plant. A layer that restates a value it agrees with is not an
override, and listing it would bury the handful of rows that matter under
a hundred that do not.
"""
seen = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
seen.setdefault(path, []).append(
(label, knowledge.get(data, path)))
rows = []
for path in sorted(seen):
chain = seen[path]
values = [value for _label, value in chain]
if len(chain) > 1 and any(v != values[0] for v in values[1:]):
rows.append((path, chain))
return rows
# ------------------------------------------------------------ the document
def project_info(doc, know):
"""What piece of work this configuration belongs to."""
from vibe import designspec, ticketqc
info = {
'model': doc.Title,
'model_path': doc.PathName or '(unsaved)',
'qc_data_dir': designspec.root_dir(doc),
'written': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
}
try:
grouped = ticketqc.assemblies_by_design(doc, know)
specs = designspec.list_specs(doc) if doc.PathName else {}
designs = []
for key in sorted(grouped):
designs.append({
'design': key,
'pieces': len(grouped[key]),
'extracted_spec': key in specs,
})
info['designs'] = designs
info['piece_count'] = sum(len(v) for v in grouped.values())
except Exception as exc:
info['designs'] = []
info['designs_error'] = str(exc)
return info
DECISION_BANNER = u'''# =========================================================================
# YOUR DECISIONS
#
# Everything in this section is yours. None of it can be worked out from the
# model, and the tool will not guess at any of it -- it uses what is written
# here. Edit a value and the next run uses it.
#
# Everything after this section is either a record of how this file was built
# or the merged knowledge it was built from. Worth reading, rarely worth
# editing: anything named down there and also named up here is overruled by
# the copy up here.
# ========================================================================='''
# The comment for each decision, written directly above the value it belongs
# to rather than gathered into one block at the top of the section. A note
# four lines away from the line it describes is a note that gets edited past.
DECISION_NOTES = (
('report_dir', u'''# Where Panel Lift Check writes its HTML. Used exactly as written, so an
# absolute path anywhere works -- a job folder on a share, say. Takes effect
# on the next run.'''),
('open_report_when_done', u'''# Open each report as it is finished. The gap between a report being written
# and being read is one double-click that is easy not to take. Turn it off
# when working through a lot of pieces in a row, or forty browser tabs is the
# result.'''),
('prestress_loss_pct', u'''# Everything lost between the jack and the stage being checked, as one
# percentage. It is NOT calculated: a loss model needs an age, a humidity and
# a curing schedule this tool does not have, and a plausible-looking number
# from guessed inputs is worse than a figure you can argue with.
#
# 8 is conservative. A commercial check of one real piece computed 1.6 at
# release. LOWER IT AND EVERY PIECE GETS MORE CREDIT.'''),
('transfer_length_in', u'''# Where a cut strand has handed its force back to the concrete. Blank uses
# ACI 318's f_se * db / 3, worked out from the pull stated on the design
# rather than assumed.'''),
('insert_position', u'''# How an insert's position is measured, per run mode.
#
# VALID VALUES -- write exactly one of these:
# fast the element's cached bounding box centre. No geometry solve.
# precise the solved solid centroid.
#
# They agree on a symmetric embed and disagree by a few inches on an L-shaped
# one -- and a face lifter's position IS the moment arm it hangs on. On-edge
# anchors are snapped to the edge either way. 'precise' is roughly an order of
# magnitude slower.
#
# The two modes are set apart on purpose. A batch runs unattended, so the time
# costs nobody anything and it should be right. One piece at a time is
# somebody waiting at the screen -- and a piece close enough for a few inches
# to decide it is a piece to put through Eriksson, not to squeeze a sharper
# number out of here.
#
# one_piece Panel Lift Check on a single selected assembly
# batch Panel Lift Check on several, the sweep, and the run Ticket QC
# starts for itself'''),
('rig_mode', u'''# How the piece hangs. This changes the mechanics, not just a coefficient.
#
# VALID VALUES -- write exactly one of these:
# rolling equalising blocks / roller sheaves. Every line in a cable group
# carries the same force; two groups, statically determinate.
# static fixed-length slings / static lift. A continuous beam on rigid
# supports; the reactions follow stiffness, so a blockout beside a
# support really does change them.
#
# NOT VALID: static_lift, static_lifting, fixed, 1, yes. A misspelled value is
# REFUSED, not silently inherited -- a typo that looked like "inherit" is
# exactly the kind of quiet wrongness this section exists to prevent.
#
# "default" applies to every stage. A stage named below overrides it for that
# stage only; put null / leave it blank there to inherit the default.
#
# Example -- strip with static slings, erect with equalising blocks:
# default: rolling
# stripping: static
# transportation: null
# erection: rolling
#
# Worth settling before anything else is tuned. A commercial check of one real
# piece stripped on fixed slings and erected on an equalising block, and its
# stripping moment came out 14.1 k-ft where one rolling mode everywhere gives
# 23.8. Which is right depends on how YOUR yard rigs -- nothing here guesses
# it.'''),
('group_tolerance_ft', u'''# How far apart ALONG THE PIECE two inserts may sit and still count as one
# pick row.
#
# The beam solve hangs each ROW from one rigid support line -- but the job
# names individual inserts and nothing names rows, so this tolerance is what
# decides who belongs together. Top view, x running along the piece's length:
#
# x=14.92 x=15.92
# | |
# edge o--+ | the edge anchor and face insert B are flush;
# B o--+ | C belongs to the SAME row but sits 1.00 ft
# | C o---+ further back along the piece.
#
# gap <= group_tolerance_ft -> A, B, C hang as ONE row, one support line.
# gap > group_tolerance_ft -> C becomes an extra "row" of its own. The
# solve then balances on a support that does
# not exist: one line goes NEGATIVE (pulled
# down) while its neighbour overloads -- on
# a real panel this read -936 lb on one
# insert and 116% of rating on the next.
#
# SET IT just above the largest stagger your yard actually leaves within a
# row. Too large merges two rows that were genuinely separate -- the two
# failure modes point opposite ways, so this number is yours to set, not a
# default to trust.'''),
('dunnage', u'''# Where the piece sits on the trailer during transportation.
#
# VALID VALUES for mode:
# null old lift approximation (transportation is still checked as a
# pick; the report labels it approximate)
# lifter_rows dunnage under the same lines as the stripping rows
# fraction two dunnage lines inset fraction*L from each end
#
# fraction is only used when mode is fraction, e.g. 0.2 means supports at
# 0.2L and 0.8L. This is plant knowledge; it is lifted up here so you can see
# and edit it without collapsing the whole knowledge tree.'''),
)
def report_dir(doc, know):
"""Where handling reports go: what was set, or beside the model."""
from vibe import designspec
return (knowledge.get(know, 'handling.report_dir') or
os.path.join(designspec.root_dir(doc), 'handling'))
def decisions(doc, know):
"""Everything in this file that is the engineer's to decide, in one place.
Every one of these lives three or four levels down the merged knowledge
tree, which is where it is read from and a hopeless place to find it.
Each is here because it was asked for by name: where the reports go,
whether they open, the prestress loss, the transfer length, how the
piece hangs at each stage, and how much stagger within a pick row still
counts as one row.
They come FIRST in the file on purpose. A config file is read from the top
and abandoned somewhere in the middle, so the part somebody opened it to
change has to sit above the part they did not.
"""
modes = {'default': knowledge.get(know, 'handling.rig_mode', 'rolling')}
for stage in knowledge.get(know, 'handling.stages', []) or []:
if stage.get('name'):
modes[stage['name']] = stage.get('rig_mode')
fast = knowledge.get(know, 'handling.fast_lifter_box', True)
fast_batch = knowledge.get(know, 'handling.fast_lifter_box_batch', False)
return {
'report_dir': report_dir(doc, know),
'open_report_when_done': knowledge.get(know, 'handling.open_report',
True),
'prestress_loss_pct': knowledge.get(know,
'handling.prestress_loss_pct',
8.0),
'transfer_length_in': knowledge.get(know,
'handling.transfer_length_in'),
'group_tolerance_ft': knowledge.get(know,
'handling.group_tolerance_ft',
1.0),
'dunnage': knowledge.get(know, 'handling.dunnage') or {},
'insert_position': {
'one_piece': 'fast' if fast else 'precise',
'batch': 'fast' if fast_batch else 'precise',
},
'rig_mode': modes,
}
def decisions_block(doc, know):
"""The decisions section as text: every value under its own comment."""
body = decisions(doc, know)
out = [DECISION_BANNER, u'', u'decisions:']
for key, note in DECISION_NOTES:
out.append(u'')
out.append(u'\n'.join(u' ' + line for line in note.split(u'\n')))
out.extend(emit({key: body[key]}, 1))
return out
def output_locations(doc, know):
"""Where each thing this tool produces is written.
Every path here is DERIVED from where the model sits. None of it is a
setting any more -- the one that was, `lifting_check_reports`, moved up
into `decisions` as `report_dir`. Two editable copies of one path is
exactly the trap this file exists not to be.
They are still listed, because "where did the specs go" is a question
people have and printing the answer beats hunting for it.
"""
from vibe import designspec
return {
'extracted_specs': designspec.sub_dir(doc, 'specs'),
'evidence': designspec.sub_dir(doc, 'evidence'),
'logs': designspec.sub_dir(doc, 'log'),
'job_knowledge_file': designspec.knowledge_path(doc),
# Resolved for THIS job's plant, not the machine-wide file -- see
# knowledge.plant_id() and the two-pass note in knowledge.load().
'plant_knowledge_file': designspec.plant_knowledge_path(
knowledge.plant_id(doc)),
}
LOCATION_NOTES = (
u'# All derived from where the model sits. Shown so you do not have to\n'
u'# hunt for them; editing them does nothing. The one path here that IS a\n'
u'# setting is report_dir, at the top of this file.'
)
HEADER = u"""# WillTools project configuration
#
# model {model}
# written {when}
#
# THIS FILE IS LIVE. It is the configuration {model} runs on, and it is the
# last word -- every setting in here beats the same setting in the layered
# files it was built from. Edit it and the next run uses what you wrote.
#
# It got that way because somebody pressed Project Config. A one-line pointer
# beside the model records where this file is:
#
# {pointer}
#
# Delete that pointer, or press Project Config again, and the model goes back
# to the layered files. Nothing else reads this path.
#
# The layers underneath, in the order they override each other. This file sits
# after all of them:
#
{layers}
#
# One consequence worth knowing. The `knowledge:` section below is the MERGED
# result, so it contains the plant's settings as values of its own. While this
# file is live, editing the plant file no longer reaches this model -- the
# copy in here wins. That is the point of pinning a configuration to a job,
# and it is also how a plant-wide fix quietly fails to arrive. When you want
# the plant layer back, clear the pointer.
"""
# Only ever appended when `layer_problems()` is non-empty. An unreadable
# layer and an empty one are OPPOSITE conditions -- one is a 255KB plant file
# silently voided (the 2026-09-01 incident that `knowledge._merge_file`'s
# marker exists for), the other is genuinely nothing -- and this file is
# where a human looks first, so the two must never render the same way.
# `# set by:` in the KNOWLEDGE section below only ever names layers that DID
# contribute a value; a layer that could not be read contributes none, which
# is exactly why it needs a banner of its own instead of relying on that line
# to mention it.
UNREADABLE_LAYER_BANNER = u'''
# =========================================================================
# ONE OR MORE KNOWLEDGE LAYERS COULD NOT BE READ
#
# Each layer named below is a file that IS on this machine, and the YAML
# reader refused it. A half-parsed layer is never merged, so NONE of that
# file was used -- every value it would have set has fallen back to
# whatever the layer underneath it says. A layer that fails to load looks
# EXACTLY like a layer that agrees with the defaults; nothing below this
# banner can tell you that happened, only this can.
#
{rows}
# Fix the file and dump again -- a key stated twice inside one mapping is
# the usual cause, and `python tools/knowledgelint.py` names it with a line
# number. The tool will not guess at what the file said.
# ========================================================================='''
def unreadable_layer_banner(problems):
"""The banner text for `layer_problems()`'s rows, or '' when there are none.
Split out from `build()` so a caller assembling its own view of the same
facts (see `layer_problems()`) does not have to reconstruct this from
scratch -- and so a test can check the wording without going through a
live `doc`.
"""
if not problems:
return u''
rows = u'#\n'.join(
u'# {0} layer -- {1}\n# {2}\n'.format(label, path, message)
for label, path, message in problems)
return UNREADABLE_LAYER_BANNER.format(rows=rows)
def _layer_status_suffix(exists, unreadable):
"""The trailing note on one layer's line in the header's layer listing.
Three facts, three different suffixes -- collapsing any two of them is
exactly the bug this exists to prevent: 'read clean' (''), 'not present'
(the file is not there, ordinary and quiet) and 'unreadable' (the file
IS there and the YAML reader refused it, which is the loud one and must
never look like either of the other two, and least of all like 'read
clean and simply set nothing').
"""
if unreadable:
return u' !! UNREADABLE !!'
if not exists:
return u' -- not present'
return u''
def build(doc, know):
"""The whole document, as text."""
from vibe import designspec
rows = _layer_rows(doc)
layer_lines = [
u'# {0:<10} {1}{2}'.format(
label, path or '(built into the tool)',
_layer_status_suffix(exists, unreadable))
for label, path, exists, _data, unreadable in rows]
body = {
'decisions': decisions(doc, know),
'project': project_info(doc, know),
'output_locations': output_locations(doc, know),
'knowledge': know,
}
text = [HEADER.format(when=datetime.datetime.now()
.strftime('%Y-%m-%d %H:%M'),
model=doc.Title,
pointer=designspec.config_pointer_path(doc),
layers=u'\n'.join(layer_lines))]
problems = [(label, path, message)
for label, path, _exists, _data, message in rows
if message]
banner = unreadable_layer_banner(problems)
if banner:
text.append(banner)
# Above everything else, because a config file is read from the top and
# abandoned in the middle.
text.append(u'')
text.append(u'\n'.join(decisions_block(doc, know)))
text.append(u'\n# What this configuration belongs to. Read for the record,'
u' not by the tool.')
text.append(u'project:')
text.append(u'\n'.join(emit(body['project'], 1)))
text.append(u'\n# Where output goes.\n#\n' + LOCATION_NOTES)
text.append(u'output_locations:')
text.append(u'\n'.join(emit(body['output_locations'], 1)))
rows = overridden(doc)
if rows:
text.append(u'\n# ---- settings more than one layer touches ----------'
u'--------------------\n#\n'
u'# Where a plant rule beats a default, or a job beats its '
u'plant. The last\n# entry on each line is the one in '
u'force.\n#')
for path, chain in rows:
text.append(u'# {0}\n# {1}'.format(
path, u' -> '.join(
u'{0}: {1}'.format(label, _scalar(value))
for label, value in chain)))
text.append(u'\n# =================================================================')
text.append(u'# KNOWLEDGE -- everything below this line is the merged configuration.')
text.append(u'#')
text.append(u'# This whole section is one `knowledge:` block, so most editors let you')
text.append(u'# collapse it (click the triangle / fold marker next to `knowledge:`).')
text.append(u'# You almost never need to edit anything here. The values the engineer')
text.append(u'# actually decides are in the `decisions:` section at the top of this')
text.append(u'# file; anything also named there is overruled by the copy up there.')
text.append(u'#')
text.append(u'# The "set by" comments say which underlying layer each value came from,')
text.append(u'# so you can tell a deliberate plant rule from a default nobody has')
text.append(u'# looked at.')
text.append(u'\nknowledge:')
text.append(_knowledge_body(doc, know, indent=1))
return u'\n'.join(text) + u'\n'
def _knowledge_body(doc, know, indent):
"""The knowledge tree, section by section, each one labelled and sourced.
Emitting the whole tree in one go would be shorter and much harder to
read: a merged configuration is several hundred lines and every one of
them looks equally important until something says otherwise.
"""
where = origins(doc)
pad = ' ' * indent
out = []
for name in sections(know):
note = SECTION_NOTES.get(name)
layers = sorted(set(
where[path] for path in _paths({name: know[name]})
if path in where))
out.append(u'')
if note:
out.append(u'{0}# {1}'.format(pad, note))
if layers:
out.append(u'{0}# set by: {1}'.format(pad, ', '.join(layers)))
out.extend(emit({name: know[name]}, indent))
return u'\n'.join(out)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
# -*- coding: utf-8 -*-
"""The FULL VIEW of the configuration: everything, and where each thing is set.
The Project Config button is a window of four boxes of controls
(`vibe.configform`): the settings a person actually changes between one press
and the next, and nothing else. This is the other half -- the page behind its
"Full view" button. Every setting the job layer may carry, every layer file,
every read-only value with the sentence saying why it is read-only, and the
provenance chain of anything more than one file had an opinion about.
The two are deliberately not two answers to the same question. The window says
WHAT IS SET AND LETS YOU CHANGE IT; this says WHAT EXISTS AND WHERE IT IS
CHANGED, and the `change it in` column is the join: it names the box that
carries each setting, or says the file. That column comes from
`configform.placement()`, so the mapping is written once.
WHAT THIS FIXED, AND WHY IT IS STILL A PAGE
--------------------------------------------
The button used to be a `CommandSwitchWindow` menu, and it had three defects
in one press:
1. THE SENTENCE THAT EXPLAINS THE SCREEN WAS INSIDE THE SCREEN'S CHROME.
`CommandSwitchWindow`'s `message` is a WPF `Label` docked left in a
36-pixel row it shares with the search box, in a window fixed at 600 wide
with `ResizeMode="NoResize"`. A Label does not wrap, so the explanation
was cut off. Prose belongs where there is room for it: on this page, or in
the real window's wrapping header.
2. THE PAGE WAS PRINTED IN THE MIDDLE OF A DIALOG LOOP, so a modal popup came
straight back over it and dismissing that popup ended the run. Nothing is
printed now until every window is shut.
3. THE OPTIONS WERE NAMED BY THEIR DOTTED KEYS. Every row in
`configmodel.SETTINGS` already carries a `label` ("Report folder") and a
`section` ("Output"); the screen showed `handling.report_dir` instead.
Everything user-facing here is label first. The dotted key still appears
-- it is what somebody grepping the YAML needs -- but as reference small
print in its own column, never as the name of the thing.
THE SHAPE, WHICH IS THE TAB'S SHAPE
------------------------------------
`vibe.foldreport`: the answer is the headline and is never folded; everything
else starts closed; a section carrying a problem opens itself and says why in
a sentence. Here the answer is *every setting the job layer may carry, what it
reads now, who decided it, and where it is changed*, so that is the headline.
The three things that open a section by themselves are the three that mean
somebody is about to be surprised:
* a project config file is live (`!`), so the layers on screen are not what
the model reads;
* a setting no layer sets at all (`?`), which is not a value of nothing --
it means nothing here could judge it;
* a layer file that is not present, which is how a plant rule silently
stops applying.
NO REVIT AND NO `doc`. Everything Revit-shaped -- the layer list, the
locations, the run options -- is read by the button and passed in as data, so
the whole page can be built and asserted on host CPython
(`WillTools.extension/tests/test_configpage.py`).
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
from vibe import configmodel
from vibe import foldreport as fold
NOT_SET = '(not set in any layer)'
# Comma-separated rather than dot-separated on purpose: `foldreport.md_text`
# restores `&mdash;`, `&nbsp;`, `&dagger;` and `&amp;` and nothing else, so a
# `&middot;` here would reach the plain-text copy of the page as its own
# source. Entities the text renderer does not know are not decoration that
# degrades, they are visible breakage.
LEGEND = ('> `D` defaults, `C` catalogue, `P` plant, `J` job, '
'`!` project config, `?` no layer sets it, `+` several layers each '
'set part of it (the layers MERGE that key, entry by entry). A `?` '
'is **not** a value of nothing: it means nothing here could judge '
'the setting.')
# ------------------------------------------------------------------ the rows
def value_text(key, res):
"""What one setting reads as, and never as a blank.
An unknown value gets a sentence rather than an empty cell, for the reason
`configmodel.resolve` gives: "judged and found nothing" and "could not
judge" are different answers and an empty cell says the first one.
"""
if not res or not res.get('known'):
return NOT_SET
return configmodel.display_value(key, res['value'])
def editable_rows(layers, settings=None):
"""Every setting the job layer may carry, in SETTINGS order.
`n` is a stable position, not an interface: the editing is done in the
four-module window (`vibe.configform`), and what this page adds is the
column saying WHICH BOX each setting is in -- or that it is in none of
them and is changed in the file.
`settings` overrides the source rows for tests. It defaults to
`configmodel.editable()`, which is whatever the loaded build of
`configmodel` knows about -- so a build that has not yet learned a setting
simply does not number one, rather than numbering a row it cannot edit.
"""
rows = configmodel.editable() if settings is None else list(settings)
out = []
for index, row in enumerate(rows):
res = configmodel.resolve(layers, row['key'])
chip = configmodel.chip(res)
value = value_text(row['key'], res)
if configmodel.merges(row['key']):
# A key the layers MERGE has no single winning layer, and
# `resolve` -- which answers for a key that replaces -- would name
# the last one that set an entry as though it owned all of them.
# `J` on a map the plant also writes is the same lie as a silent
# override, one level down.
merged = configmodel.merged_map(layers, row['key'])
chip = configmodel.map_chip(merged)
value = configmodel.format_merged_map(merged) if merged \
else NOT_SET
res = dict(res)
res['layer'] = ' + '.join(configmodel.map_layers(merged)) or None
out.append({'n': index + 1,
'key': row['key'],
'label': row['label'],
'section': row['section'],
'row': row,
'res': res,
'chip': chip,
'value': value})
return out
def group_rows(rows):
"""[(section, [row])] in the order the sections first appear."""
order = []
holder = {}
for row in rows or []:
if row['section'] not in holder:
holder[row['section']] = []
order.append(row['section'])
holder[row['section']].append(row)
return [(name, holder[name]) for name in order]
def unknown_rows(rows):
"""The settings nothing in any layer decides."""
return [row for row in rows or [] if not row['res']['known']]
def config_rows(rows):
"""The settings a live project config file is deciding, over the layers."""
return [row for row in rows or [] if row['chip'] == '!']
def contested_rows(rows):
"""The settings more than one layer had an opinion about.
The full chain of a setting exactly one file mentions is one line long and
it is already on the page. These are the ones where reading the chain
tells the reader something the headline could not.
"""
return [row for row in rows or []
if len(row['res']['chain']) > 1 or row['res']['overrides']]
# -------------------------------------------------------------- the whole page
def render(title=None, layers=None, rows=None, problems=None,
job_path=None, config_path=None, config_missing=False,
env=None, run_options=None, run_options_rows=None,
run_options_path=None, locations=None, report_dir=None,
subtrees=None, muted=None, muted_note=None, muted_rows=None,
notes=None, full_path=None, placement=None):
"""The page as `foldreport.Section`s: headline first, the rest folded.
Everything is data. `layers` is `configdump.layer_values(doc,
include_config=True)`; `subtrees` is [(name, note, [(key, value, layer)])];
`locations` is `configdump.output_locations()`; `notes` are extra sentences
for the headline (a build that could not show something says so there).
`placement` is {key: where a person changes it} -- the four-module
window's boxes. A key it does not name is changed in the file, and the
page says so rather than leaving the column blank: a blank there would
read as "nowhere", and everything in the job layer is changeable
somewhere.
"""
layers = list(layers or [])
rows = editable_rows(layers) if rows is None else rows
sections = [_headline(title, rows, problems, job_path, config_path,
config_missing, notes, full_path, placement)]
missing_layers = [label for label, _p, exists, _d in layers if not exists]
unknown = unknown_rows(rows)
if unknown:
sections.append(_unknown(unknown))
if config_path:
sections.append(_config(config_path, config_missing,
config_rows(rows)))
sections.append(_read_only(layers))
if muted is not None or muted_note or muted_rows:
sections.append(_muted(muted, muted_note, muted_rows))
sections.append(_run_options(run_options, run_options_rows,
run_options_path))
sections.extend(_subtrees(subtrees))
sections.append(_layers(layers, missing_layers, env, job_path))
sections.append(_output(locations, report_dir))
sections.append(_chains(contested_rows(rows)))
sections.append(_job_file())
return sections
def _headline(title, rows, problems, job_path, config_path, config_missing,
notes, full_path, placement=None):
section = fold.Section('now', 'What this job is set to', headline=True)
section.md('# Configuration of {0}'.format(title or 'this model'))
section.md('Every setting the job layer may carry, what it reads now, '
'and which layer decided it. The **change it in** column says '
'where: one of the four boxes in the Project Config window, or '
'the file itself. Everything is written to the job layer and '
'only the job layer: `{0}`'
.format(job_path or '(not created yet)'))
section.md('Everything else &mdash; what is read-only and why, where each '
'file lives, the whole provenance chain, the run-time options '
'&mdash; is folded below. **If nothing below is open, nothing '
'below needs you.**')
for line in notes or []:
section.md('> ' + line)
for line in problems or []:
section.md('**{0}**'.format(line))
if config_path:
section.md(
'> ## This model runs on a project config file\n>\n> `{0}`{1}\n>\n'
'> It is the last word. **An edit made here does not reach this '
'model** until that file is refreshed from the layers or cleared.'
.format(config_path, ' **(MISSING)**' if config_missing else ''))
for name, group in group_rows(rows):
section.md('### ' + name)
section.table(['setting', 'in force', 'from', 'change it in',
'key'],
[[_cell(row['label']), _cell(row['value']),
'[{0}] {1}'.format(row['chip'],
row['res']['layer'] or 'nothing'),
_where(placement, row['key']),
'`{0}`'.format(row['key'])]
for row in group])
section.md(LEGEND)
if full_path:
section.md('The whole page, nothing folded, is also written to `{0}` '
'on every press.'.format(full_path))
return section
def _unknown(unknown):
section = fold.Section(
'unknown', 'Settings no layer sets',
summary='{0} setting(s)'.format(len(unknown)),
problem='{0} setting(s) that nothing in any layer decides. That is '
'not a value of nothing -- whatever reads them falls back to '
'its own idea, and nothing here can tell you what that is.'
.format(len(unknown)))
section.table(['setting', 'key'],
[[_cell(row['label']), '`{0}`'.format(row['key'])]
for row in unknown])
section.md('Each one is still editable: in its box in the window, or in '
'the job file.')
return section
def _config(config_path, config_missing, overridden):
problem = ('A project config file is live, so the layers below are NOT '
'what this model reads.')
if config_missing:
problem = ('The project config file this model points at is MISSING. '
'Nothing is falling back quietly -- put it back, refresh '
'it, or go back to the layered files.')
section = fold.Section(
'config', 'The project config file, which is the last word',
summary='{0} setting(s) decided by it'.format(len(overridden)),
problem=problem)
section.md('`{0}`'.format(config_path))
if overridden:
section.table(['setting', 'in force', 'key'],
[[_cell(row['label']), _cell(row['value']),
'`{0}`'.format(row['key'])] for row in overridden])
else:
section.md('It sets none of the settings on this page, so every value '
'above still comes from the layer named beside it.')
section.md('Until it is refreshed from the layers or cleared, a '
'job-layer edit does not reach this model.')
return section
def _read_only(layers):
rows = []
whys = []
for row in configmodel.read_only():
res = configmodel.resolve(layers, row['key'])
rows.append([_cell(row['label']),
_cell(value_text(row['key'], res)),
'[{0}] {1}'.format(configmodel.chip(res),
res['layer'] or 'nothing'),
'`{0}`'.format(row['key'])])
whys.append('**{0}** &mdash; {1}'.format(row['label'],
row['why'] or ''))
section = fold.Section('readonly', 'Shown here, not editable here',
summary='{0} setting(s)'.format(len(rows)))
section.md('This button writes the job layer and nothing else. What a '
'plant\'s hardware is, and which part number is a face lifter, '
'is not a job fact: one plant edit reaches every job, so '
'editing that file stays a deliberate act somewhere else. '
'Every one of these is still shown, with the sentence saying '
'why it is not yours to change here.')
section.table(['setting', 'in force', 'from', 'key'], rows)
for line in whys:
section.md('> ' + line)
return section
WHERE_MUTES_ARE_SET = (
'Muting is configured in the **Ticket QC** box of the Project Config '
'window: it lists every row the checklist asks, with a speaker beside '
'each. Clicking one asks for the reason -- required, dated for you, '
'written to the job layer.')
WHAT_A_MUTE_DOES = (
'**Muting does not stop a check.** A muted row runs on every ticket '
'exactly as before and keeps its place on the table; what changes is how '
'it reports, quietly instead of as a red row, when the result is not a '
'failure. **A failure is never quietened** &mdash; it stays on the table, '
'in the tally, and in the machine-readable JSON, whatever this list says.')
def _muted(muted, muted_note, muted_rows=None):
"""What is muted, whose mute it is, and WHERE THE CONTROL IS.
That last sentence is on the page because its absence was the defect: the
owner went looking for the mute control twice and did not find it, and
this page -- the one surface that lists what is muted -- said nothing at
all about where muting is done. A page that shows a state and not the
thing that sets it sends the reader hunting.
`muted_rows` is `configform.mute_table(...)`: the same rows the window
draws, so the layer column here and the buttons there cannot disagree. It
is passed in rather than computed because it needs the ticket checklist
table, which this module has no other business loading. Without it the
section falls back to the flat map, which is what every older caller
hands over.
"""
muted = muted if isinstance(muted, dict) else {}
listed = [row for row in (muted_rows or []) if row.get('muted')]
theirs = [row for row in listed if not row.get('editable')]
stale = [row for row in listed if not row.get('known')]
problem = None
if muted:
problem = ('{0} checklist item(s) are muted on this job. Each one '
'still RUNS on every ticket and keeps its row -- muting '
'only makes a result that is not a failure report quietly '
'rather than red. It never quietens a FAIL, and its '
'findings still reach the JSON.'.format(len(muted)))
if stale:
problem = ('{0} muted number(s) are not checklist items at all, so '
'they mute NOTHING. Somebody believes a row is off while '
'it is on.'.format(len(stale)))
section = fold.Section('muted', 'Muted checklist items',
summary='{0} muted'.format(len(muted)),
problem=problem)
if listed:
section.table(['item', 'checklist row', 'why this job never has one',
'set by'],
[[_cell(row['number']), _cell(row['name']),
_cell(row['reason'] or '(no reason given)'),
_cell(row['layer'] or 'nothing')] for row in listed])
elif muted:
section.table(['item', 'why this job never has one'],
[[_cell(key), _cell(muted[key])]
for key in sorted(muted)])
else:
section.md('Nothing is muted on this job. Every checklist row reports '
'at full volume.')
section.md('> ' + WHAT_A_MUTE_DOES)
section.md('> ' + WHERE_MUTES_ARE_SET)
if theirs:
# Not editable there, and saying so HERE as well: this page is where
# somebody lands after failing to change one in the window, and a
# second silence would send them to the wrong file.
section.md('> **{0} of them are not the job layer\'s.** The knowledge '
'layers MERGE this map, so a job entry cannot take a mute '
'set higher up away -- {1}. Edit that layer\'s file to '
'unmute.'.format(
len(theirs),
', '.join('{0} ({1})'.format(row['number'], row['layer'])
for row in theirs)))
if muted_note:
section.md('> ' + muted_note)
return section
def _run_options(options, option_rows, path):
options = options or {}
option_rows = list(option_rows if option_rows is not None
else configmodel.RUN_OPTIONS)
section = fold.Section(
'runopts', 'Run-time options &mdash; not knowledge',
summary='{0} option(s)'.format(len(option_rows)))
section.md('> No layers, no provenance, nothing overrides them. They are '
'choices about what a run is willing to spend, which is a '
'property of the run and not of the plant. `{0}`'
.format(path or '(beside the model)'))
section.table(
['what it is', 'now', 'default', 'key'],
[[_cell(row['label']),
_cell('{0}'.format(options.get(row['key'], row['default']))),
_cell('{0}'.format(row['default'])), '`{0}`'.format(row['key'])]
for row in option_rows])
return section
def _subtrees(subtrees):
"""One section for the parameter map, one for everything else shown raw."""
out = []
rest = []
for name, note, leaves in subtrees or []:
if name == 'parameters':
section = fold.Section(
'params', 'Revit parameter names this tool reads',
summary='{0} role(s)'.format(len(leaves)),
problem=None if leaves else
'No parameter names are mapped, so the checks that read them '
'do not run and say nothing about it.')
section.md('> ' + note)
section.table(['role', 'parameter name read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
else:
rest.append((name, note, leaves))
if rest:
section = fold.Section(
'published', 'Published data and plant knowledge, read-only',
summary='{0} group(s)'.format(len(rest)))
for name, note, leaves in rest:
section.md('### ' + name)
section.md('> ' + note)
section.table(['key', 'value read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
return out
def _layers(layers, missing, env, job_path):
problem = None
if missing:
problem = ('{0} layer file(s) are not present: {1}. Whatever they say '
'is not being applied, and nothing else reports that.'
.format(len(missing), ', '.join(missing)))
section = fold.Section(
'layers', 'Where every layer lives',
summary='{0} layer(s)'.format(len(layers)), problem=problem)
section.md('Each one overrides the one above it.')
section.table(['layer', 'file', 'present'],
[[_cell(label), _cell(path or '(built into the tool)'),
'yes' if exists else 'MISSING']
for label, path, exists, _data in layers])
section.md('The job layer, which is the one this button writes: `{0}`'
.format(job_path or '(not created yet)'))
section.table(['environment variable', 'value'],
[[_cell(name), _cell(value)] for name, value in env or []])
return section
def _output(locations, report_dir):
locations = locations or {}
section = fold.Section('output', 'Where output goes',
summary='{0} place(s)'.format(len(locations)))
section.table(['what', 'where'],
[[_cell(key.replace('_', ' ')), _cell(locations[key])]
for key in sorted(locations)])
section.md('`{0}` is the report folder in force, and it is the one path '
'here that is a setting.'.format(report_dir))
return section
def _chains(rows):
section = fold.Section(
'chains', 'The full chain, where more than one layer had an opinion',
summary='{0} setting(s)'.format(len(rows)))
section.md('A setting exactly one file mentions is already answered on '
'the page above. These are the ones where a layer was beaten, '
'which is the case worth reading.')
for row in rows:
# No key line of its own: `chain_lines` leads with the dotted key and
# the chip, so printing one here would say it twice.
section.md('### ' + row['label'])
for line in configmodel.chain_lines(row['res']):
section.md(' ' + line)
if not rows:
section.md('Every setting comes from exactly one place.')
return section
def _job_file():
section = fold.Section('jobfile', 'What panellift is handed',
summary='8 field(s)')
section.md('> The job file (`*.job.json`) written beside every report is '
'not a second place to configure anything: it is generated '
'from the settings above on every run. These are the fields it '
'carries and where each one comes from, so the two halves of '
'"the job layer" read as one thing.')
section.table(['job file field', 'comes from'],
[['rigging.mode', 'handling.rig_mode'],
['rigging.sling_angle_deg', 'handling.sling_angle_deg'],
['rigging.group_tolerance_ft',
'handling.group_tolerance_ft'],
['stages[].rig_mode', 'handling.stages[].rig_mode'],
['stages[].multiplier / pci_case / strength',
'handling.stages[]'],
['stages[].lifters',
'clicked on the piece in Panel Lift Check'],
['rigging.tension_fos / shear_fos',
'catalogue.safety_factors.*'],
['panel.*', 'measured from the model']])
return section
def _where(placement, key):
"""Which of the window's boxes changes this setting, or the file.
Never blank. A blank cell in a column headed "change it in" reads as
"nowhere", and every setting on this page is changeable somewhere.
"""
if placement is None:
return ''
return placement.get(key) or 'the file'
def _cell(value):
"""One table cell. A pipe in a value would otherwise split the row.
`output.print_table` builds a markdown pipe table and hands it to the
markdown renderer, so a muting reason containing a `|` -- a sentence
somebody typed -- would silently become two columns and shift every cell
after it.
"""
text = '' if value is None else '{0}'.format(value)
return text.replace('|', '&#124;')
+739
View File
@@ -0,0 +1,739 @@
# -*- coding: utf-8 -*-
"""Design summaries as reviewable data.
A design summary is a drafting view full of free text that a human wrote. The
checking scripts want numbers. Turning one into the other is the only part of
the job that genuinely needs judgement, so it happens once, up front, and the
result is written to disk where you can read and correct it:
<model folder>/_ticketqc/
evidence/<DESIGN>.json everything we could scrape off the summary
specs/<DESIGN>.json the structured spec <-- the thing to review
knowledge.yaml hand-written rules that outlive one project
log/<DESIGN>.txt what the extractor printed
Evidence is gathered deterministically here. So is the spec now: the shape
of a spec, and how it reads and writes as text, live in `specformat.py` --
which touches no Revit API at all, on purpose, so that shape can be unit
tested without a licence or a model (see its docstring). This module is the
Revit side of that boundary: it reads the model, and it reads and writes
specformat's shapes to disk beside it. Everything downstream reads only the
spec, so a checking run is pure, repeatable arithmetic.
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
whole point of this file is that an engineer reads it and corrects it by hand.
An older spec written as plain JSON is still read -- see load_spec.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
import re
import json
import datetime
from Autodesk.Revit.DB import (
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
BrowserOrganization, Element, StorageType,
)
from vibe import specformat
from vibe import specsave
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
normalize_spec, new_spec, strand_positions, strand_row_marks,
strand_diagram, spec_text, parse_spec_text, design_key,
)
from vibe.compat import as_text
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
# path arithmetic and lives in a module with no Revit import, so it can be
# tested on host CPython -- but every caller has always asked designspec for
# it, and a second spelling of the same question is how two answers start.
from vibe.plantpath import ( # noqa: F401 -- re-exported
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
)
# Control characters and format characters that ride along in Revit text and
# crash downstream printers / parsers. Measured: a model parameter whose
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
# way into evidence/WP1.json and broke a tool that printed it. Everything
# that scrapes model text goes through clean_text at the boundary; the
# function itself lives in ticketpage (the Revit-free module, unit-tested).
from vibe.ticketpage import clean_text
FOLDER = '_ticketqc'
JOBS_ENV = 'WILLTOOLS_JOBS'
# ------------------------------------------------------------------- layout
def root_dir(doc):
"""Where this project's QC data lives.
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
underneath it. Everything a project knows then sits in one place the
engineer chose, rather than in two that the tool chose for him.
Without it, beside the model, falling back to APPDATA when the model has
no path yet or its folder cannot be written. That fallback is the reason
the override exists. Saving a detached copy gives it a path, so the
lookup silently moves from APPDATA to the model folder, and a job
knowledge file written before the save stops being read -- measured
2026-08-30, and from the outside it looks exactly like edits that did not
take.
"""
override = os.environ.get(JOBS_ENV)
if override:
return _ensure(os.path.join(override, doc.Title or 'untitled'))
model = doc.PathName
if model:
base = os.path.join(os.path.dirname(model), FOLDER)
try:
_ensure(base)
return base
except Exception:
pass
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
'pyrevit-ticketqc', doc.Title or 'untitled')
_ensure(fallback)
return fallback
def _ensure(path):
if not os.path.isdir(path):
os.makedirs(path)
return path
def sub_dir(doc, name):
return _ensure(os.path.join(root_dir(doc), name))
def evidence_path(doc, design):
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
def spec_path(doc, design, ext='yaml'):
"""Where one design's spec lives. New specs are written as '.yaml'.
A spec written before this file carried comments is still on disk as
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
whichever one is actually there.
"""
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
def find_spec_path(doc, design):
"""(path, ext) of the spec actually on disk for this design.
'.yaml' wins when both exist -- it is the one a human could have
corrected, and load_spec must not silently prefer the one they didn't
touch. Neither existing, this is where a new spec would be written.
"""
yaml_path = spec_path(doc, design, 'yaml')
if os.path.isfile(yaml_path):
return yaml_path, 'yaml'
json_path = spec_path(doc, design, 'json')
if os.path.isfile(json_path):
return json_path, 'json'
return yaml_path, 'yaml'
def log_path(doc, design):
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
def knowledge_path(doc):
return os.path.join(root_dir(doc), 'knowledge.yaml')
# ------------------------------------------------------- the project config
#
# A project's configuration file is written wherever the engineer wants it --
# the job folder on a share, usually, where the rest of the job's paperwork
# lives and where a reviewer will look for it. That is the whole point of
# letting them choose, and it means the tool cannot go and find it by
# convention afterwards.
#
# So a one-line pointer stays behind, beside the model with the rest of the QC
# data. It holds the path and nothing else. Losing it costs one button press;
# putting the config itself here would defeat the choice.
CONFIG_POINTER = 'active-config.txt'
def config_pointer_path(doc):
return os.path.join(root_dir(doc), CONFIG_POINTER)
def active_config_path(doc):
"""The project config file in force for this model, or None.
Returns the path even when the file has gone missing. A pointer to
nothing is a fact the caller has to be told about -- resolving it to None
would silently fall back to the layered files, and the run would use a
different configuration than the one the engineer set, without saying so.
"""
pointer = config_pointer_path(doc)
if not os.path.isfile(pointer):
return None
try:
with io.open(pointer, 'r', encoding='utf-8') as fh:
path = fh.read().strip()
except Exception:
return None
return path or None
def set_active_config(doc, path):
"""Make this file the configuration this model runs on."""
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
fh.write(as_text(path))
return path
def clear_active_config(doc):
"""Go back to the layered files. Returns what was in force, or None."""
pointer = config_pointer_path(doc)
previous = active_config_path(doc)
if os.path.isfile(pointer):
os.remove(pointer)
return previous
def _safe(name):
"""A design number as a filename.
One rule, and it lives in `specsave` -- the Extract Design button names
the COPY it saves elsewhere with the same function, so a copy can never
end up called something the canonical file is not.
"""
return specsave.safe_name(name)
# ---------------------------------------------------------- design numbers
#
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
# file, alongside the rest of the spec shape. It is the same function under
# the same name; nothing that calls `designspec.design_key` has to change.
#
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
# asking "are these two design numbers the same design?" needed a licence.
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
# out of a suggestion -- and it must answer both inside Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
# the reduction there would have been a second spelling of one question, which
# is how two answers start; so the function went to the Revit-free module and
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
# `specformat.FALLBACK_DESIGN_RE`.
def _nomen(know, key, default):
"""One nomenclature setting, tolerating a knowledge dict that is not there.
Imported late: knowledge imports this module, so a module-level import
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
and resolves `nomenclature.<key>` at each call site, so do not rename it
without teaching the scanner.
"""
if know is None:
return default
from vibe import knowledge as K
return K.get(know, 'nomenclature.' + key, default)
def list_specs(doc):
"""Design numbers that already have a spec on disk.
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
wins, same rule as find_spec_path, so a spec a human has never touched
(still '.json') does not shadow one they have.
"""
folder = sub_dir(doc, 'specs')
by_design = {}
for fn in sorted(os.listdir(folder)):
low = fn.lower()
if low.endswith('.yaml'):
rank = 0
elif low.endswith('.json'):
rank = 1
else:
continue
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
if not spec or not spec.get('design_number'):
continue
design = spec['design_number']
prior = by_design.get(design)
if prior is None or rank < prior[0]:
by_design[design] = (rank, spec)
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
# --------------------------------------------------------------------- io
def read_json(path):
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
except Exception:
return None
def write_json(path, data):
_ensure(os.path.dirname(path))
text = as_text(json.dumps(data, indent=2, sort_keys=True,
ensure_ascii=False))
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(text)
return path
def read_yaml(path):
"""A spec file written by save_spec, or None. Comments are just dropped."""
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
text = fh.read()
except Exception:
return None
try:
return specformat.parse_spec_text(text)
except Exception:
return None
def read_spec_file(path):
"""A spec, whichever of the two formats it was written in."""
if path.lower().endswith('.json'):
return read_json(path)
return read_yaml(path)
def load_spec(doc, design):
path, _ext = find_spec_path(doc, design)
return normalize_spec(read_spec_file(path))
def save_spec(doc, design, spec):
"""Write a spec as hand-editable YAML.
Always '.yaml', regardless of what was there before -- a spec that gets
re-extracted goes back to being machine-written, and a stale '.json'
left beside it would otherwise look like an untouched original. Nothing
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
here on, same as list_specs.
"""
path = spec_path(doc, design, 'yaml')
_ensure(os.path.dirname(path))
text = specformat.spec_text(spec)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(as_text(text))
return path
# ------------------------------------------------------------ element utils
def _name(el):
try:
return Element.Name.GetValue(el)
except Exception:
return ''
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _params(el, wanted=None):
out = {}
for p in el.Parameters:
pname = p.Definition.Name
if wanted is not None and pname not in wanted:
continue
try:
if p.StorageType == StorageType.String:
val = p.AsString()
elif p.StorageType == StorageType.Integer:
val = p.AsInteger()
elif p.StorageType == StorageType.Double:
val = p.AsValueString()
else:
val = p.AsValueString()
except Exception:
val = None
if val not in (None, ''):
out[pname] = val
return out
def _dimension(d):
"""One dimension, with enough geometry to say what it measures.
A bare string is not enough for a strand chain. Where the strands sit is
dimensioned as a multi-segment chain -- rail to first strand, then strand
to strand, then last strand to the far edge -- so the segments in order,
their exact lengths, and which way the chain runs are all part of the
reading. N segments locate N-1 strands and the total is the piece width,
which is what makes the reading checkable.
Two traps, both met on real summaries:
* Dimension.Curve throws on these drafting views, so the direction is
inferred from how the segment origins are spread instead.
* segment.Value is the drawn geometry and segment.ValueString is what is
printed. On an overridden segment they disagree, and only the printed
text is the instruction -- so both are recorded and neither is
silently preferred.
"""
entry = {}
try:
entry['value'] = clean_text(d.ValueString)
except Exception:
entry['value'] = None
for key, attr in (('above', 'Above'), ('below', 'Below'),
('prefix', 'Prefix'), ('suffix', 'Suffix')):
try:
val = getattr(d, attr, None)
except Exception:
val = None
if val:
entry[key] = val
try:
raw = d.Value
if raw is not None:
entry['value_ft'] = round(float(raw), 6)
except Exception:
pass
try:
entry['references'] = d.References.Size
except Exception:
pass
try:
origin = d.Origin
entry['x'] = round(origin.X, 4)
entry['y'] = round(origin.Y, 4)
except Exception:
pass
segments = []
try:
if d.NumberOfSegments > 1:
for seg in d.Segments:
item = {}
try:
item['value'] = clean_text(seg.ValueString)
except Exception:
item['value'] = None
try:
if seg.Value is not None:
item['value_ft'] = round(float(seg.Value), 6)
except Exception:
pass
try:
point = seg.Origin
item['x'] = round(point.X, 4)
item['y'] = round(point.Y, 4)
except Exception:
pass
segments.append(item)
except Exception:
pass
if segments:
entry['segments'] = segments
entry['segment_count'] = len(segments)
xs = [s['x'] for s in segments if 'x' in s]
ys = [s['y'] for s in segments if 'y' in s]
if xs and ys:
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
else 'vertical')
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
if len(lengths) == len(segments):
entry['total_ft'] = round(sum(lengths), 6)
if entry.get('value') or segments:
return entry
return None
def _view_content(doc, view):
"""Text, dimensions and drawn components of one view, positions included.
Positions matter: the min/max table in a summary is a label note and a
value note sitting side by side, and only the geometry links them.
"""
notes = []
for t in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
try:
c = t.Coord
notes.append({'text': t.Text.replace('\r', '').strip(),
'x': round(c.X, 4), 'y': round(c.Y, 4)})
except Exception:
notes.append({'text': t.Text.replace('\r', '').strip()})
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_Dimensions)
.WhereElementIsNotElementType().ToElements())]
dims = [d for d in dims if d]
components = {}
for e in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
try:
key = e.Symbol.Family.Name
except Exception:
key = type(e).__name__
components[key] = components.get(key, 0) + 1
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
'text_notes': notes, 'dimensions': dims, 'components': components}
# ------------------------------------------------------------ the gathering
def _sheet_folder(org, sheet):
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
if org is None:
return ''
try:
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
return ''
def find_summary_views(doc, design, know=None):
"""The views and sheets that make up one design's packet.
Driven by SHEETS, not by view names. A view belongs to the design of the
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
would file it under the wrong design without anything looking wrong.
Sheets are reduced through design_key, so the whole packet -- cover sheet
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
order, which puts the cover sheet (the design summary itself) first.
"""
key = design_key(design, know)
if not key:
return [], []
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
ignore = [str(p).upper()
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
stale = _nomen(know, 'stale_view_re', None)
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
sheets = []
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if s.IsTemplate:
continue
number = (s.SheetNumber or '').strip()
if any(number.upper().startswith(p) for p in ignore):
continue
if design_key(number, know) != key:
continue
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
continue
sheets.append(s)
sheets.sort(key=lambda s: (s.SheetNumber or ''))
views, seen = [], set()
for sheet in sheets:
for vid in sheet.GetAllPlacedViews():
if vid in seen:
continue
seen.add(vid)
view = doc.GetElement(vid)
if view is None:
continue
if stale:
try:
if re.search(stale, view.Name):
continue
except Exception:
pass
views.append(view)
return views, sheets
def gather_evidence(doc, design, know=None):
"""Scrape everything the extractor could possibly need. No interpretation."""
views, sheets = find_summary_views(doc, design, know)
evidence = {
'schema': SCHEMA_VERSION,
'design_number': design,
'model': doc.Title,
'model_path': doc.PathName,
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'summary_views': [_view_content(doc, v) for v in views],
'sheets': [],
}
for sheet in sheets:
entry = {
'number': sheet.SheetNumber,
'name': sheet.Name,
'id': _eid(sheet),
'parameters': _params(sheet),
'views': [],
}
for vid in sheet.GetAllPlacedViews():
view = doc.GetElement(vid)
if any(v.Id == view.Id for v in views):
continue
entry['views'].append(_view_content(doc, view))
evidence['sheets'].append(entry)
# A worked example of a real piece helps the extractor ground its units.
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
return evidence
def _sample_piece(doc, design, know=None):
"""One assembly on this design, so the extractor can see real magnitudes."""
from Autodesk.Revit.DB import AssemblyInstance
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
from vibe.ticketqc import rstr
# Compared as reduced keys: the piece may be stamped PNL1 while
# the summary sheet is PNL1.0, and they are the same design.
if design_key(rstr(member, know, 'panel.design_number'),
know) != design_key(design, know):
break
bom = {}
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
try:
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
except Exception:
pass
return {
'mark': asm.AssemblyTypeName,
'family': member.Symbol.Family.Name,
'panel_parameters': _params(member),
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
'bill_of_material': bom,
}
else:
continue
return None
# ------------------------------------------------------------- spec shape
#
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
# in specformat.py now -- see the imports at the top of this file -- because
# none of them touch Revit and every one of them is worth testing without it.
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
# code written against this module.
def build_spec(doc, design, know=None, evidence=None):
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
Everything below either matched one of ticketqc's rules or it did not --
there is no third option, and nothing here guesses. What matched is
filled in; every text note on the design summary that nothing matched is
recorded in 'unresolved' rather than silently dropped. What the chain of
dimensions means -- the strand matrix -- is not attempted here at all: it
is not text, matching it well enough to trust would take real judgement,
and a wrong grid is worse than an honest blank one a human fills in by
looking at the drawing. datum comes from knowledge (rails.strand_datum)
because that much genuinely is known already; columns_in and rows start
empty.
Returns None when this design has no summary view to read.
"""
from vibe import knowledge as K
from vibe import ticketqc
if know is None:
know = K.load(doc)
views, sheets = find_summary_views(doc, design, know)
if not views:
return None
if evidence is None:
evidence = gather_evidence(doc, design, know=know)
view = views[0] # the design's own summary view --
# the rest of the packet's views
# are sub-details, not this design
reading = ticketqc.parse_design_summary(doc, view)
spec = new_spec(design, evidence)
spec['source']['extractor'] = 'regex'
spec['piece_description'] = sheets[0].Name if sheets else None
spec['concrete']['fc_psi'] = reading.get('fc')
spec['concrete']['fci_psi'] = reading.get('fci')
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
spec['reinforcing']['strand_groups'] = [
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
'it_lb': g.get('it'), 'role': None, 'note': None}
for g in (reading.get('strands') or [])
]
spec['reinforcing']['strand_layout']['datum'] = K.get(
know, 'rails.strand_datum', 'left_rail')
envelope = reading.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
if entry:
val, raw = entry
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
# which does not land on a clean decimal even when the
# drawing dimension is exact -- 34'-11 1/2" is really
# 34.958333333333336 in binary float. Round to the nearest
# 1/16" (6 decimal places comfortably clears that) so the
# spec shows the number a person would actually write, not
# float noise nobody typed.
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
spec['envelope'][dim][bound + '_text'] = raw
# Every note the generic rules could not place. The plant's declared rule
# packs then get one look at that list and may move a note into `notes`
# (prose a person reads) or leave it where it is with a better sentence
# beside it -- never remove one. `apply_to` returns both halves and their
# lengths always sum back to what went in, which is the property that
# keeps a house-style rule from becoming a silent exclusion.
#
# No packs declared, or a declaration this build refuses, means this is
# exactly the single assignment it used to be. A refusal is carried on the
# spec rather than raised: a spec that lost its extraction because a
# knowledge file named an unknown pack would be a worse outcome than one
# that says so on its face and leaves every note visible.
from vibe import extractrules
entries = ticketqc.unresolved_in_view(doc, view)
packs, problems = extractrules.load(know)
notes, unresolved = extractrules.apply_to(entries, packs)
spec['notes'] = notes
spec['unresolved'] = unresolved
if problems:
spec['source']['rule_pack_problems'] = problems
return spec
@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
"""PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of
the generic reader and loaded only where the plant knowledge says so.
WHY THIS EXISTS
---------------
`ticketqc.parse_design_summary` reads a design summary with rules that are
true of precast drawings generally -- an f'c line, a strand callout, a mesh
callout. Every text note it cannot place lands in the spec's `unresolved`
list verbatim, which is what stops a narrow rule set from being a lossy one.
But a real sheet carries a second population of notes that are not general at
all. They are one producer's house style: the title of a standard detail, the
label above a legend, a standing instruction that appears on every sheet that
producer draws. The generic reader cannot place them and should not try --
they are not facts about precast, they are facts about an office.
Left alone they are not harmless. Seven of them on one sheet turn `unresolved`
into a list nobody reads, and the day a genuinely unread value lands there it
is the eighth line of a list the reader has already learned to skim. A signal
that always fires is not a signal -- the same argument `.setup-opts` makes for
a check that always FAILs, and the same one `checks.muted` makes for a row
that is permanently NOT RUN.
WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO
--------------------------------------------
A pack classifies a note the generic rules did not match. It may say:
* `note` -- prose that belongs in the spec's `notes` list. An
instruction, a condition, a standing rule. It is CARRIED,
verbatim, into a field a person reads.
* `furniture` -- drawing furniture that carries no data at all: the bare
title of a legend or a detail. It STAYS in `unresolved`,
with the pack's reason in place of the blanket one.
A pack may NEVER remove a note from the spec, and there is no third verdict
that makes one disappear. `furniture` is not an ignore list: the line is still
written out, still readable, still countable -- only the sentence beside it
changes, from "no rule matched this" to "this is a legend title, it carries no
data". An exclusion the code chose for itself is the one failure this project
has already paid for (see `vibe/scope.py`), and nothing here may repeat it.
WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture`
--------------------------------------------------
The two errors are not symmetric, in the same way `vibe/scope.py`'s two are
not. A note wrongly called `note` is copied into a field a person reads and
costs them one line of attention. A note wrongly called `furniture` has been
declared meaningless by a regex, and the reader is told there is nothing
there. So a rule that is not certain writes `note`, and a title that carries
any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a
note, not furniture -- the condition is the part that could matter.
WHICH PACKS LOAD IS DECLARED, NEVER INFERRED
--------------------------------------------
From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though
that key exists and would be enough to guess with: which house style a job is
drawn in is a thing somebody knows and can write down, and a guess that is
right today is a guess that goes wrong silently the first time a job is drawn
to another producer's standard on this plant's template.
extract:
rule_packs: [mc]
Undeclared means NO PACK LOADS and every note behaves exactly as it did
before this module existed -- unmatched notes land in `unresolved` under the
generic reason. That is the honest default: a machine that has been told
nothing about an office's habits should claim to recognise nothing.
A declared name this module does not know is REFUSED, loudly, and no pack
loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts`
check: an opt file that has gone quiet about a line nobody honours is worse
than no opt file, because the reader believes something is in force that is
not. `problems` comes back beside the packs and the caller prints it.
Nothing here imports a module named in knowledge. `PACKS` is a whitelist of
names this package ships; a name outside it is an error, never an import.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
# Declared name -> module basename inside this package. A WHITELIST: the
# knowledge layer chooses among these, it never names a module to import.
PACKS = {
'mc': 'mc',
}
KEY = 'extract.rule_packs'
# What a pack may return from `classify`. `note` is carried into the spec's
# notes; `furniture` stays in unresolved with a better reason. There is no
# verdict that drops a line -- see the module docstring.
NOTE = 'note'
FURNITURE = 'furniture'
KINDS = (NOTE, FURNITURE)
class Placement(object):
"""One pack's verdict on one note.
`rule` is the pack-qualified id of the rule that fired (`mc/legend-title`)
so a reader of the spec can find the line of code that made the claim, and
argue with it. A placement that cannot say which rule made it is not
accepted -- an unattributable classification is exactly the kind nobody
can correct.
"""
__slots__ = ('kind', 'rule', 'reason')
def __init__(self, kind, rule, reason):
if kind not in KINDS:
raise ValueError('unknown placement kind: %r' % (kind,))
if not rule:
raise ValueError('a placement must name the rule that made it')
self.kind = kind
self.rule = rule
self.reason = reason
def __repr__(self):
return 'Placement(%r, %r)' % (self.kind, self.rule)
def load(know, get=None):
"""(packs, problems) for the rule packs this knowledge declares.
`packs` is a list of imported pack modules, in declared order. `problems`
is a list of one-line strings naming anything refused; when it is
non-empty `packs` is EMPTY -- a half-loaded rule set would classify some
notes and not others, and the reader could not tell which sentences to
trust. All or nothing, and say why.
"""
if get is None:
from vibe import knowledge as K
get = K.get
declared = get(know, KEY, None) or []
if isinstance(declared, str):
declared = [declared]
problems = []
names = []
for raw in declared:
name = (raw or '').strip().lower()
if not name:
problems.append('an empty name in %s -- remove the entry or '
'name a pack' % (KEY,))
elif name not in PACKS:
problems.append("%s names '%s', which this build does not ship. "
'Known packs: %s'
% (KEY, name, ', '.join(sorted(PACKS)) or '(none)'))
elif name in names:
problems.append("%s names '%s' twice" % (KEY, name))
else:
names.append(name)
if problems:
return [], problems
packs = []
for name in names:
packs.append(__import__('vibe.extractrules.' + PACKS[name],
globals(), locals(), ['classify'], 0))
return packs, []
def classify(text, packs):
"""The first pack that recognises `text`, or None.
First, not best: packs are tried in the order they were declared, and the
reader can see that order in the job file. Nothing here merges two packs'
opinions -- a note that two house styles both claim is a fact about the
declaration, not something this function should quietly average.
"""
for pack in (packs or []):
placed = pack.classify(text)
if placed is not None:
return placed
return None
def apply_to(entries, packs):
"""Split `unresolved` entries into (notes, unresolved) using the packs.
`entries` are the dicts `ticketqc.unresolved_in_view` returns -- each
carries at least 'text' and 'reason'. Every entry comes back in exactly
one of the two lists and none is dropped: `len(notes) + len(unresolved)`
always equals `len(entries)`, which `test_extractrules` asserts directly
because it is the property the whole module rests on.
A `note` gains 'rule' so the spec says which rule claimed it. A
`furniture` keeps its place in unresolved and only its 'reason' changes.
"""
notes = []
unresolved = []
for entry in (entries or []):
placed = classify(entry.get('text') or '', packs)
if placed is None:
unresolved.append(entry)
continue
out = dict(entry)
out['rule'] = placed.rule
if placed.reason:
out['reason'] = placed.reason
if placed.kind == NOTE:
notes.append(out)
else:
unresolved.append(out)
return notes, unresolved
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""The `mc` pack: one producer's design-summary habits.
Loaded only when the plant layer declares it (`extract.rule_packs: [mc]`) --
see `vibe/extractrules/__init__.py` for why that is declared and not inferred
from `plant.id`.
WHERE THESE RULES COME FROM
---------------------------
Every rule below was written against text observed on a real sheet, and each
one says which. They are not a model of how this producer draws; they are a
record of lines that actually landed in `unresolved` and what they turned out
to be. A rule with no observation behind it does not belong here -- this file
is evidence, and a guess mixed into evidence is indistinguishable from it
afterwards.
Observed on `WPA1.0` (sample_job, 8" horizontal wall, read 2026-09-01):
all seven notes the generic reader could not place, listed in the order they
sit on the sheet.
WHAT IS DELIBERATELY NOT HERE
------------------------------
`STRIPPING & BLOCKING DETAIL < 25'` is NOT treated as the same thing as the
bare `STRIPPING & BLOCKING DETAIL` beside it. The bare one is the title of a
detail drawn on the sheet and carries no data. The other one carries a
CONDITION -- which detail applies below twenty-five feet -- and a condition is
the part that could change what gets built. So the bare title is furniture and
the conditional one is a note, and the rule that separates them tests for
anything trailing the title rather than matching both with one loose pattern.
That asymmetry is the module's standing rule (`__init__`: when unsure, note).
Nothing here encodes a NUMBER. `*USE MC STANDARD REINFORCEMENT ...` names a
standard this office keeps; what that standard says is plant knowledge and
belongs in the plant knowledge file, looked up by name, never frozen into a
regex in this file. The rule's whole job is to carry the sentence to a field a
person reads.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.extractrules import Placement, NOTE, FURNITURE
NAME = 'mc'
def _rule(rule_id, pattern, kind, reason):
return (rule_id, re.compile(pattern, re.IGNORECASE), kind, reason)
# Order matters and is not alphabetical: a more specific rule must come before
# the looser one it would otherwise be swallowed by. The conditional detail
# title is tested before the bare one for exactly that reason.
RULES = (
# ---------------------------------------------------------- instructions
_rule('mc/standard-reinforcement',
r"^\*?\s*USE\s+MC\s+STANDARD\s+REINFORCEMENT\b",
NOTE,
'a standing instruction naming this plant\'s standard reinforcement '
'for cut-down panels and openings -- prose, not a number; what the '
'standard says is plant knowledge, looked up by name'),
_rule('mc/conditional-bar',
r"^#\s*\d+\s+BAR\b.*\bONLY\s+NEEDED\s+WHEN\b",
NOTE,
'a conditional reinforcing instruction -- the bar is required only '
'when the condition in the sentence holds, so the condition is the '
'value and it stays as prose'),
_rule('mc/pick-instruction',
r"^USE\s+FOR\b.*\bPICK\b",
NOTE,
'a handling instruction saying which pick this detail is for -- '
'prose, and it qualifies a detail rather than stating a number'),
_rule('mc/detail-title-conditional',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\b\s*\S+",
NOTE,
'a standard-detail title carrying a CONDITION (which detail applies, '
'and when) -- the title alone would be furniture, the condition is '
'not'),
# ------------------------------------------------------------- furniture
_rule('mc/detail-title',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\s*$",
FURNITURE,
'the bare title of a standard detail drawn on the sheet -- names '
'the detail, states nothing about this design'),
_rule('mc/legend-title',
r"^STRAND\s+PATTERN\s*$",
FURNITURE,
'the title above the strand-pattern legend -- the legend\'s DATA is '
'the dimension chain under it, which is read as geometry, not as '
'this text'),
_rule('mc/legend-label',
r"^STRAND\s*$",
FURNITURE,
'a bare column label in the strand legend -- a heading over values '
'read elsewhere, with no value of its own'),
)
def classify(text):
"""A Placement for `text`, or None when this pack does not recognise it.
`text` arrives already whitespace-normalised by the caller (one space
between words, no leading or trailing run), so every pattern here can
assume single spaces and anchor at both ends where it means to.
"""
probe = (text or '').strip()
if not probe:
return None
for rule_id, pattern, kind, reason in RULES:
if pattern.search(probe):
return Placement(kind, rule_id, reason)
return None
+521
View File
@@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30), about the reinforcing report:
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
"""Reports that fold: a headline you read, and detail that opens only when it
has a problem in it. No Revit here, and nothing report-specific either.
THE RULE THIS IMPLEMENTS, AND WHOSE IT IS
------------------------------------------
The owner, 2026-08-30, about the reinforcing report:
"The biggest, top-most part is the target and the donor. Everything
else follows after, smaller, and made foldable."
"There is actually a large amount of information in here that I do not
care about. Unless something is wrong I don't think I will read it.
Make this kind of thing default-folded."
Which is a stronger statement than a layout preference, and it is written down
here rather than in one button because it generalises to every report this
tool prints:
* THE ANSWER IS THE HEADLINE. Biggest, first, never folded.
* EVERYTHING ELSE STARTS CLOSED. He will not read it and does not want to
see it.
* A SECTION THAT CARRIES A PROBLEM OPENS ITSELF. A check that did not run, a
caveat that actually bites, a gap somebody has to act on. The reader's
contract becomes: **if nothing below the headline is open, nothing needs
me.** That contract is the whole value, and it is worth more than any
individual section being visible -- which is why `problem` is a SENTENCE
naming what is wrong, not a boolean: a section that opens has to say why
it opened.
* FOLDED IS NOT GONE. Every closed section still prints a one-line header
saying what is inside it and how much. Nothing is hidden; things are
folded. A caveat that has been removed from the page is a caveat the tool
has decided the reader does not need, and this project has already paid
for one of those.
WHAT THE OUTPUT PANEL ACTUALLY IS, AND WHY THE HTML LOOKS DATED
----------------------------------------------------------------
pyRevit's output window is a `System.Windows.Forms.WebBrowser`
(`pyrevit/output/__init__.py`, line 106) -- the IE engine hosted in a WinForms
control, NOT a modern browser and not the environment the panellift HTML
report renders in. A WinForms WebBrowser runs in IE7 document mode unless the
host process has a `FEATURE_BROWSER_EMULATION` registry entry, and pyRevit
sets none. So:
* `<details>` / `<summary>` DO NOT EXIST here. They are HTML5. This is the
trap, because they are the obvious answer and they fail silently -- an
unknown element renders as an inline span and the "collapsed" content is
simply always visible.
* `addEventListener`, `classList`, `querySelector` are all out.
* `document.getElementById(id).style.display` and an inline `onclick`
attribute are IE5-era DOM and work in every mode. That is what this uses.
JavaScript definitely runs in the panel: pyRevit's own charts feature injects
Chart.js into it (`coreutils/charts.py`). So the toggle should work. But it
has NOT been exercised inside Revit by the agent that wrote it -- there was no
bridge available -- so the failure is designed for rather than hoped against:
* A PROBLEM SECTION IS NEVER FOLDED IN THE FIRST PLACE. It renders open, as
plain HTML, with no dependence on the script running at all. If the toggle
is dead, every section that matters is still on the page.
* A folded section that will not open loses nothing either, as long as the
caller writes the full text out as well -- `text(sections, show_all=True)`
is that, and `reinforcereport` hands the button a path to say so in the
footer.
THE BLOCK SHAPE
---------------
A section holds `('md', text)` and `('table', columns, rows)` tuples, the same
two the reports already produce. Markdown, not HTML, because the same sections
have to render as plain text at a command line -- `tools/reinforce_offline.py`
has no panel -- and a report that became HTML-only would have to be written
twice and would drift.
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
import re
import textwrap
MD = 'md'
TABLE = 'table'
class Section(object):
"""One foldable part of a report.
`key` an id, unique on the page. Used for the toggle's element id.
`title` the one-line header. ALWAYS shown, open or closed.
`summary` what is inside, in numbers, shown on the header when closed.
A header that says only "details" tells the reader nothing
about whether to open it.
`blocks` ('md', text) / ('table', columns, rows).
`problem` None for routine. A SENTENCE when something in here needs the
reader -- it is printed on the header and it forces the
section open.
`headline` True for the part that is the answer: printed big, first, and
never given a fold at all.
"""
def __init__(self, key, title, summary=None, blocks=None, problem=None,
headline=False):
self.key = key
self.title = title
self.summary = summary
self.blocks = list(blocks or [])
self.problem = problem or None
self.headline = bool(headline)
@property
def is_open(self):
"""Open when it is the answer, or when it has a problem in it."""
return self.headline or bool(self.problem)
def add(self, block):
self.blocks.append(block)
return self
def md(self, text):
return self.add((MD, text))
def table(self, columns, rows):
return self.add((TABLE, columns, rows))
def __repr__(self):
return '<Section {0} {1}>'.format(
self.key, 'OPEN' if self.is_open else 'folded')
def sections_with_problems(sections):
"""The ones that opened themselves, in order. What the reader must see."""
return [s for s in sections if s.problem]
def problem_count(sections):
return len(sections_with_problems(sections))
# ------------------------------------------------------------------ as blocks
def blocks(sections, show_all=False):
"""Every section flattened to blocks, folding the routine ones.
A folded section becomes ONE line -- its title, its summary and nothing
else. `show_all` prints everything, which is what a file written for the
record wants and what `--all` gives at a command line.
"""
out = []
for section in sections or []:
if section.headline:
out.extend(section.blocks)
continue
if section.is_open or show_all:
out.append((MD, _open_header(section)))
out.extend(section.blocks)
else:
out.append((MD, _folded_header(section)))
return out
def _open_header(section):
head = '## ' + _text(section.title)
if section.problem:
head += ' &mdash; ' + _text(section.problem)
return head
def _folded_header(section):
bits = ['**' + _text(section.title) + '**']
if section.summary:
bits.append(_text(section.summary))
return '- ' + _text(' &mdash; ').join(bits) + ' _(folded)_'
# ------------------------------------------------------------------- as text
def _text(value):
"""`value` as the interpreter's own text type -- never through `str()`.
THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS PATH, measured
2026-08-31 in the sibling renderer: `last-ticketqc.txt` was written with
every CJK character of the job file's mute sentences removed -- the ASCII
fragments and the gaps between them survived -- while the same sentences
rendered correctly in the panel and in `qc-run.json`. A file promised as
the complete record that silently drops half a sentence is worse than one
that fails to write.
`vibe.compat` is the project's one answer to this class: it returns the
engine's text type, decoding bytes as UTF-8, and the caller encodes once
on the way to disk. Imported lazily so this module keeps the standard-
library-only shape it has at import time.
"""
from vibe import compat
return compat.as_text('' if value is None else value)
def text(sections, show_all=False, width=78):
"""The whole report as plain text. What a terminal and a log file want."""
lines = []
for block in blocks(sections, show_all=show_all):
lines.append(_text(as_text(block, width=width)))
return _text('\n').join(lines)
def as_text(block, width=78):
if block[0] == TABLE:
return table_text(block[1], block[2])
return md_text(block[1], width=width)
def md_text(md, width=78):
"""Markdown as these reports write it -> plain text.
Nothing is reworded. Entities become ASCII (the extension is IronPython
2.7, where a non-ASCII source literal is a byte string, so the reports
write `&mdash;` rather than the character), emphasis marks are dropped,
and headings get a rule under them.
NOR IS ANYTHING DROPPED. Every line goes out through `_fill`, and
`_flat` coerces with `_text` on the way in -- read both before
putting a bare `textwrap` call or a `str()` back here.
"""
body = (md.replace('&mdash;', '--').replace('&nbsp;', ' ')
.replace('&dagger;', '+').replace('&amp;', '&'))
if body.startswith('# '):
title = _flat(body[2:])
return '\n' + '=' * width + '\n' + title + '\n' + '=' * width
if body.startswith('## '):
title = _flat(body[3:])
return '\n' + title + '\n' + '-' * min(width, len(title))
if body.strip() == '---':
return '-' * width
quoted = body.startswith('> ')
if quoted:
body = body[2:]
listed = body.startswith('- ')
prefix = ' ! ' if quoted else ' '
# A folded section's header is a list item, and its continuation lines are
# indented past the dash so a two-line header cannot read as two headers.
tail = ' ' * (len(prefix) + (2 if listed else 0))
# NOTHING LONG IS EVER BROKEN. A file path or a piece mark split across
# two lines is not a wrapped sentence, it is an unusable value: the reader
# copies these into Revit's Select by ID or into a shell. Better a line
# that runs past the margin than one that has to be reassembled.
return _fill(_flat(body), width, prefix, tail)
def _fill(body, width, initial_indent, subsequent_indent):
"""`textwrap.fill`, with its whitespace TRANSLATE step taken out of
the path -- and the reason is invisible on the interpreter the suite
runs on.
`textwrap` munges whitespace by calling `text.translate(<a 256-entry
table>)` on anything it takes for a `str`. On CPython that table is
applied to a byte string, where every byte has an entry; the engine
this extension ships to is IronPython 2.7, whose `str` holds .NET
UTF-16 text, so A CHARACTER ABOVE U+00FF HAS NO ENTRY IN THE TABLE AT
ALL -- and the report came back missing exactly those characters and
nothing else. Measured on the sibling renderer, 2026-08-31.
Everything reaching here has already been flattened by `_flat`, so
there is no whitespace left to munge and `textwrap` is told not to
try. Nothing about the wrapping changes.
`ticketpage._wrap_text` is the same cure at the other renderer; the
two are one measured defect, not two style preferences.
NOTHING LONG IS EVER BROKEN, unchanged: a file path or a piece mark
split across two lines is not a wrapped sentence, it is an unusable
value -- the reader copies these into Revit or into a shell.
"""
return textwrap.fill(_text(body), width=width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=False, break_on_hyphens=False,
expand_tabs=False, replace_whitespace=False)
def _flat(body):
return (' '.join(_text(body).split())
.replace('**', '').replace('`', '').replace('__', ''))
def table_text(columns, rows):
"""A fixed-width table. Nothing is truncated: every cell is a measurement
or a piece mark, and a silently cut mark is a wrong answer that looks like
a right one.
The column widths are counted in CHARACTERS, so a cell of CJK -- two
columns wide in a terminal font and one character long -- leaves its
column looking ragged. That is cosmetic and it is the only thing this
gets wrong about such a cell: nothing is dropped, cut or reordered.
Straightening it needs east-asian-width tables; nobody has asked."""
if not rows:
return ' (nothing)'
grid = [[_cell(c) for c in columns]]
grid += [[_cell(c) for c in row] for row in rows]
width = [max(len(row[i]) for row in grid) for i in range(len(columns))]
out = [' ' + ' '.join(c.ljust(width[i]) for i, c in enumerate(grid[0])),
' ' + ' '.join('-' * w for w in width)]
for row in grid[1:]:
out.append(' ' + ' '.join(c.ljust(width[i])
for i, c in enumerate(row)))
return '\n'.join(out)
def _cell(value):
# `_text`, not `str`: a cell can hold a mark, a note, or a sentence
# somebody typed. This is the text path's OTHER coercion boundary --
# `md_text` itself has none, its only exposure was the wrap.
return (_text(value).replace('&mdash;', '--').replace('&dagger;', '+')
.replace('&nbsp;', ' ').replace('&amp;', '&'))
# ------------------------------------------------------------------- as HTML
#
# Everything below is written for an IE-engine WebBrowser control. See the
# module docstring: no HTML5 elements, no modern DOM, inline handlers only.
STYLE = """
.wt-fold { margin: 10px 0 0 0; }
.wt-head { background: #eef1f5; border-left: 4px solid #9fb0c4;
padding: 5px 8px; cursor: pointer; font-size: 8pt; color: #445; }
.wt-head-open { background: #f9f2f4; border-left: 4px solid #c7254e;
color: #c7254e; cursor: pointer; padding: 5px 8px;
font-size: 8pt; }
.wt-body { padding: 2px 8px 8px 14px; font-size: 8pt; }
.wt-why { font-weight: bold; }
.wt-hint { color: #889; font-size: 7pt; }
"""
# The toggle. `document.getElementById` and `style.display` are IE5-era DOM
# and work in every document mode a WinForms WebBrowser can be in; nothing
# here uses addEventListener, classList or querySelector, and nothing uses
# <details>, which does not exist in this engine at all.
SCRIPT = """
function wtFold(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.display == 'none') {
el.style.display = 'block';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
} else {
el.style.display = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
}
}
function wtClip(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.maxHeight == 'none') {
el.style.maxHeight = el.getAttribute('wtcap');
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
} else {
el.style.maxHeight = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
}
}
"""
# `wtClip` IS `wtFold`'S SIBLING, NOT A SECOND MECHANISM.
#
# Same conventions, deliberately: an inline `onclick`, an element id, a
# header at `id + '_h'`, and the same `[+]`/`[-]` marker swap. It exists
# because the two folds hide differently and one function cannot do both:
#
# wtFold toggles `display`, for a block that is ABSENT when closed. Used
# by every section of a folding report.
# wtClip toggles `max-height` against the element's own `wtcap`
# attribute, for a block that is WHOLLY PRESENT and merely too
# tall -- the checklist's note cells (`ticketpage.note_cell_html`).
# Its text stays in the document when collapsed, which is exactly
# why it is not `display: none`: a reader can still select and
# copy the part he cannot see, and a browser's own find still
# reaches it.
#
# Both are IE5-era DOM. `style.maxHeight` and `getAttribute` are as old as
# `style.display`, and nothing here uses addEventListener, classList,
# querySelector or `<details>`.
def html(sections, md_to_html=None, headline=False):
"""The folding half of the report as one HTML string.
`headline` sections are skipped by default: the button prints those
through pyRevit's own `print_md` / `print_table`, because that is what can
linkify an element id and what the reader is used to reading. Pass
`headline=True` to render everything.
`md_to_html` converts one markdown block. The default handles exactly the
vocabulary these reports use; a caller with a real markdown library (the
button has `pyrevit.coreutils.markdown`) can pass that instead.
"""
md_to_html = md_to_html or md_html
out = []
for section in sections or []:
if section.headline and not headline:
continue
out.append(_section_html(section, md_to_html))
# ONE LINE, NO NEWLINE ANYWHERE IN IT. pyRevit's `print_html` is a
# `print(..., end='')` into the panel's stream, and every newline in that
# stream starts a new output entry -- which would cut a `<div>` in half
# and leave a fold's body outside its own container. So joined with
# nothing, here and inside each section.
return ''.join(out)
def _section_html(section, md_to_html):
body = []
for block in section.blocks:
if block[0] == TABLE:
body.append(table_html(block[1], block[2]))
else:
body.append(md_to_html(block[1]))
shown = section.is_open
mark = '[-]' if shown else '[+]'
# Escaped, then the report's own entities put back -- exactly as
# `_cell_html` and `md_html` do. A header may carry `&mdash;` like any
# other report string; without the restore it would reach the panel as
# the literal source text.
head = [mark, '<b>' + _entities(_escape(section.title)) + '</b>']
if section.problem:
head.append('<span class="wt-why">&mdash; ' +
_entities(_escape(section.problem)) + '</span>')
elif section.summary:
head.append('&mdash; ' + _entities(_escape(section.summary)))
if not shown:
head.append('<span class="wt-hint">(click to open)</span>')
return (
'<div class="wt-fold">'
'<div id="{0}_h" class="{1}" onclick="wtFold(\'{0}\')">{2}</div>'
'<div id="{0}" class="wt-body" style="display:{3}">{4}</div>'
'</div>').format(
_id(section.key),
'wt-head-open' if section.problem else 'wt-head',
' '.join(head),
'block' if shown else 'none',
''.join(body))
def _id(key):
return 'wt_' + re.sub(r'[^0-9A-Za-z_]', '_', str(key))
def table_html(columns, rows):
if not rows:
return '<p><i>(nothing)</i></p>'
out = ['<table><tr>']
for column in columns:
out.append('<th>' + _escape(str(column)) + '</th>')
out.append('</tr>')
for row in rows:
out.append('<tr>')
for cell in row:
out.append('<td>' + _cell_html(cell) + '</td>')
out.append('</tr>')
out.append('</table>')
return ''.join(out)
def _cell_html(value):
# Cells already carry the report's own entities (`&mdash;`, `&dagger;`)
# and its marks. They are escaped and the entities put back, so a mark
# containing an ampersand cannot break the row and a dagger still prints.
return _entities(_escape(str(value)))
# The report's whole markdown vocabulary, and nothing else: it is a fixed set
# written by one module, not arbitrary user text, so a 30-line converter is
# honest here where a general one would be a dependency.
_BOLD = re.compile(r'\*\*(.+?)\*\*')
_CODE = re.compile(r'`(.+?)`')
_ITALIC = re.compile(r'(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])')
def md_html(md):
"""One markdown block -> HTML, for the panel."""
text_ = md or ''
if text_.strip() == '---':
return '<hr/>'
tag, body = 'p', text_
if body.startswith('### '):
tag, body = 'h4', body[4:]
elif body.startswith('## '):
tag, body = 'h3', body[3:]
elif body.startswith('# '):
tag, body = 'h2', body[2:]
quoted = body.startswith('> ')
if quoted:
body = body[2:]
body = _escape(body)
body = _BOLD.sub(r'<b>\1</b>', body)
body = _CODE.sub(r'<code>\1</code>', body)
body = _ITALIC.sub(r'<i>\1</i>', body)
body = _entities(body)
if quoted:
return ('<div style="border-left:3px solid #9fb0c4;padding-left:8px">'
'<' + tag + '>' + body + '</' + tag + '></div>')
return '<' + tag + '>' + body + '</' + tag + '>'
def _escape(body):
return (body.replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;'))
def _entities(body):
"""Put back the handful of entities the reports write on purpose.
They are escaped along with everything else on the way in -- that is the
point of escaping -- and then restored by name here, so the reports keep
writing `&mdash;` (ASCII source, IronPython 2.7 safe) and the panel keeps
printing an em dash.
"""
for name in ('mdash', 'dagger', 'nbsp', 'ndash', 'hellip'):
body = body.replace('&amp;' + name + ';', '&' + name + ';')
return body
+279
View File
@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""Small, boring helpers that remove the usual Revit API boilerplate.
Written for IronPython 2.7 (pyRevit's default engine) but 3.x-safe:
no f-strings, no walrus.
"""
# pylint: disable=import-error,invalid-name,broad-except
from contextlib import contextmanager
from Autodesk.Revit import DB, UI # noqa: F401
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, BuiltInParameter,
ElementId, StorageType, Transaction, TransactionGroup,
UnitUtils,
)
# UnitTypeId arrived in Revit 2021 and DisplayUnitType was removed in 2022, so
# the two never overlap for long. Resolving the unit handles once here keeps a
# host outside that window from failing the import of the whole library --
# which would take every button down, not just the four conversions below.
try:
from Autodesk.Revit.DB import UnitTypeId
_MILLIMETERS = UnitTypeId.Millimeters
_METERS = UnitTypeId.Meters
_SQUARE_METERS = UnitTypeId.SquareMeters
except ImportError: # Revit 2020 and older
from Autodesk.Revit.DB import DisplayUnitType
_MILLIMETERS = DisplayUnitType.DUT_MILLIMETERS
_METERS = DisplayUnitType.DUT_METERS
_SQUARE_METERS = DisplayUnitType.DUT_SQUARE_METERS
from pyrevit import revit, script, HOST_APP
# ------------------------------------------------------------ session handles
#
# revit.doc / revit.uidoc are LIVE @property accessors -- they always return
# whichever document is active RIGHT NOW. Assigning one to a module global
# snapshots its value ONCE, at the first import of `vibe`, and freezes it to
# whatever document happened to be active then. With two models open and the
# user switching between them that snapshot goes stale, and any helper that
# read it would silently act on the wrong model -- a transaction opened on the
# stale doc, a collector enumerating the stale doc's elements with no error.
#
# So the helpers below NEVER read these globals; they call _doc()/_uidoc()/
# _uiapp(), which re-resolve on every call. The globals are kept only for
# backward compatibility: `vibe/__init__.py` re-exports them, and some callers
# do `from vibe.helpers import doc`. IronPython 2.7 has no module-level
# __getattr__ (that is Python 3.7+), so these names cannot be made to
# re-resolve on plain attribute access -- a caller that reads helpers.doc /
# vibe.doc still gets the frozen snapshot. Any such caller in a
# model-reading or transaction path must be repointed to _doc() (or read
# revit.doc directly) instead of these names.
uiapp = HOST_APP.uiapp
uidoc = revit.uidoc
doc = revit.doc
def _uiapp():
"""The live UIApplication, re-resolved on each call (never a snapshot)."""
return HOST_APP.uiapp
def _uidoc():
"""The active UIDocument, re-resolved on each call (never a snapshot)."""
return revit.uidoc
def _doc():
"""The active Document, re-resolved on each call (never a snapshot)."""
return revit.doc
# ----------------------------------------------------------------- collecting
def _as_bic(category):
"""Accept BuiltInCategory, 'OST_Walls', or 'Walls'."""
if isinstance(category, BuiltInCategory):
return category
name = str(category)
if not name.startswith('OST_'):
name = 'OST_' + name.replace(' ', '')
bic = getattr(BuiltInCategory, name, None)
if bic is None:
raise ValueError('unknown BuiltInCategory: ' + str(category))
return bic
def q(category=None, cls=None, view=None, types=False, document=None):
"""One-liner element query. Returns a list.
q('OST_Walls') -> all wall instances
q(cls=DB.Wall) -> same, by class
q('OST_Doors', view=doc.ActiveView)-> only what's visible in the view
q('OST_Walls', types=True) -> wall types
"""
d = document or _doc()
col = FilteredElementCollector(d, view.Id) if view is not None \
else FilteredElementCollector(d)
if category is not None:
col = col.OfCategory(_as_bic(category))
if cls is not None:
col = col.OfClass(cls)
col = col.WhereElementIsElementType() if types \
else col.WhereElementIsNotElementType()
return list(col.ToElements())
def all_of(category, document=None):
"""All instances of a category."""
return q(category, document=document)
def types_of(category, document=None):
"""All element types of a category."""
return q(category, types=True, document=document)
def by_name(elements, name):
"""First element whose .Name matches, else None."""
for el in elements:
try:
if el.Name == name:
return el
except Exception:
continue
return None
def selected(document=None):
"""Currently selected elements as a list."""
d = document or _doc()
return [d.GetElement(i) for i in _uidoc().Selection.GetElementIds()]
def pick(message='Pick elements', multiple=True):
"""Interactive pick. Returns a list of elements (empty if cancelled)."""
from Autodesk.Revit.UI.Selection import ObjectType
_uidoc_ = _uidoc()
_doc_ = _doc()
try:
if multiple:
refs = _uidoc_.Selection.PickObjects(ObjectType.Element, message)
return [_doc_.GetElement(r) for r in refs]
ref = _uidoc_.Selection.PickObject(ObjectType.Element, message)
return [_doc_.GetElement(ref)]
except Exception:
return []
# ---------------------------------------------------------------- parameters
def _find_param(el, name):
"""Resolve a parameter by BuiltInParameter, exact name, then case-insensitive."""
if isinstance(name, BuiltInParameter):
return el.get_Parameter(name)
prm = el.LookupParameter(str(name))
if prm is not None:
return prm
lowered = str(name).lower()
for candidate in el.Parameters:
if candidate.Definition.Name.lower() == lowered:
return candidate
return None
def p(el, name, default=None):
"""Read a parameter value, typed. Falls back to `default` if missing."""
prm = _find_param(el, name)
if prm is None:
return default
st = prm.StorageType
if st == StorageType.String:
return prm.AsString()
if st == StorageType.Integer:
return prm.AsInteger()
if st == StorageType.Double:
return prm.AsDouble()
if st == StorageType.ElementId:
return prm.AsElementId()
return default
def p_display(el, name, default=None):
"""Read a parameter as the string Revit shows in the UI (with units)."""
prm = _find_param(el, name)
if prm is None:
return default
val = prm.AsValueString()
return val if val is not None else prm.AsString()
def set_p(el, name, value):
"""Write a parameter. Must be called inside a transaction.
Returns True on success, False if the parameter is missing or read-only.
"""
prm = _find_param(el, name)
if prm is None or prm.IsReadOnly:
return False
return bool(prm.Set(value))
# -------------------------------------------------------------- transactions
@contextmanager
def tx(name='vibe'):
"""Transaction context manager - commits on success, rolls back on error.
with tx('Rename walls'):
set_p(wall, 'Comments', 'checked')
"""
t = Transaction(_doc(), name)
t.Start()
try:
yield t
t.Commit()
except Exception:
if t.HasStarted() and not t.HasEnded():
t.RollBack()
raise
@contextmanager
def txg(name='vibe group'):
"""Transaction group - lets you undo several transactions as one step."""
g = TransactionGroup(_doc(), name)
g.Start()
try:
yield g
g.Assimilate()
except Exception:
if g.HasStarted() and not g.HasEnded():
g.RollBack()
raise
# --------------------------------------------------------------------- units
# Revit stores lengths in decimal FEET internally, always.
def ft(millimeters):
"""mm -> internal feet."""
return UnitUtils.ConvertToInternalUnits(millimeters, _MILLIMETERS)
def mm(internal_feet):
"""internal feet -> mm."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _MILLIMETERS)
def m(internal_feet):
"""internal feet -> meters."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _METERS)
def sqft_to_sqm(internal_area):
"""internal square feet -> square meters."""
return UnitUtils.ConvertFromInternalUnits(internal_area, _SQUARE_METERS)
# -------------------------------------------------------------------- output
def out():
"""The pyRevit output window for the running script."""
return script.get_output()
def log(*args):
"""print() that always lands in the pyRevit output window."""
print(' '.join(str(a) for a in args))
def table(rows, columns=None, title=None):
"""Render a list of lists as a table in the pyRevit output window."""
output = script.get_output()
if title:
output.print_md('### ' + title)
output.print_table(table_data=[[str(c) for c in r] for r in rows],
columns=columns or [])
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""Which job's knowledge layer a HOST-SIDE tool should read, with no doc open.
Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by
`doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for
a model with no path yet. That lookup needs a live document and cannot be
ported here: a terminal running `tools/snapshot_cycle.py` or
`tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and
guessing one from several candidates is a worse failure than asking, because a
wrong guess runs silently on the wrong job's settings and looks completely
normal -- the exact failure mode this whole codebase is built against
(see CLAUDE.md's "could not judge" discipline).
THE RESOLUTION ORDER, deliberately, and in this order only:
1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a
FILE) wins outright. The caller already knows which file; nothing
here second-guesses it, and a bad path is reported the ordinary way a
missing file is reported (by whoever tries to read it), not specially
here.
2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one
that has a `knowledge.yaml` in it) -- unambiguous, so it is used with
no further input.
3. a NAMED job (`--job "sample_job"`) picks one out of several by
name.
4. otherwise: REFUSE, and name every candidate folder found. This is the
correct behaviour when several real job folders exist and none was
named -- not a hole to fill in later. Silently picking "the newest
one" or "the one that happens to set the key being asked for" is a
guess wearing the clothes of a rule, and the day it guesses wrong the
run looks completely normal while reading another job's tolerances.
This is intentionally the only place this order is written down. Two tools
reading job knowledge off disk had two different half-answers to "where is
it" before this module existed -- `tools/snapshot_cycle.py` required an
explicit `--knowledge` and reported the resulting absence as if the setting
itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded
to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now.
`plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen
by an env var that may name either a file or its folder. This is a different
shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is
no `doc.Title` here to break the tie) so it earns its own module rather than
being bent to fit plantpath's shape, but the discipline is the same one:
never fall back to a plausible-looking wrong answer in silence, and say
which of "not configured", "not found" and "found but ambiguous" happened,
because they are different facts and read as different sentences.
IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at
module scope -- or at all -- so it is also safe to load from host CPython the
same way `plantpath.py` is: `tools/snapshot_cycle.py` and
`tools/knowledge_drift.py` both do, via the `vibe` package stub documented in
`WillTools.extension/tests/conftest.py`.)
"""
# pylint: disable=invalid-name
import os
JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV
KNOWLEDGE_FILENAME = 'knowledge.yaml'
class JobLocateResult(object):
"""What `resolve_job_knowledge()` found, or precisely why it did not.
`path` is set only on success -- callers should treat `bool(result)` (or
`result.path`) as the found/not-found test. `blocking` marks the
not-found cases that are a genuine misconfiguration worth stopping a run
over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a
WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary
"nothing configured yet" case, which a caller may reasonably continue
past on DEFAULTS alone -- exactly as it always could when no `--knowledge`
was given.
"""
__slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking')
def __init__(self, path=None, how=None, reason=None, candidates=None,
root=None, blocking=False):
self.path = path
self.how = how
self.reason = reason
self.candidates = list(candidates or [])
self.root = root
self.blocking = bool(blocking)
def __bool__(self):
return self.path is not None
__nonzero__ = __bool__ # IronPython 2.7
def __repr__(self):
if self.path:
return 'JobLocateResult(path={0!r}, how={1!r})'.format(
self.path, self.how)
return 'JobLocateResult(reason={0!r})'.format(self.reason)
def job_candidates(jobs_root):
"""Every subfolder of `jobs_root` that carries a `knowledge.yaml`.
Sorted, so a refusal that lists them is stable across runs and a "single
unambiguous" check does not depend on directory-walk order. Nothing here
tries to tell a "real" job folder from a stray one (a leftover detach
copy, an old bootstrap) -- that would be exactly the judgement call this
module exists to refuse to make silently. Every folder with a
`knowledge.yaml` in it is a candidate; a human names the right one.
"""
out = []
if not jobs_root or not os.path.isdir(jobs_root):
return out
for name in sorted(os.listdir(jobs_root)):
folder = os.path.join(jobs_root, name)
if not os.path.isdir(folder):
continue
candidate = os.path.join(folder, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
out.append(candidate)
return out
def _job_name_of(knowledge_path):
return os.path.basename(os.path.dirname(knowledge_path))
def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None,
env=None):
"""Which job knowledge.yaml a host-side tool should read. A JobLocateResult.
explicit a path stated outright -- a `--knowledge` flag, or an env var
that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as
`tools/snapshot_cycle.py`'s argparse default already reads).
Wins over everything below. Not checked for existence here:
whoever reads the file reports a missing one the ordinary
way, and duplicating that check here would be a second,
possibly-drifted answer to "does this file exist".
jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None`
to have it read from `env` here instead.
job_name a folder name under `jobs_root`, stated explicitly (a `--job`
flag) to pick one out of several.
env `os.environ` by default; overridable so tests never touch the
real environment.
"""
env = os.environ if env is None else env
if explicit:
return JobLocateResult(path=explicit, how='explicit')
root = jobs_root if jobs_root is not None else env.get(JOBS_ENV)
if not root:
return JobLocateResult(
reason='no job knowledge file was named (--knowledge) and {0} '
'is not set, so there is no folder to look for a job '
'under.'.format(JOBS_ENV))
if not os.path.isdir(root):
return JobLocateResult(
root=root, blocking=True,
reason='{0} is set to {1}, but that is not a folder.'
.format(JOBS_ENV, root))
if job_name:
candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
return JobLocateResult(path=candidate, how='named', root=root)
candidates = job_candidates(root)
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='no job named {0!r} under {1} (looked for {2}). Job '
'folders found there: {3}'
.format(job_name, root, candidate,
', '.join(names) or '(none)'))
candidates = job_candidates(root)
if len(candidates) == 1:
return JobLocateResult(path=candidates[0], how='single', root=root)
if not candidates:
return JobLocateResult(
root=root,
reason='{0} is set to {1}, but no job folder under it has a '
'{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME))
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='{0} is {1}, which holds {2} job folders ({3}) -- there is '
'no doc.Title here to pick one by, so nothing here guesses. '
'Say which with --job, or point --knowledge at the file '
'directly.'.format(JOBS_ENV, root, len(candidates),
', '.join(names)))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""The Revit half of the job-level reconciliation: gather, then hand over.
`vibe.jobrecon` holds the reconciliation itself and knows nothing about
Revit, so it can be tested on host CPython. This module is the thin gatherer
that turns a live document into the plain dicts that module eats -- the same
split `ticketpage.py` / `ticketchecks.py` uses, for the same reason: the
arithmetic is where a wrong answer hides, and arithmetic must be testable
without a licence.
NO OFFICE PARAMETER NAME APPEARS IN THIS FILE. Everything is read by ROLE
through `vibe.knowledge`, and a role nobody has mapped reads as a REFUSAL,
not as an empty job. That distinction is the whole point of this tool: a
reconciliation that cannot see the control numbers must say so, because
"found nothing" and "looked at nothing" are the same output otherwise, and
this project has already shipped that bug once.
Roles this module needs, none of which the older checks used:
member.control_number the piece's control number -- ONE PHYSICAL
PIECE. The unit of the whole reconciliation.
sheet.control_numbers the title-block slots holding the control
numbers a ticket covers. A list of parameter
names, or leave it unset and give
`nomenclature.control_slot_re` instead, a
regex matched against sheet parameter names --
which is what a plant with numbered slots
(..._01 .. _30) wants.
sheet.total_required the quantity the title block states.
sheet.sheet_count how many pages the ticket says it has.
member.ticket_created the model's own claim that a ticket exists.
member.ticket_detailed ... and that it was detailed. Both optional.
Already-mapped roles it reuses: `member.mark`, `panel.design_number`,
`sheet.design_number`.
Cost, measured on a 2480-piece job: the piece sweep is about 6 s across six
categories and the sheet sweep about 1 s across 1105 sheets -- no geometry is
built anywhere, which is what keeps it in seconds rather than the 10 s a
690-piece geometry pass costs.
IronPython 2.7: no f-strings, no pathlib, no typing.
"""
# pylint: disable=import-error,invalid-name,broad-except
import re
import time
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, AssemblyInstance, ViewSheet,
)
from vibe import jobrecon
# Where precast pieces can live. Not a filter on what counts as a piece --
# a piece is anything carrying a control number -- but the set of collectors
# the sweep opens. Every category here is reported with its tally, INCLUDING
# the zeros, so a reader can see what was looked at; categories outside this
# list are named in the result's blind spots rather than silently omitted.
#
# Never widen this to an unfiltered collector. A model-wide
# WhereElementIsNotElementType() walk killed Revit here on 2026-08-18.
PIECE_CATEGORIES = (
'OST_StructuralFraming',
'OST_StructuralColumns',
'OST_Walls',
'OST_Floors',
'OST_StructuralFoundation',
'OST_GenericModel',
)
# WHAT WAS LOOKED AT IS NOT A BLIND SPOT. `gather` returns ONE list of notes
# -- everything it wants said about the run -- and two different kinds live in
# it: 'I could not see X', which is a blind spot, and 'I opened these six
# category collectors and here is the tally', which is an inventory. The
# report folds the second and opens the first, so `run` has to tell them
# apart, and these two constants are how: the inventory notes are written
# with them three lines below and recognised by them in `is_inventory_note`.
#
# `gather`'s signature is deliberately NOT changed to return them separately.
# `vibe.takeoffgather` calls `gather` too and collects the same notes; a
# different tuple there would be a change to another button's path for the
# sake of this one's formatting.
INVENTORY_LEAD = 'categories searched:'
INVENTORY_INDENT = ' '
def is_inventory_note(note):
"""True for a note that is an INVENTORY of what was swept, not a blind
spot. See the constants above."""
text = str(note or '')
return text.startswith(INVENTORY_LEAD) or text.startswith(INVENTORY_INDENT)
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _param_text(param):
"""A parameter as text, whatever it is stored as."""
if param is None:
return None
try:
value = param.AsString()
except Exception:
value = None
if value in (None, ''):
try:
value = param.AsValueString()
except Exception:
value = None
return value
def _read(el, names):
"""First non-empty value across candidate parameter names."""
for name in names or ():
value = _param_text(el.LookupParameter(name))
if value not in (None, ''):
return value
return None
def _roles(know, overrides=None, _doc_for_knowledge=None):
"""Resolve every role this module reads, once.
`overrides` -- {role: name or [names]}, for a caller that knows the
template but whose knowledge file has not been taught these roles yet.
Explicit and visible beats a hardcoded office name in the repo.
"""
from vibe import knowledge as K
from vibe.compat import string_types
overrides = overrides or {}
# `know=None` used to mean "nothing is mapped", which made a caller who
# simply forgot the argument indistinguishable from an unconfigured
# plant: every role resolved empty and the whole reconciliation refused.
# It refused LOUDLY, which is the only reason this was survivable. Every
# other module in vibe loads knowledge itself when it is handed none, and
# so does this one now. A genuine refusal still happens -- it just has to
# be because the file really does not map the role.
if know is None and _doc_for_knowledge is not None:
try:
know = K.load(_doc_for_knowledge)
except Exception:
know = None
out = {}
for role in ('member.control_number', 'member.mark', 'member.ticket_created',
'member.ticket_detailed', 'panel.design_number',
'sheet.design_number', 'sheet.control_numbers',
'sheet.total_required', 'sheet.sheet_count'):
if role in overrides:
value = overrides[role]
out[role] = ([value] if isinstance(value, string_types)
else list(value))
elif know is not None:
out[role] = K.param_names(know, role)
else:
out[role] = []
slot_re = overrides.get('control_slot_re')
if slot_re is None and know is not None:
slot_re = K.get(know, 'nomenclature.control_slot_re', None)
out['control_slot_re'] = slot_re
return out
def gather(doc, know=None, overrides=None, categories=None):
"""Read the document. Returns (pieces, sheets, notes, timing).
`notes` collects everything the gather itself could not do -- an unmapped
role, a category that could not be collected. It becomes part of the
reconciliation's blind spots; it is never swallowed.
"""
roles = _roles(know, overrides, _doc_for_knowledge=doc)
notes = []
t0 = time.time()
ctrl_names = roles['member.control_number']
if not ctrl_names:
notes.append('the role member.control_number is not mapped -- no '
'piece could be identified, so NOTHING was reconciled. '
'Map it in the plant knowledge file.')
slot_names = roles['sheet.control_numbers']
slot_re = roles['control_slot_re']
if not slot_names and not slot_re:
notes.append('neither the role sheet.control_numbers nor '
'nomenclature.control_slot_re is set -- no ticket could '
'be read, so every piece will read as unticketed. This '
'is a refusal, not a finding.')
# ---- which elements are already inside an assembly ------------------
in_assembly = {}
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
name = asm.AssemblyTypeName
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if member is not None:
in_assembly[_eid(member)] = name
# ---- the pieces -----------------------------------------------------
pieces = []
per_category = {}
for cat_name in (categories or PIECE_CATEGORIES):
bic = getattr(BuiltInCategory, cat_name, None)
if bic is None:
notes.append('this Revit build has no category {0}; it was not '
'searched'.format(cat_name))
continue
try:
elements = (FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception as exc:
notes.append('could not collect {0}: {1}'.format(cat_name, exc))
continue
found = 0
for el in elements:
control = _read(el, ctrl_names)
if control in (None, ''):
continue
found += 1
family = None
try:
family = el.Symbol.Family.Name
except Exception:
try:
family = el.Name
except Exception:
family = None
pieces.append({
'id': _eid(el),
'category': cat_name,
'control': control,
'mark': _read(el, roles['member.mark']),
'design': _read(el, roles['panel.design_number']),
'family': family,
'in_assembly': in_assembly.get(_eid(el)),
'ticket_created': _read(el, roles['member.ticket_created']),
'ticket_detailed': _read(el, roles['member.ticket_detailed']),
})
per_category[cat_name] = {'elements': len(elements), 'pieces': found}
t1 = time.time()
# ---- the sheets -----------------------------------------------------
#
# One pass over each sheet's own Parameters, not thirty LookupParameter
# calls per sheet: with numbered slots that is the difference between
# about a second and about ten across a 1105-sheet job, and the slots are
# discovered rather than assumed to be contiguous.
slot_pattern = re.compile(slot_re, re.IGNORECASE) if slot_re else None
sheets = []
for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements():
slots = {}
if slot_pattern is not None:
for param in sheet.Parameters:
try:
name = param.Definition.Name
except Exception:
continue
if slot_pattern.match(name):
slots[name] = _param_text(param)
controls = [slots[k] for k in sorted(slots)]
for name in slot_names:
controls.append(_param_text(sheet.LookupParameter(name)))
sheets.append({
'id': _eid(sheet),
'number': sheet.SheetNumber,
'name': sheet.Name,
'design': _read(sheet, roles['sheet.design_number']),
'controls': controls,
'total_required': _read(sheet, roles['sheet.total_required']),
'sheet_count': _read(sheet, roles['sheet.sheet_count']),
})
t2 = time.time()
searched = set(categories or PIECE_CATEGORIES)
notes.append(INVENTORY_LEAD + ' {0}. A precast piece modelled in any '
'other category was not seen by this run.'
.format(', '.join(sorted(searched))))
for cat_name in sorted(per_category):
row = per_category[cat_name]
notes.append(INVENTORY_INDENT +
'{0}: {1} element(s), {2} carrying a control number'
.format(cat_name, row['elements'], row['pieces']))
timing = {'pieces_s': round(t1 - t0, 2), 'sheets_s': round(t2 - t1, 2),
'total_s': round(t2 - t0, 2)}
return pieces, sheets, notes, timing
def _numeric_sort(value):
"""Sort control numbers the way a person reads them: 9 before 10."""
text = str(value)
parts = re.split(r'(\d+)', text)
key = []
for part in parts:
if part.isdigit():
key.append((1, int(part), ''))
elif part:
key.append((0, 0, part))
return key
def run(doc, know=None, overrides=None, categories=None, scope=None):
"""Gather and reconcile. Returns the jobrecon result, plus timings.
The DECLARED SCOPE is read from the job knowledge here and handed to
`reconcile`, which sets aside what is outside it and reports that. The
gather itself is deliberately unscoped: it reads the whole job, so the
count of what was left alone is a measured number rather than an
estimate of what was never looked at.
"""
pieces, sheets, notes, timing = gather(doc, know=know,
overrides=overrides,
categories=categories)
if scope is None:
from vibe import scope as scopelib
if know is None:
from vibe import knowledge as K
know = K.load(doc)
scope = scopelib.of(know)
recon = jobrecon.reconcile(pieces, sheets, sort_key=_numeric_sort,
scope=scope)
# Split, not merged: what the sweep opened folds away as routine, what it
# could not see opens itself. Both are printed either way.
recon['blind_spots'] = ([n for n in notes if not is_inventory_note(n)]
+ recon['blind_spots'])
recon['searched'] = [n for n in notes if is_inventory_note(n)]
recon['timing'] = timing
return recon
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
"""The Revit half of lateral extraction. READ-ONLY. No Transaction, ever.
This is the `ticketchecks.py` of the pair: it imports the Revit API at module
scope and therefore cannot be unit tested, which is exactly why it does as
little thinking as possible. It reads parameters and geometry, hands plain
dicts to `vibe.latmodel`, and makes no judgement of its own beyond "the
model did not tell me".
WHAT WAS MEASURED IN THE LIVE MODEL, 2026-08-30
-----------------------------------------------
Two findings shaped this file, and both are the reason it refuses things
rather than inventing them.
1. ZERO elements carry the lateral marker today. Every string parameter of
every structural framing, wall, foundation and floor element, plus 1200
generic models, was read: nothing anywhere contains 'lateral' or
'resist'. The marker exists as a convention and has not been applied yet.
So the first honest run of this tool reports NOTHING MARKED, and that
must not look like an answer.
PARAMETER NAMES ARE NOT IN THIS FILE
------------------------------------
Not one Revit parameter is named here by its literal name. Every one is
reached through a ROLE resolved against the plant's knowledge file --
`member.identity_description` for the field the marker is typed into,
`member.mark` for the piece mark. That is not tidiness: a parameter name IS
this office's template, and a name written into shareable code means the
next plant cannot be served without editing code.
An unmapped role therefore makes this stand DOWN, with a sentence naming
the role. It never falls back to a name that happens to work here -- a
fallback would work on this model and silently find nothing on the next
one, which is precisely the failure this codebase is built against. The
only exception is Revit's own built-in Mark, used purely to label a row in
the report; it is a Revit concept, not a plant's, and nothing depends on it.
2. Every structural framing element in that model has a LocationPoint, not
a LocationCurve. They are point-placed precast families. A member axis
therefore cannot be read the usual way, and the bounding box is not a
substitute: a column whose COLUMN_LENGTH parameter reads 90'-5" has a
bounding box 140 ft tall, because the family carries geometry beyond the
member. Deriving an axis from that box would produce coordinates that
look completely reasonable and are wrong by 50 feet.
So point-placed elements are SKIPPED with that reason, unless a plant
explicitly opts in via `lateral.point_member_axis`. The default is None
-- refuse -- for the same reason `rails.left_rail_is_fixed` defaults to
None: a wrong default here is invisible.
IronPython 2.7.
"""
# pylint: disable=import-error,invalid-name,broad-except
from Autodesk.Revit.DB import (FilteredElementCollector, BuiltInCategory,
BuiltInParameter, ElementId, StorageType,
LocationCurve, LocationPoint, Line)
from vibe import knowledge as K
from vibe import latmodel
# The role the marker field is reached through. The NAME behind it lives in
# the plant's knowledge file and deliberately not here.
DESC_ROLE = 'member.identity_description'
MARK_ROLE = 'member.mark'
# Scanned when the user has selected nothing. Never unscoped -- an
# unfiltered collector took Revit down on 2026-08-18.
DEFAULT_CATEGORIES = [
'OST_StructuralFraming', 'OST_StructuralColumns', 'OST_Walls',
'OST_StructuralFoundation', 'OST_GenericModel',
]
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _sparam(el, name):
try:
pr = el.LookupParameter(name)
if pr is None or pr.StorageType != StorageType.String:
return None, False
v = pr.AsString()
return (v.strip() if v else None), True
except Exception:
return None, False
def read_description(el, param_names):
"""(text, source_param, any_such_param_exists).
`param_names` comes from the `member.identity_description` role. This
function never invents a name of its own.
The third value is the one that matters. If NO mapped parameter exists
on any element, the marker cannot have been written anywhere, and that
is 'could not judge' -- not 'nothing is marked'.
"""
exists = False
for name in param_names:
v, present = _sparam(el, name)
exists = exists or present
if v:
return v, name, True
return None, None, exists
def _mark(el, know):
"""The piece mark, for labelling report rows only.
The plant's own mark parameter first, through its role. Revit's built-in
Mark second -- that is a Revit concept rather than an office one, it is
the sanctioned general fallback (see knowledge.OPTIONAL_ROLES), and
nothing but a table cell depends on it.
"""
for name in K.param_names(know, MARK_ROLE):
v, _ = _sparam(el, name)
if v:
return v
try:
pr = el.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)
if pr is not None:
v = pr.AsString()
if v and v.strip():
return v.strip()
except Exception:
pass
return None
def _category(el):
try:
return el.Category.Name if el.Category else None
except Exception:
return None
def _type_name(el):
"""The Revit type name -- a section LABEL, never section properties.
`.Name` on a Revit type is ambiguous under IronPython on some bindings
(it came back empty for every element in the 2026-08-30 measurement
pass), so the built-in type parameter is tried first and reflection
second. Both are Revit's own, not this office's.
"""
try:
pr = el.get_Parameter(BuiltInParameter.ELEM_TYPE_PARAM)
if pr is not None:
v = pr.AsValueString()
if v and v.strip():
return v.strip()
except Exception:
pass
for getter in (lambda: el.Symbol.Name,
lambda: el.WallType.Name,
lambda: el.Name):
try:
v = getter()
if v and v.strip():
return v.strip()
except Exception:
continue
return None
def _material(el):
try:
pr = el.get_Parameter(BuiltInParameter.STRUCTURAL_MATERIAL_PARAM)
if pr is None:
return None
mid = pr.AsElementId()
if mid is None or mid == ElementId.InvalidElementId:
return None
me = el.Document.GetElement(mid)
if me is None:
return None
return me.Name
except Exception:
return None
def _pt(xyz):
return (xyz.X, xyz.Y, xyz.Z)
def axis_of(el, know):
"""((i, j), source, None) or (None, None, why_not). Feet. Read-only.
Feet because that is Revit's internal unit and nothing here converts.
The tuple carries no unit of its own; `latmodel.LENGTH_UNIT` is the one
place the unit is stated, and it is stated once.
"""
try:
loc = el.Location
except Exception:
loc = None
if isinstance(loc, LocationCurve):
try:
crv = loc.Curve
except Exception:
return None, None, 'the location curve could not be read'
if not isinstance(crv, Line):
return (None, None,
'the location curve is a {0}, not a straight line. A '
'curved member is not a single analysis member and is '
'not split into one here.'.format(type(crv).__name__))
try:
return ((_pt(crv.GetEndPoint(0)), _pt(crv.GetEndPoint(1))),
'location_curve', None)
except Exception as ex:
return None, None, 'end points unreadable: {0}'.format(ex)
if isinstance(loc, LocationPoint):
mode = K.get(know, 'lateral.point_member_axis', None)
if not mode:
return (None, None,
'placed as a point-based family, so it has no location '
'curve and no member axis. Its bounding box is not used: '
'measured on this model, a 90 ft column has a 140 ft '
'bounding box because the family carries geometry beyond '
'the member, so an axis taken from it would be wrong by '
'tens of feet while looking entirely plausible. Set '
'lateral.point_member_axis in knowledge.yaml to opt in.')
if mode != 'bounding_box_longest':
return (None, None,
'lateral.point_member_axis is set to "{0}", which this '
'version does not implement.'.format(mode))
try:
bb = el.get_BoundingBox(None)
except Exception:
bb = None
if bb is None:
return None, None, 'point-based, and it has no bounding box either'
lo, hi = _pt(bb.Min), _pt(bb.Max)
spans = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
k = spans.index(max(spans))
mid = [(lo[n] + hi[n]) / 2.0 for n in range(3)]
a, b = list(mid), list(mid)
a[k], b[k] = lo[k], hi[k]
return (tuple(a), tuple(b)), 'bounding_box', None
return (None, None,
'no Location of a kind that carries an axis (got {0})'
.format(type(loc).__name__ if loc is not None else 'None'))
def _wall_skip_reason(el):
"""Why a marked wall is not turned into a line member.
A shear wall is a panel. RISA models it as a wall panel or a mesh of
plates, and this tool emits no plates at all -- so turning the wall's
base curve into a horizontal line member would put a beam where the
engineer put a wall. The geometry is recorded in the skip row so that
nothing is lost, but no member is invented.
"""
bits = []
try:
loc = el.Location
if isinstance(loc, LocationCurve):
c = loc.Curve
a, b = _pt(c.GetEndPoint(0)), _pt(c.GetEndPoint(1))
bits.append('base line ({0:.3f},{1:.3f},{2:.3f}) to '
'({3:.3f},{4:.3f},{5:.3f}) ft'
.format(a[0], a[1], a[2], b[0], b[1], b[2]))
except Exception:
pass
try:
pr = el.get_Parameter(BuiltInParameter.WALL_USER_HEIGHT_PARAM)
if pr is not None:
bits.append('height {0:.3f} ft'.format(pr.AsDouble()))
except Exception:
pass
geom = '; '.join(bits) or 'geometry not readable'
return ('a wall is a shear PANEL, not a line member. This tool emits no '
'plates or wall panels, and will not put a beam where the '
'engineer put a wall. Recorded geometry: ' + geom)
def candidates(doc, selection=None, know=None):
"""The elements to inspect. Always scoped -- never a bare collector."""
if selection:
return list(selection)
cats = K.get(know, 'lateral.categories', None) or DEFAULT_CATEGORIES
out = []
for cn in cats:
bic = getattr(BuiltInCategory, cn, None)
if bic is None:
continue
try:
out.extend(FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception:
continue
return out
NOT_RUN_UNMAPPED = 'role_unmapped'
NOT_RUN_ABSENT = 'parameter_absent'
def scan(doc, selection=None, know=None):
"""Inspect candidates for the marker. Returns a dict, or (None, reason).
A tuple `(None, reason)` means COULD NOT JUDGE, and there are exactly
two ways to get there:
NOT_RUN_UNMAPPED the `member.identity_description` role has no
parameter name behind it, so this does not know
where the marker would be written.
NOT_RUN_ABSENT the role is mapped, but no candidate element
actually carries that parameter.
In both, the absence of marked members says nothing at all. A returned
dict with empty `marked` means the opposite: the question was asked
properly and the answer is that nothing is marked. The button reports
the two differently, which is the whole reason this is not just an
empty result.
"""
know = know or {}
params = K.param_names(know, DESC_ROLE)
if not params:
return None, NOT_RUN_UNMAPPED
tokens = K.get(know, 'lateral.marker_tokens', None) \
or latmodel.DEFAULT_MARKER_TOKENS
els = candidates(doc, selection=selection, know=know)
any_field = False
marked, ambiguous, unmarked = [], [], 0
for el in els:
text, source, present = read_description(el, params)
any_field = any_field or present
verdict, token = latmodel.match_marker(text, tokens)
if verdict == latmodel.MATCH_NO:
unmarked += 1
continue
row = {'element': el, 'element_id': _eid(el), 'desc': text,
'desc_param': source, 'token': token,
'category': _category(el), 'mark': _mark(el, know)}
(marked if verdict == latmodel.MATCH_YES else ambiguous).append(row)
if not any_field:
return None, NOT_RUN_ABSENT
return {'params_tried': params, 'tokens': tokens,
'candidates': len(els), 'marked': marked,
'ambiguous': ambiguous, 'unmarked': unmarked}, None
def gather(doc, selection=None, know=None):
"""(model, scan, None) -- or (None, None, reason) when it could not judge.
Read-only from start to finish. No Transaction is opened anywhere in
this module and none should ever be: the production model this runs on
is somebody's live work.
"""
found, why = scan(doc, selection=selection, know=know)
if found is None:
return None, None, why
raws = []
for row in found['marked']:
el = row['element']
cat = row['category'] or ''
if 'Wall' in cat:
raws.append({'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'],
'token': row['token'], 'i': None, 'j': None,
'axis_source': None, 'section': None,
'material': _material(el),
'note': _wall_skip_reason(el)})
continue
ends, source, why = axis_of(el, know)
tname = _type_name(el)
raws.append({
'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'], 'token': row['token'],
'i': ends[0] if ends else None,
'j': ends[1] if ends else None,
'axis_source': source,
# A type NAME, flagged unverified. See latmodel.build for why no
# dimension from GetStructuralSection() is trusted on this model.
'section': ({'name': tname, 'source': 'revit_type_name',
'verified': False} if tname else None),
'material': _material(el),
'note': why,
})
tol = K.get(know, 'lateral.node_tolerance_ft', None)
model = latmodel.build(raws, node_tol_ft=tol, model_title=doc.Title)
# latmodel.build only knows "no axis". The REASON lives out here, where
# the Revit object was, so put it back on the skip row -- a skip without
# a reason is the thing this codebase keeps refusing to ship.
by_id = {}
for r in raws:
if r.get('note'):
by_id[r['element_id']] = r['note']
for s in model['skipped']:
reason = by_id.get(s.get('element_id'))
if reason:
s['reason'] = reason
return model, found, None
+414
View File
@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""The Revit-free half of lateral extraction: marker, model, node graph.
This file is the `ticketpage.py` of the lateral tool -- no Revit import at
module scope, no I/O, no `doc`. `vibe.latextract` is the `ticketchecks.py`
side: it walks the model and hands raw dicts in here. `vibe.latrisa` turns
what comes out into files.
WHY THE SHAPE IS THIS CAREFUL
-----------------------------
Every other tool in this repository produces something a human reads and
judges. This one produces an input file for structural analysis software.
The failure mode is therefore new: a file that OPENS and is subtly wrong is
much worse than no file at all, because the wrongness arrives wearing the
authority of a finished model.
So the rule throughout: **nothing this cannot determine is ever defaulted.**
A member whose section could not be read does not get a section -- it gets
`section = None`, its label goes in `member['unresolved']`, and the report
names it. The RISA draft writes an obviously-bogus shape label for it so
that RISA refuses the member rather than analysing a guess.
And the standing distinction this codebase already draws (see the NOT RUN
discussion in `ticketchecks.py`): "could not judge" is not "judged and found
nothing". `collect()` returns `None` for a run that could not happen at all
-- no marker configured, nothing selectable -- and an empty model for a run
that happened and found nothing marked. The button reports those two
differently, on purpose.
IronPython 2.7: no f-strings, no dataclasses, no typing syntax.
"""
# pylint: disable=invalid-name,broad-except
import math
import re
# --------------------------------------------------------------- the marker
#
# The user marks lateral-resisting members BY HAND, by typing into a
# free-text identity field. The owner's own spelling is "lateral-resistent";
# the conventional English is "lateral-resistant". Both are accepted, because
# the field is typed by a person and a tool that rejects the spelling its own
# owner uses is a tool nobody marks anything for.
#
# These are the DEFAULTS. The live values come from the plant layer under
# `lateral.marker_tokens` (declared in knowledge.DEFAULTS) so a plant that
# writes something else -- "LFRS", "seismic" -- changes a YAML line rather
# than this file.
DEFAULT_MARKER_TOKENS = ['lateral-resistent', 'lateral-resistant']
# Words that, immediately in front of the marker, invert it. "NOT
# LATERAL-RESISTENT" is a phrase a detailer plausibly types, and a plain
# substring match would read it as a mark. We neither include it nor drop it
# silently: it comes back as AMBIGUOUS and the report names the element and
# quotes the text, for a human to settle. Guessing either way here is the
# kind of quiet error this whole file exists to avoid.
NEGATORS = ('not', 'non', 'no', 'never', 'excl', 'exclude', 'excluded')
MATCH_NO = 'no'
MATCH_YES = 'yes'
MATCH_AMBIGUOUS = 'ambiguous'
_NONWORD = re.compile(r'[^a-z0-9]+')
def normalise(text):
"""Lowercase; every run of non-alphanumerics becomes one space.
This is what makes the match tolerant of how the token was typed:
'Lateral-Resistent', 'LATERAL RESISTANT', 'lateral_resistent' and
'lateral resistent' all normalise to the same thing.
It deliberately does NOT remove the separator entirely, so the run-together
'lateralresistent' does not match. That is a judgement call: accepting it
would mean 'lateral' followed by anything starting with 'resistent' also
matches, and the tool cannot tell a typo from a different phrase. An
unmatched element is visible in the report as "not marked"; a wrongly
matched one is invisible.
"""
if text is None:
return ''
try:
low = text.lower()
except Exception:
low = str(text).lower()
return _NONWORD.sub(' ', low).strip()
def match_marker(text, tokens=None):
"""(verdict, matched_token_or_None) for one free-text field.
verdict is MATCH_YES / MATCH_NO / MATCH_AMBIGUOUS.
ACCEPTS (verdict YES):
'lateral-resistent' exactly, any case
'lateral-resistant' the conventional spelling
'LATERAL RESISTENT' any separator run: - _ space . /
'shear wall, lateral-resistent' surrounded by other text
'lateral-resistent (see S2.1)' trailing text
REJECTS (verdict NO):
'lateral' on its own -- half a token
'resistant' on its own
'lateralresistent' no separator at all (see normalise)
'laterally resistant' 'laterally' is not 'lateral'
'' / None an empty field is not a mark
FLAGS (verdict AMBIGUOUS -- never counted either way, always reported):
'NOT lateral-resistent'
'non lateral resistant'
"""
hay = normalise(text)
if not hay:
return MATCH_NO, None
padded = ' ' + hay + ' '
for raw in (tokens or DEFAULT_MARKER_TOKENS):
needle = normalise(raw)
if not needle:
continue
# Whole-token match: the marker must sit on word boundaries, so
# 'laterally resistant' does not match 'lateral resistant'.
at = padded.find(' ' + needle + ' ')
if at < 0:
continue
before = padded[:at].strip().split(' ')
if before and before[-1] in NEGATORS:
return MATCH_AMBIGUOUS, raw
return MATCH_YES, raw
return MATCH_NO, None
# ------------------------------------------------------------------- units
#
# Revit's internal length unit is decimal FEET. Always. Handing inches to
# XYZ() is a 12x error that has already cost this project real time, so the
# unit travels WITH the number everywhere in this structure rather than
# being remembered. Nothing in this file converts anything: feet in, feet
# out. RISA's own default length unit is feet too, which is why the emitter
# has no conversion either -- but it states the unit in the file instead of
# relying on that.
LENGTH_UNIT = 'ft'
# How close two member endpoints must be to count as the same node.
# 1/8 inch. Not a modelling tolerance -- a "did the detailer snap these
# together" tolerance. Anything looser starts welding members that a
# structural engineer means to be separate.
DEFAULT_NODE_TOL_FT = 0.0104167 # 1/8"
# ----------------------------------------------------------- raw member in
#
# What `latextract` hands over per element. Every field may be None, and
# None means "the model did not tell us", never "zero" or "default":
#
# element_id int
# category 'OST_Walls'
# mark piece mark / control mark, or None
# desc the identity-description text the marker was found in
# token which marker spelling matched
# i, j (x, y, z) tuples in FEET, or None if no axis could be found
# axis_source 'location_curve' | 'bounding_box' | None
# section {'name':..,'source':..} or None
# material str or None
# note free text from the gatherer
def _key(pt, tol):
"""Grid key for endpoint welding, at the tolerance."""
return (int(round(pt[0] / tol)), int(round(pt[1] / tol)),
int(round(pt[2] / tol)))
def _dist(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
+ (a[2] - b[2]) ** 2)
def build(raw_members, node_tol_ft=None, model_title=None):
"""Raw gathered members -> the intermediate structure. Pure.
Returns a dict. Never raises on a bad member: a member that cannot be
turned into two nodes is moved into `skipped` with a reason, because a
silently dropped member is exactly the failure this tool must not have.
"""
tol = node_tol_ft or DEFAULT_NODE_TOL_FT
nodes = []
index = {}
members = []
skipped = []
def node_for(pt):
"""Weld to an existing node within tol, else make a new one.
Snapping is to a GRID at the tolerance, then confirmed by true
distance against the candidates in the 27 neighbouring cells -- a
plain grid alone would fail to weld two points a hair either side of
a cell boundary.
"""
k = _key(pt, tol)
best = None
best_d = None
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for dz in (-1, 0, 1):
for idx in index.get((k[0] + dx, k[1] + dy, k[2] + dz), ()):
d = _dist(pt, (nodes[idx]['x'], nodes[idx]['y'],
nodes[idx]['z']))
if d <= tol and (best_d is None or d < best_d):
best, best_d = idx, d
if best is not None:
return best
idx = len(nodes)
nodes.append({'label': 'N{0}'.format(idx + 1),
'x': pt[0], 'y': pt[1], 'z': pt[2],
'members': []})
index.setdefault(k, []).append(idx)
return idx
for n, raw in enumerate(raw_members):
i_pt, j_pt = raw.get('i'), raw.get('j')
label = 'M{0}'.format(n + 1)
if not i_pt or not j_pt:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'no member axis could be established '
'(no location curve, and no usable bounding box)'})
continue
if _dist(i_pt, j_pt) <= tol:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'the two ends are the same point to within the node '
'tolerance -- a zero-length member is not analysable'})
continue
ii, jj = node_for(i_pt), node_for(j_pt)
if ii == jj:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'both ends welded to the same node at the '
'{0} ft tolerance'.format(tol)})
continue
unresolved = []
sec = raw.get('section')
if not sec:
unresolved.append('section')
elif not sec.get('verified'):
# A Revit type NAME is not a section property. It is the thing an
# engineer maps to a RISA shape by hand, and it is worth carrying
# -- but calling it a section would be the exact silent default
# this file exists to refuse. Measured on this model 2026-08-30:
# a type named 28" x 48" reports a parameterised rectangle 28"
# wide and 20 FEET deep. The API's own section object is wrong
# here, so no dimension read from it is trusted.
unresolved.append('verified section properties '
'(only a type name was read)')
if not raw.get('material'):
unresolved.append('material')
# End releases are NEVER extracted -- see `notes` below. Listing it
# per member keeps it in front of whoever reads one member's row,
# not only in the preamble.
unresolved.append('end releases')
mem = {
'label': label,
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'desc': raw.get('desc'),
'token': raw.get('token'),
'i_node': nodes[ii]['label'],
'j_node': nodes[jj]['label'],
'length_ft': _dist(i_pt, j_pt),
'axis_source': raw.get('axis_source'),
'section': raw.get('section'),
'material': raw.get('material'),
'releases': None,
'unresolved': unresolved,
'note': raw.get('note'),
}
members.append(mem)
nodes[ii]['members'].append(label)
nodes[jj]['members'].append(label)
model = {
'schema': 'willtools.lateral/1',
'model_title': model_title,
'length_unit': LENGTH_UNIT,
'coordinate_note': 'Revit project internal coordinates, decimal feet. '
'Not shared coordinates, not a survey datum.',
'node_tol_ft': tol,
'nodes': nodes,
'members': members,
'skipped': skipped,
}
model['connectivity'] = connectivity(model)
model['notes'] = notes(model)
return model
def connectivity(model):
"""What the node graph does and does not say. Facts, not a verdict.
Two things a structural model needs that a Revit precast model does not
hand over, both reported here rather than fixed:
free_ends a node only one member reaches. Fine at a support, a
disconnected member anywhere else -- and this cannot
tell which, because supports are not extracted.
near_misses two endpoints close enough to be suspicious but further
apart than the weld tolerance. Almost always a detailer
gap the analysis model should close; NEVER closed here.
"""
free = [n['label'] for n in model['nodes'] if len(n['members']) == 1]
tol = model['node_tol_ft']
# "Suspicious" = up to 6 inches. Wide enough to catch a real gap, narrow
# enough that it is not just listing the whole model.
near = []
ns = model['nodes']
# The near-miss search is O(n^2). Above this it would freeze Revit's UI
# thread, which is a hard rule here. Skipping it is reported as skipped
# (near_miss_checked False) rather than as "no near misses found" --
# the same could-not-judge / judged-and-found-nothing distinction the
# rest of this file keeps.
checked = len(ns) <= 1200
if not checked:
return {'node_count': len(ns), 'member_count': len(model['members']),
'free_ends': free, 'near_misses': [], 'near_miss_total': None,
'near_miss_checked': False,
'interior_intersections_checked': False}
for a in range(len(ns)):
for b in range(a + 1, len(ns)):
d = _dist((ns[a]['x'], ns[a]['y'], ns[a]['z']),
(ns[b]['x'], ns[b]['y'], ns[b]['z']))
if tol < d <= 0.5:
near.append({'a': ns[a]['label'], 'b': ns[b]['label'],
'gap_ft': d})
near.sort(key=lambda r: r['gap_ft'])
return {
'node_count': len(ns),
'member_count': len(model['members']),
'free_ends': free,
'near_misses': near[:200],
'near_miss_total': len(near),
'near_miss_checked': True,
# A member end landing part-way along another member is NOT a
# connection here and is not searched for. Saying so is the point.
'interior_intersections_checked': False,
}
def notes(model):
"""The standing caveats, as sentences, carried inside the structure.
They travel with the data rather than living only in the button's report,
so that the JSON handed to somebody three months from now still says what
it does not contain.
"""
out = [
'Coordinates are Revit internal decimal FEET, in project coordinates.',
'END RELEASES ARE NOT EXTRACTED. Revit carries no reliable release '
'information for these precast families, so every member here is '
'released nowhere. Loaded as-is, an analysis program will treat every '
'joint as fully fixed, which for a precast lateral system is very '
'likely wrong. Set releases by hand.',
'SUPPORTS ARE NOT EXTRACTED. No boundary conditions are emitted at '
'all. The model as written is unrestrained and will not solve until '
'supports are added.',
'LOADS ARE NOT EXTRACTED. No load cases, combinations, diaphragms, '
'masses or seismic parameters are present.',
'Members are connected only where their END POINTS coincide within '
'{0} ft. A member ending part-way along another member is NOT '
'connected to it, and no node is inserted to make it so.'
.format(model['node_tol_ft']),
]
unres_sec = [m['label'] for m in model['members']
if any(u.startswith('section') or u.startswith('verified section')
for u in m['unresolved'])]
if unres_sec:
out.append('{0} member(s) have no verified section property. They are '
'not given one. See the unresolved list.'.format(len(unres_sec)))
unres_mat = [m['label'] for m in model['members'] if 'material' in m['unresolved']]
if unres_mat:
out.append('{0} member(s) have no material. They are not given one.'
.format(len(unres_mat)))
return out
def unresolved_rows(model):
"""One row per member per missing thing -- element by element, not counts."""
rows = []
for m in model['members']:
for what in m['unresolved']:
rows.append({'label': m['label'], 'element_id': m['element_id'],
'mark': m['mark'], 'missing': what})
return rows
def is_analysable(model):
"""False whenever loading this straight into analysis software would lie.
Never used to suppress the export -- the export is always written and
always labelled. Used to decide how loudly the report shouts.
"""
if not model or not model['members']:
return False
if any(m['unresolved'] for m in model['members']):
return False
if model['connectivity']['free_ends']:
return False
return True
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""Intermediate lateral model -> files. Revit-free, no I/O at module scope.
THREE OUTPUTS, AND WHY THE RISA ONE IS A DRAFT
----------------------------------------------
`emit_json` the intermediate structure verbatim. This is the ground
truth and the only output that claims to be complete.
`emit_csv` nodes and members as two plain tables. Any engineer can open
these, and any script can read them. No format risk at all.
`emit_risa_draft`
RISA-3D .r3d text. **DRAFT. NOT VERIFIED.**
What was established about the RISA-3D format, and from where:
RISA's own Appendix D - File Format
https://help.risa.com/risahelp/risa3d/Content/Appendices/Appendix%20D%20(3D).htm
* .r3d is plain ASCII, keyword driven, editable in Notepad.
* Data is divided into segments delimited by bracketed keyword labels:
[NODES], [MEMBERS], [PLATES], [BOUNDARY_CONDITIONS], [BASIC_LOAD_CASES],
[UNITS], [VERSION_NO], [PROGRAM_INFO], and the file opens with
[RISA-3D_INPUT_DATA].
* The record count follows the keyword label in angle brackets: `<n>`.
* Every record ends with a semicolon, except the project-description
records between [.PROJECT_DESCRIPTION] and [.END_PROJECT_DESCRIPTION].
* Labels are quoted strings. A member label may be up to 20 characters.
* A node record is "LABEL" x y z ... ;
* A member record is "LABEL" "DESIGN_LIST" "SHAPE_LABEL" iNode jNode ... ;
with later fields covering K node, rotation, section-set offset,
tension/compression, design rules, material, end releases, end offsets.
What was NOT established, and this is the reason for the word DRAFT:
The documentation designates the fields of each record by letter (a, b, c,
...) and the table that maps those letters to meanings could not be
retrieved -- the mirror that carries it returns 403, and no complete
worked example of a [NODES] or [MEMBERS] record was found in public
sources. So the field COUNT, the field ORDER, and the encoding of the
[UNITS] line are not known with confidence. Nor is which RISA version's
schema a given install expects.
Guessing them would produce a file that very plausibly opens and is
quietly wrong about which column is which -- the single worst outcome
available to this tool. So the RISA output is written with a
`.r3d.DRAFT.txt` extension. That is deliberate and is not a naming
accident: it cannot be double-clicked into RISA, and anybody who wants to
try it has to rename it, which is the moment they read the header.
To finish this properly: open any existing .r3d from this office in
Notepad, paste its [UNITS], [NODES] and [MEMBERS] segments into the repo
issue, and the emitter becomes a real one in about an hour. Until then the
CSV and the JSON are the outputs to use.
IronPython 2.7: no f-strings.
"""
# pylint: disable=invalid-name,broad-except
import json
from vibe import latmodel
# A shape label RISA cannot possibly resolve. Used wherever a section is
# unknown. The point is that RISA fails the member rather than analysing it
# with whatever the current default shape happens to be -- an unanalysable
# file beats a wrong answer.
UNRESOLVED_SHAPE = 'WILLTOOLS_UNRESOLVED_SECTION'
UNRESOLVED_MATERIAL = 'WILLTOOLS_UNRESOLVED_MATERIAL'
def emit_json(model):
"""The intermediate structure. Complete, and the only output that is."""
return json.dumps(model, indent=2, sort_keys=True)
def _csv_cell(v):
if v is None:
return ''
s = v if isinstance(v, str) else str(v)
if any(c in s for c in ',"\n'):
return '"' + s.replace('"', '""') + '"'
return s
def _csv(header, rows):
lines = [','.join(header)]
for r in rows:
lines.append(','.join([_csv_cell(c) for c in r]))
return '\n'.join(lines) + '\n'
def emit_nodes_csv(model):
"""label, x, y, z, unit, how many members meet here."""
rows = [[n['label'], '{0:.6f}'.format(n['x']), '{0:.6f}'.format(n['y']),
'{0:.6f}'.format(n['z']), model['length_unit'],
len(n['members']), ' '.join(n['members'])]
for n in model['nodes']]
return _csv(['label', 'x', 'y', 'z', 'unit', 'member_count', 'members'],
rows)
def emit_members_csv(model):
"""One row per member, with the blanks LEFT BLANK.
An empty `section` cell is the honest representation of a section this
could not read. It is never filled with a plausible-looking default, and
`unresolved` on the same row says the same thing in words so that a
reader skimming for problems finds them without comparing columns.
"""
rows = []
for m in model['members']:
sec = m.get('section') or {}
rows.append([
m['label'], m['i_node'], m['j_node'],
'{0:.6f}'.format(m['length_ft']), model['length_unit'],
sec.get('name'), sec.get('source'),
m.get('material'),
'', # end releases: never extracted
m.get('axis_source'),
m.get('category'), m.get('mark'), m.get('element_id'),
m.get('desc'), m.get('token'),
'; '.join(m.get('unresolved') or []),
])
return _csv(['label', 'i_node', 'j_node', 'length', 'unit',
'section', 'section_source', 'material', 'end_releases',
'axis_source', 'revit_category', 'mark', 'revit_element_id',
'identity_description', 'marker_matched', 'unresolved'],
rows)
DRAFT_HEADER = """\
! =====================================================================
! DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE. DO NOT LOAD AND TRUST.
! =====================================================================
!
! Written by WillTools lateral extraction from a Revit model.
!
! The section keywords, the <n> record counts, the semicolon terminators
! and the quoted labels below follow RISA's published Appendix D. The
! FIELD ORDER inside each record does NOT: Appendix D names those fields
! only by letter and the table mapping letters to meanings could not be
! obtained. The numbers below are in the right sections and are very
! likely in the wrong columns.
!
! This file is named .r3d.DRAFT.txt so that it cannot be opened into RISA
! by accident. Renaming it to .r3d is a decision, not a convenience.
!
! WHAT IS NOT IN THIS FILE, AT ALL:
! * end releases -- not extractable from these Revit families
! * boundary conditions / supports -- none. This model is unrestrained.
! * loads, load combinations, diaphragms, mass, seismic parameters
! * plates, walls as shells (walls appear as line members only)
! * member offsets, K-node orientation, unbraced lengths, design rules
!
! Use the .json and the two .csv files beside this one. They are complete
! statements of what was extracted; this file is not.
!
! Length unit: {unit} (Revit internal decimal feet, project coordinates)
! Node weld tolerance: {tol} ft
! ---------------------------------------------------------------------
"""
def emit_risa_draft(model):
"""RISA-3D-shaped text, clearly and repeatedly labelled as a draft."""
L = []
L.append(DRAFT_HEADER.format(unit=model['length_unit'],
tol=model['node_tol_ft']))
L.append('[RISA-3D_INPUT_DATA]')
L.append('')
L.append('[.PROJECT_DESCRIPTION]')
L.append('DRAFT lateral extraction -- field order unverified')
L.append('Model: {0}'.format(model.get('model_title') or '(unnamed)'))
L.append('[.END_PROJECT_DESCRIPTION]')
L.append('')
# [UNITS] is deliberately NOT emitted as data. Its field encoding is the
# part of the format least established, and a units line in the wrong
# order is the most dangerous single line the file could contain -- it
# rescales everything silently. A comment says what the numbers are in.
L.append('! [UNITS] omitted on purpose -- the encoding of this line is')
L.append('! not established, and a wrong units line rescales the whole')
L.append('! model without any visible sign. All lengths below are FEET.')
L.append('')
L.append('[NODES] <{0}>'.format(len(model['nodes'])))
for n in model['nodes']:
L.append(' "{0}" {1:.6f} {2:.6f} {3:.6f} ;'
.format(n['label'], n['x'], n['y'], n['z']))
L.append('[END_NODES]')
L.append('')
L.append('[MEMBERS] <{0}>'.format(len(model['members'])))
for m in model['members']:
sec = m.get('section') or {}
shape = sec.get('name') or UNRESOLVED_SHAPE
mat = m.get('material') or UNRESOLVED_MATERIAL
L.append(' "{0}" "" "{1}" "{2}" "{3}" "{4}" ; ! {5}'
.format(m['label'][:20], shape, mat,
m['i_node'], m['j_node'],
'unresolved: ' + ', '.join(m['unresolved'])
if m['unresolved'] else 'complete'))
L.append('[END_MEMBERS]')
L.append('')
L.append('! [BOUNDARY_CONDITIONS] intentionally absent -- no supports')
L.append('! were extracted. Nothing here is restrained.')
L.append('')
L.append('! ---- what was skipped, and why -----------------------------')
if model['skipped']:
for s in model['skipped']:
L.append('! skipped element {0} ({1}, mark {2}): {3}'
.format(s.get('element_id'), s.get('category'),
s.get('mark'), s.get('reason')))
else:
L.append('! nothing was skipped.')
L.append('')
for note in model['notes']:
L.append('! NOTE: ' + note)
return '\n'.join(L) + '\n'
def file_set(model, stem):
"""[(filename, text)] -- everything this tool writes, in one place.
The RISA file is last and its name carries the warning, so that a
directory listing sorted any way still shows the complete outputs first.
"""
return [
(stem + '-lateral.json', emit_json(model)),
(stem + '-lateral-nodes.csv', emit_nodes_csv(model)),
(stem + '-lateral-members.csv', emit_members_csv(model)),
(stem + '-lateral.r3d.DRAFT.txt', emit_risa_draft(model)),
]
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
# -*- coding: utf-8 -*-
u"""The Design Criteria section of the Panel Analysis input form.
WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three
times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like
this, where would a user even understand it), "现在这个panel analysis做的也太丑
了" (even now it is still too ugly) -- and then gave the fix in one sentence:
"全部给我做成普通人能看懂的样子", make every part of it something an ordinary
person can understand. The form is being rebuilt to look like Eriksson Wall's
own printed pages, split one Eriksson section per file so eight agents can
build it at once without stepping on each other. This file owns exactly one
of those sections: **Design Criteria**, which on Eriksson's own printout is
four blocks stacked on one page -- Project Information, Material Properties,
Load Combinations, Settings -- not four separate sections. `page()` below
returns all four, in that order, as one flat block list.
`page()` NEVER RAISES. A form button that throws mid-render leaves the owner
looking at a stack trace instead of a form, which is worse than an ugly form
-- so every lookup here is defensive, and anything this module could not lay
out is named in the `problems` list it returns instead.
THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF
renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`,
`table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as
nothing, so a typo here is a silently missing block, not an error. There is
no test for "the renderer draws this correctly" (there is no renderer to run
against on host CPython, and Revit is not running for this module to check
against) -- only for "every block this function returns is a well-formed
member of that vocabulary."
WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol`
/ `hint` for every field, rather than importing them from
`vibe.panelanalysisform`. Two reasons, both load-bearing:
1. Another agent is restructuring `panelanalysisform.FIELDS` while this file
is being written, so importing text from it ties this module's stability
to a moving target -- exactly what "the block list is stable" (tested
below) is supposed to rule out.
2. Roughly half of what Eriksson's own Design Criteria page prints has NO
key in `panelanalysisform.FIELDS` at all yet (project identity, concrete
elastic modulus, deflection-limit checkboxes, stress-limit checkboxes,
resistance-factor splits, development multipliers by strand type, ...).
Those need a label and a hint regardless of whether the backing field
exists, so authoring locally is the only option that covers both cases the
same way.
`panelanalysisform` IS still consulted, but only for one yes/no fact per
field: does this key already exist in `FIELDS`. That answer decides EXISTS
vs. MISSING for the `problems` list, per the owner's instruction that a field
whose key does not exist yet degrades to an empty editor plus a named
problem, never to a vanished block. The import is wrapped in `try/except
Exception` (not just `ImportError`) because a sibling agent editing that file
right now can leave it syntactically broken for a moment, and this module
must keep rendering through that -- "could not judge whether the key exists"
degrades to "treat it as missing", the same discipline CLAUDE.md's
`same_face()` example applies to an unrecognised abbreviation.
WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH
`columns`, NOT `table`. Eriksson prints both as grids, and the block
vocabulary's `table` kind was the first thing tried -- but a `table` cell is
only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no
way to put a live numeric/text editor inside one. Every value in those two
grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is
something the user can actually type, so they are laid out as `columns` of
`field` blocks that mirror Eriksson's rows and column groupings instead. The
Load Combinations tables, by contrast, use `table` for real: every row is a
fixed ACI 318-14 coefficient (public code text, not this job's data -- see
"psi vs. ksi" below for the same reasoning applied to a different table) and
the only per-row editable thing is the Include tick, which the `table`
vocabulary already supports natively.
PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength
keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in
ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not
perfectly consistent about it, and this file does not try to fix that, only
to read it correctly field by field). Eriksson prints everything in **ksi**.
Per the assignment: keep the psi keys as the source of truth and put the ksi
reading in the field's `symbol` or `hint` instead of introducing a second,
parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and
`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this
divided by 1000, in ksi" so the two reconcile by eye against a printed sheet;
the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`,
`strand_ep_ksi`) need no such note.
WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS.
On Eriksson's own page these are not something the user types on THIS page at
all -- they are a computed caption ("Design contains no exterior soil loads")
that reads back whatever soil-load rows exist elsewhere in the model. Nothing
in this codebase collects soil-load rows anywhere yet, so there is no field
for this section to degrade to an "empty editor" for -- there is no editor to
have. Rendering it as a static `sentence` instead of inventing a text field
that would silently claim to collect something it cannot is the same
discipline as `knowledge.same_face()` returning `None` rather than `False`:
"cannot judge, and does not pretend to." One `problems` entry names this once
for both columns, so the gap is visible without repeating the same sentence
twice.
THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a
checkbox that turns a calculation on, printed beside the sentence that
applies when it is off ("R-Value not being calculated"). Both checkbox keys
are new (`design_criteria.rvalue_calc_enabled`,
`design_criteria.min_reinf_checked` -- see the naming note below) and both
degrade the same way every other missing key does: an empty `check` editor
plus a named `problems` entry, with the off-state sentence always shown
underneath since "unchecked" is this page's honest default until the field
exists to say otherwise.
NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted
namespacing convention for one composite input (`stage.<stage_slug>.<opt>`,
see `stage_field_key`). New keys here that do not fit a single flat name
follow the same shape: `design_criteria.<thing>` for one-off Settings/Project
fields (e.g. `design_criteria.analysis_beta_d`), and
`load_combo.<strength|service>.<n>.include` /
`load_combo.<strength|service>.min_earth_factor` for the Load Combinations
grid, mirroring `stage_field_key`'s `<group>.<slug>.<opt>` shape exactly.
IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting
is `'{0}'.format(...)`; every literal that carries the prime character or a
non-ASCII letter is a `u'...'` literal with the coding cookie above declaring
the file UTF-8, the same convention `panelanalysisform.py` uses throughout.
Imports NOTHING beyond the standard library and, defensively,
`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit,
no `panelsketch`, so this loads and runs on plain CPython the way
`tests/conftest.py` expects.
"""
# ------------------------------------------------------------- key existence
def _existing_keys():
"""The set of keys `panelanalysisform.FIELDS` already declares, or None.
None means "could not judge" -- the import failed, or the module's shape
changed out from under us mid-refactor -- and every caller below treats
None the same as "key not found": every field on this page degrades to
an empty editor plus a `problems` entry rather than silently assuming
the other agent's in-flight edit added the key. Broad `except Exception`
on purpose: a sibling agent saving a half-written `panelanalysisform.py`
can raise a SyntaxError, not just ImportError, and this page must keep
rendering through that.
"""
try:
from vibe import panelanalysisform as pf
return set(f['key'] for f in pf.FIELDS)
except Exception:
return None
# ------------------------------------------------------------------- fields
#
# One entry per field this page can lay out: key -> (label, symbol, hint,
# required, editor, choices). Authored locally -- see the module docstring's
# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the
# two keys `panelanalysisform` itself already gates the Run button on
# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching
# the rest of Design Criteria in that module.
_F = {
# --------------------------------------------------- Project Information
'project_name': (
'Project name', '',
'The name of the project this panel belongs to, for the record on '
'this printout. Leave blank if the project has no name yet.',
False, 'text', None),
'client_name': (
'Client name', '',
"The client this panel's project is for. Leave blank if unknown.",
False, 'text', None),
'project_description': (
'Project description', '',
'A short free-text description of the project. Leave blank if '
'there is nothing to add.',
False, 'text', None),
'project_location': (
'Project location', '',
'Where the project is built -- city, state, or site name. Leave '
'blank if unknown.',
False, 'text', None),
'engineer_initials': (
'Engineer of record', '',
'Who is responsible for this design. Leave blank if not yet '
'assigned.',
False, 'text', None),
'project_date': (
'Design date', '',
'The date this design applies to. Leave blank to leave it unset.',
False, 'text', None),
'job_number': (
'Job number', '',
"This job's internal tracking number, if it has one. Leave blank "
'if none has been assigned yet.',
False, 'text', None),
'design_spec': (
'Governing design code', '',
'Which code edition this design is checked against, e.g. '
'"ACI 318-14". Leave blank to leave it unstated.',
False, 'text', None),
# ------------------------------------------- Material Properties/Concrete
'fci_psi': (
'Concrete strength at release', u"f′ci (psi)",
u"How strong the concrete must be when the strand is cut or the "
u"panel is stripped from the bed -- usually lower than the 28-day "
u"strength. Eriksson prints this divided by 1000, in ksi. Leave "
u"blank if this panel is not prestressed.",
False, 'num', None),
'fc_psi': (
'Concrete strength at 28 days', u"f′c (psi)",
u"The concrete's design strength once fully cured; every stress "
u"limit on this page is a multiple of this number. Eriksson prints "
u"this divided by 1000, in ksi. Required -- the solve cannot run "
u"without it.",
True, 'num', None),
'conc_eci_psi': (
'Concrete elastic modulus at release', u"E′ci (psi)",
u"How stiff the concrete is at release strength. Not yet used by "
u"the solve. Leave blank if unknown -- it can be computed from "
u"f′ci once a rule for that is agreed.",
False, 'num', None),
'conc_ec_psi': (
'Concrete elastic modulus, in-place', u"E′c (psi)",
u"How stiff the concrete is once fully cured. Not yet used by the "
u"solve. Leave blank if unknown.",
False, 'num', None),
'unit_weight_pcf': (
'Concrete unit weight', 'pcf',
"How much the concrete weighs per cubic foot; the panel's own "
'weight is computed from this. Required.',
True, 'num', None),
'concrete_type': (
'Concrete type', '',
'What kind of concrete this panel is cast from (normal weight, '
'lightweight, ...). Reference only -- nothing computes from it '
'yet.',
False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')),
# ----------------------------------------------- Material Properties/Bar
'rebar_fy_ksi': (
'Rebar yield strength', 'fy (ksi)',
'The stress at which ordinary (non-prestressed) reinforcing bar '
'starts to yield. Shown for the record; not yet used by the '
'solve.',
False, 'num', None),
'rebar_es_ksi': (
'Rebar elastic modulus', 'Es (ksi)',
'How stiff ordinary reinforcing steel is. Shown for the record; '
'not yet used by the solve.',
False, 'num', None),
'ps_fpu_ksi': (
u'Strand ultimate strength', u'fₚᵤ (ksi)',
'The breaking strength of the prestressing strand. Leave blank if '
'this panel is not prestressed.',
False, 'num', None),
'strand_ep_ksi': (
'Strand elastic modulus', 'Ep (ksi)',
'How stiff the prestressing strand is. Shown for the record; not '
'yet used by the solve.',
False, 'num', None),
# -------------------------------------------------------- Load combos
'design_criteria.min_earth_factor_strength': (
'Minimum earth factor, strength combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'strength combinations below. Leave blank to use none.',
False, 'num', None),
'design_criteria.min_earth_factor_service': (
'Minimum earth factor, service combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'service combinations below. Leave blank to use none.',
False, 'num', None),
# --------------------------------------------------- Settings/Deflection
'deflection_limit': (
'Typical deflection limit', 'L / n',
'How much the panel is allowed to bow or sag, as a fraction of its '
'span, e.g. "150" for L/150. Not yet used by the solve.',
False, 'text', None),
'design_criteria.deflection_add_bow': (
'Add an initial bow to reported deflections?', '',
'Whether the panel is assumed to already be slightly bowed before '
'any load is applied, and that starting bow is added to every '
'reported deflection. Leave unchecked to assume none.',
False, 'check', None),
'design_criteria.deflection_additional_bow_in': (
'Additional bow assumed', 'in',
'How much starting bow to add, if the box above is checked. Leave '
'blank for none.',
False, 'num', None),
'design_criteria.deflection_check_cantilever': (
'Check deflections on cantilevers?', '',
'Whether cantilevered portions of the panel get their own '
'deflection check, separate from the typical limit above. Leave '
'unchecked to skip it.',
False, 'check', None),
'design_criteria.deflection_cantilever_limit': (
'Cantilever deflection limit', 'L / n',
'The separate deflection limit for cantilevered portions, if the '
'box above is checked, e.g. "75" for L/75.',
False, 'text', None),
# ---------------------------------------------- Settings/Structural Anal.
'second_order': (
'2nd-order effects considered?', '',
'Whether P-delta / second-order bending is considered for this '
'panel. Not yet used by the solve.',
False, 'text', None),
'design_criteria.analysis_beta_d': (
u'Sustained-load ratio for 2nd-order analysis', u'βd',
'The fraction of the axial load that is sustained (long-term), '
'used by the 2nd-order analysis above. Leave blank if not '
'applicable.',
False, 'num', None),
'design_criteria.cracking_method': (
'Cracking method', '',
'Which published method decides whether the section is treated as '
'cracked. Leave blank if not applicable.',
False, 'text', None),
'design_criteria.cracking_limit_sqrt_fc': (
u'Cracking stress limit', u"n·√f′c",
u"The stress, as a multiple of √f′c, at which the "
u"section is assumed to crack. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'design_criteria.rvalue_calc_enabled': (
'Calculate an R-value for this panel?', '',
'Whether an insulation R-value is calculated for this panel. Leave '
'unchecked if it is not being calculated -- that is the default.',
False, 'check', None),
'design_criteria.min_reinf_checked': (
'Check minimum reinforcement?', '',
'Whether the ACI minimum-steel requirement is checked for this '
'panel. Leave unchecked if it is not being checked -- that is the '
'default.',
False, 'check', None),
# -------------------------------------------------- Settings/Stress opts
'design_criteria.stress_failure_if_cracked': (
'Fail the panel if it cracks?', '',
'Whether a cracked section is treated as an outright failure '
'rather than just a flag. Leave unchecked to only flag it.',
False, 'check', None),
'design_criteria.stress_ignore_lateral_ecc': (
'Ignore lateral strand eccentricity?', '',
'Whether side-to-side strand placement is ignored when computing '
'stress. Leave unchecked to include it.',
False, 'check', None),
'design_criteria.stress_use_principal_axis': (
'Use the principal axis for stress?', '',
'Whether stress is computed about the section\'s principal axis '
'rather than its geometric one. Leave unchecked to use the '
'geometric axis.',
False, 'check', None),
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
'Modulus of rupture, in place', u"n·√f′c",
u"The concrete's tensile cracking stress once erected, as a "
u"multiple of √f′c. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'inplace_comp_ratio': (
'Compression allowed once erected', u"n·f′c",
'The compression limit for the panel once it is standing in '
'place, as a fraction of f′c. Not yet used by the solve.',
False, 'num', None),
'handling_tension_sqrt': (
'Tension allowed while handling', u"n·√f′ci",
u"How much the concrete may be pulled apart during stripping / "
u"handling, as a multiple of √f′ci. Blank uses the "
u"built-in default.",
False, 'num', None),
'handling_comp_ratio': (
'Compression allowed while handling', u"n·f′ci",
u"How much the concrete may be squeezed during handling, as a "
u"fraction of f′ci. Blank uses the built-in default.",
False, 'num', None),
'design_criteria.flexure_method': (
'Flexure calculation method', '',
'Which method computes bending capacity (e.g. a stress-block '
'method). Leave blank to use the built-in default.',
False, 'text', None),
'design_criteria.flexure_ignored_end_distance_in': (
'Ignored end distance', 'in',
'A length at each end of the panel excluded from the flexure '
'check (strand not yet developed there). Leave blank for none.',
False, 'num', None),
'design_criteria.flexure_cracking_check': (
'Flexural cracking check', '',
'Which cracking check applies to the flexure calculation, if any. '
'Leave blank for none.',
False, 'text', None),
# ------------------------------------------------ Settings/Resist. Fact.
'phi_flexure': (
'Bending strength reduction factor, developed', u'φ, tension',
"The ACI safety factor applied to the panel's bending strength "
'once the strand is fully developed. Blank uses the built-in '
'default (0.90).',
False, 'num', None),
'design_criteria.phi_compression': (
'Compression strength reduction factor, developed',
u'φ, compression',
"The ACI safety factor applied to the panel's compression "
'strength once the strand is fully developed. Leave blank to use '
'the built-in default.',
False, 'num', None),
'phi_shear': (
'Shear strength reduction factor, developed', u'φ, shear',
"The ACI safety factor applied to the panel's shear strength once "
'the strand is fully developed. Blank uses the built-in default '
'(0.75).',
False, 'num', None),
'design_criteria.phi_tension_transfer': (
'Bending strength reduction factor, transferring',
u'φ, tension',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'design_criteria.phi_shear_transfer': (
'Shear strength reduction factor, transferring', u'φ, shear',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'dev_mult': (
'Strand development multiplier, normal', '',
'A multiplier on the strand development length for ordinary '
'(non-debonded, uncut) strand. Blank uses the built-in default '
'(1.0).',
False, 'num', None),
'design_criteria.dev_mult_debonded': (
'Strand development multiplier, debonded', '',
'The same multiplier, for strand that is deliberately debonded '
'over part of its length. Leave blank to use the built-in '
'default.',
False, 'num', None),
'design_criteria.dev_mult_cut': (
'Strand development multiplier, cut', '',
'The same multiplier, for strand that is field-cut rather than '
'released normally. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.dev_mult_mild': (
'Development multiplier, mild reinforcement', '',
'The same idea applied to ordinary (non-prestressed) reinforcing '
'bar. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.shear_treat_as_column': (
'Treat this panel as a column for shear?', '',
'Whether the shear check uses column rules rather than wall '
'rules. Leave unchecked to use wall rules.',
False, 'check', None),
'design_criteria.axial_ignore_tensile_failures': (
'Ignore axial tensile-strength failures?', '',
'Whether the axial check is allowed to pass even when the '
"concrete's tensile strength alone is exceeded. Leave unchecked "
'to flag them.',
False, 'check', None),
'design_criteria.axial_ignore_buckling_failures': (
'Ignore buckling failures?', '',
'Whether the axial check is allowed to pass even when the panel '
'would buckle. Leave unchecked to flag them.',
False, 'check', None),
}
# Prestress-loss group: shown as a sentence when every one of these is blank
# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here"
# style for Exterior/Interior Soil, applied to the one other Design Criteria
# group that is entirely optional and entirely empty on a fresh panel.
_PRESTRESS_LOSS_FIELDS = (
'ps_loss_method', 'ps_loss_humidity_pct',
'ps_loss_release_days', 'ps_loss_erection_days',
)
_F['ps_loss_method'] = (
'Prestress loss method', '',
'Which published method (PCI, AASHTO, ...) the assumed loss below '
'comes from. Reference only.',
False, 'text', None)
_F['ps_loss_humidity_pct'] = (
'Ambient humidity assumed', '%',
'The relative humidity the loss method uses -- losses are larger in '
'drier air. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_release_days'] = (
'Days from casting to release', '',
'How many days after casting the strand is cut / the panel is '
'stripped. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_erection_days'] = (
'Days from casting to erection', '',
'How many days after casting the panel is erected in place. Not yet '
'used by the solve.',
False, 'num', None)
def _field(key, problems, seen, existing):
"""One `field` block for `key`, plus a `problems` entry if it is missing.
`seen` de-dupes: several groups below reuse a couple of keys (none do
today, but the guard costs nothing and keeps a future edit from quietly
filing the same problem twice). `existing` is `_existing_keys()`'s
result, threaded through rather than re-read per field so one broken
import during a render is reported once, not dozens of times.
"""
spec = _F.get(key)
if spec is None:
# Programming error inside this file, not a missing form key --
# still degrade rather than raise, and say so plainly.
problems.append(
"Design Criteria has no text authored for the key '{0}' -- "
'this is a bug in pa_criteria.py, not a missing form field.'
.format(key))
return {'kind': 'field', 'key': key, 'label': key, 'symbol': None,
'hint': None, 'required': False, 'editor': 'text',
'choices': None}
label, symbol, hint, required, editor, choices = spec
if existing is None or key not in existing:
if key not in seen:
seen.add(key)
problems.append(
"'{0}' ({1}) has no backing field in panelanalysisform.FIELDS "
'yet -- shown as an empty editor until it is added.'
.format(label, key))
return {'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint, 'required': required,
'editor': editor, 'choices': list(choices) if choices else None}
def _heading(text, level):
return {'kind': 'heading', 'text': text, 'level': level}
def _cols(columns):
return {'kind': 'columns', 'columns': columns}
def _sentence(text):
return {'kind': 'sentence', 'text': text}
def _rule():
return {'kind': 'rule'}
def _space(h=1):
return {'kind': 'space', 'h': h}
def _group_or_sentence(values, keys, empty_text, build_filled):
"""[blocks] -- a `sentence` when every key in `keys` is blank, else
whatever `build_filled()` returns.
This is the "nothing here vs. never asked" split applied at group
granularity: `keys` all being blank in `values` is a real, filled-in
answer ("nothing entered"), not the absence of a form -- so it is shown
as a plain sentence instead of a wall of empty boxes, the same way
Eriksson's own printout shows a caption instead of blank inputs when
there is nothing to report. `values` is read with `.get(...)`, never
indexed, and never mutated.
`build_filled` is a zero-argument CALLABLE, not a plain list -- the
filled-in branch typically calls `F(key)`, which files a `problems`
entry for any key still missing from `panelanalysisform.FIELDS`, and
that side effect must only happen for fields actually being shown. A
plain list argument would be built eagerly by the caller before this
function ever looks at `keys`, filing a phantom "missing field" problem
for a field the empty-sentence branch never renders.
"""
for k in keys:
v = values.get(k)
if v is not None and str(v).strip():
return build_filled()
return [_sentence(empty_text)]
# --------------------------------------------------------- load combinations
#
# Standard ACI 318-14 basic strength/service combinations -- public code
# text, the same eight/ten rows any ACI-compliant tool would print, not this
# job's data. Kept as a fixed reference table (see the module docstring's
# "WHY THE 3-COLUMN CONCRETE GRID ... `columns`, NOT `table`" note for why
# THIS table, unlike the material grids, is a real `table` block): the only
# thing a user edits per row is which combinations apply, via the Include
# tick, and `table` already carries a native check-editor cell for that.
_STRENGTH_COMBOS = (
('1', '1.4', '0', '0', '0', '0', '1', '1.6'),
('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'),
('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'),
('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'),
('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'),
('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'),
('7', '0.9', '0', '0', '1', '0', '1', '1.6'),
('8', '0.9', '0', '0', '0', '1', '1', '1.6'),
)
_SERVICE_COMBOS = (
('1', '1', '0', '0', '0', '0', '0.75', '1'),
('2', '1', '1', '0', '0', '0', '0.75', '1'),
('3', '1', '0', '1', '0', '0', '0.75', '1'),
('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'),
('5', '1', '0', '0', '0.6', '0', '0.75', '1'),
('6', '1', '0', '0', '0', '0.7', '0.75', '1'),
('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'),
('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'),
('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'),
('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'),
)
_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include']
def _combo_table(name, rows, problems):
table_rows = []
for row in rows:
n = row[0]
include_key = 'load_combo.{0}.{1}.include'.format(name, n)
table_rows.append(list(row) + [{'editor': 'check', 'key': include_key}])
problems.append(
"the Include tick boxes in the {0} load-combination table have no "
"backing fields yet (load_combo.{0}.<n>.include) -- shown as empty "
"check boxes.".format(name))
return {'kind': 'table', 'headers': list(_COMBO_HEADERS),
'header_groups': None, 'rows': table_rows, 'row_key': None}
# --------------------------------------------------------------------- page
def page(values, reads=None):
"""(blocks, problems) for the Design Criteria section.
`values` is the form's current string-valued dict, read defensively --
every lookup goes through `.get(...)` and a key this module expects but
does not find is treated as blank, never as an error, because another
agent is restructuring `panelanalysisform.FIELDS` at the same time this
is being written. `values` is never mutated. `reads` is optional and, on
this section, only used for the one-line "Panel <mark>" context caption
at the top -- everything else on Design Criteria is job-level or
material data, not something read off the framing element.
Never raises: every lookup that could fail (the `panelanalysisform`
import, a missing dict key) degrades to a `problems` entry instead.
"""
values = values if isinstance(values, dict) else {}
reads = reads if isinstance(reads, dict) else {}
problems = []
seen = set()
existing = _existing_keys()
if existing is None:
problems.append(
'could not read panelanalysisform.FIELDS to check which keys '
'already exist (the module may be mid-edit); every field on '
'this page is shown, and is reported as missing until this is '
'confirmed.')
def F(key):
return _field(key, problems, seen, existing)
blocks = []
blocks.append(_heading('Design Criteria', 1))
mark = (reads.get('mark') or '').strip()
design_number = (reads.get('design_number') or '').strip()
if mark or design_number:
blocks.append({'kind': 'kv', 'rows': [
('Panel', mark or '(mark not read)'),
('Design number', design_number or '(not read)'),
]})
# ----------------------------------------------------- Project Information
blocks.append(_heading('Project Information', 2))
blocks.append(_cols([
[F('project_name'), F('client_name'), F('project_description')],
[F('project_location'), F('engineer_initials')],
[F('project_date'), F('job_number'), F('design_spec')],
]))
blocks.append(_rule())
# ------------------------------------------------------ Material Properties
blocks.append(_heading('Material Properties', 2))
blocks.append(_heading('Concrete', 3))
blocks.append(_cols([
[F('fci_psi'), F('fc_psi')],
[F('conc_eci_psi'), F('conc_ec_psi')],
[F('unit_weight_pcf'), F('concrete_type')],
]))
blocks.append(_space(1))
blocks.append(_cols([
[_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')],
[_heading('Mesh', 3),
_sentence('No mesh material properties are collected on this '
'form yet.')],
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
]))
blocks.append(_rule())
# ---------------------------------------------------------- Prestress Loss
blocks.append(_cols([
[_heading('Prestress Losses', 2)] +
_group_or_sentence(
values, _PRESTRESS_LOSS_FIELDS,
'No prestress loss assumptions entered for this panel.',
lambda: [F(k) for k in _PRESTRESS_LOSS_FIELDS]),
]))
blocks.append(_rule())
# -------------------------------------------------------------- Soil loads
#
# `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR
# SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing
# to degrade to an editor for here.
problems.append(
'soil loads are not collected anywhere in this form yet; Exterior '
'Soil and Interior Soil always read as "no loads" until a later '
'stage adds that input.')
blocks.append(_cols([
[_heading('Exterior Soil', 2),
_sentence('Design contains no exterior soil loads.')],
[_heading('Interior Soil', 2),
_sentence('Design contains no interior soil loads.')],
]))
blocks.append(_rule())
# ----------------------------------------------------------- Load Combos
blocks.append(_heading('Load Combinations', 1))
blocks.append(_cols([
[_heading('Strength Combinations', 2),
_combo_table('strength', _STRENGTH_COMBOS, problems),
F('design_criteria.min_earth_factor_strength')],
[_heading('Service Combinations', 2),
_combo_table('service', _SERVICE_COMBOS, problems),
F('design_criteria.min_earth_factor_service')],
]))
blocks.append(_rule())
# --------------------------------------------------------------- Settings
blocks.append(_heading('Settings', 1))
col_a = [
_heading('Deflection Limits', 2),
F('design_criteria.deflection_add_bow'),
F('design_criteria.deflection_additional_bow_in'),
F('deflection_limit'),
F('design_criteria.deflection_check_cantilever'),
F('design_criteria.deflection_cantilever_limit'),
_heading('Structural Analysis', 2),
F('second_order'),
F('design_criteria.analysis_beta_d'),
F('design_criteria.cracking_method'),
F('design_criteria.cracking_limit_sqrt_fc'),
F('design_criteria.rvalue_calc_enabled'),
] + _group_or_sentence(
values, ('design_criteria.rvalue_calc_enabled',),
'R-value is not being calculated for this panel.', lambda: []) + [
F('design_criteria.min_reinf_checked'),
] + _group_or_sentence(
values, ('design_criteria.min_reinf_checked',),
'Minimum steel is not being checked for this panel.', lambda: [])
col_b = [
_heading('Stress Options', 2),
F('design_criteria.stress_failure_if_cracked'),
F('design_criteria.stress_ignore_lateral_ecc'),
F('design_criteria.stress_use_principal_axis'),
_heading('In-Place Stress Limits', 2),
F('design_criteria.inplace_modulus_rupture_sqrt_fc'),
F('inplace_comp_ratio'),
_heading('Handling Stress Limits', 2),
F('handling_tension_sqrt'),
F('handling_comp_ratio'),
_heading('Flexure Options', 2),
F('design_criteria.flexure_method'),
F('design_criteria.flexure_ignored_end_distance_in'),
F('design_criteria.flexure_cracking_check'),
]
col_c = [
_heading('Resistance Factors', 2),
_cols([
[_heading('Developed', 3),
F('phi_flexure'), F('design_criteria.phi_compression'),
F('phi_shear')],
[_heading('Transferring', 3),
F('design_criteria.phi_tension_transfer'),
_sentence('Not applicable for compression once the strand '
'has fully transferred.'),
F('design_criteria.phi_shear_transfer')],
]),
_heading('Development Multipliers', 2),
F('dev_mult'),
F('design_criteria.dev_mult_debonded'),
F('design_criteria.dev_mult_cut'),
F('design_criteria.dev_mult_mild'),
_heading('Shear Options', 2),
F('design_criteria.shear_treat_as_column'),
_heading('Axial Options', 2),
F('design_criteria.axial_ignore_tensile_failures'),
F('design_criteria.axial_ignore_buckling_failures'),
]
blocks.append(_cols([col_a, col_b, col_c]))
return blocks, problems
+570
View File
@@ -0,0 +1,570 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Concrete Extents" section, laid out.
The owner has said three times the Panel Analysis form is too ugly to read
("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成
普通人能看懂的样子" -- every part of it has to be something an ordinary
person, not just an engineer, can understand. The fix agreed on is to make
the form look like Eriksson Wall's own printed page for this section (a solid
wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals /
Solid Zones table, and a drawings row) but with a plain-English `label`
beside every engineering `symbol` and a one-sentence `hint` on every field --
never the symbol alone, standing in as if it were the explanation.
`page(values, reads=None)` returns `(blocks, problems)`: a flat list of block
dicts from a fixed vocabulary a generic WPF renderer (built separately, not
here) consumes, plus a list of plain-English sentences for anything that
could not be laid out. This module never raises -- a field it cannot place
becomes a `problems` entry, never a traceback the owner has to read a
Python stack to understand.
WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to
load and run on plain CPython under `tests/conftest.py`'s trick (see that
file's docstring) the same way the rest of Revit-free `vibe` does, and it has
to run under IronPython 2.7 inside the button. `panelsketch` is named by
STRING in a `drawing` block's `source` field -- the renderer calls it, not
this module -- because a sibling agent is extending `panelsketch` at the same
time this file is being written, and importing a module mid-rewrite is how
two agents' work collides.
WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns
`vibe.panelanalysisform`, the source of the form's key names, and is
restructuring it at the same time this file is being written. A key this
module expects that is not actually there is "not present", never treated as
a bug -- `_paf_field` returns `None` and the caller falls back rather than
raising. The same discipline applies to the structured opening rows a third
agent is meant to be adding at `values['openings_rows']` (shaped
`{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross',
'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's
own contract): when it is not there yet, or is not a list, this module falls
back to the raw `values['openings']` text field and SAYS SO in `problems`,
rather than guessing at a shape that has not landed.
WHY THE SECTION PROPERTIES BLOCK OMITS MOST OF ERIKSSON'S OWN ROWS: Eriksson
prints Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb and St, but the last
seven need either an axis convention this module has no authority to invent,
or a net-of-openings deduction it does not compute. Printing a plausible
number in a column headed "St, in³" when nobody has verified it is exactly
the fabricated section modulus the owner has ruled against elsewhere in this
codebase (`CLAUDE.md` "no invented default" discipline) -- so this module
computes ONLY Area / Width / Height, from the panel's own width and
thickness, and names the rest in `problems` as omitted, never fills them in
with a guess.
WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT:
`panelsketch.elevation()`'s own docstring says it draws the front elevation
BESIDE a side elevation as one combined picture, not two separate ones.
Eriksson's page prints them as two separately captioned drawings, and this
module is asked to lay out a `columns` row of exactly that shape -- so it
does, naming `source: 'elevation'` under both the "Side Elevation" and
"Front Elevation" captions, and says in `problems` that until `panelsketch`
is split in two, both captions currently draw the same combined image. That
is a real, current limitation, not something to paper over by inventing a
`source` name that does not exist.
Miters get the same "print a sentence, never leave a blank" discipline
Eriksson uses on its own page (an unchecked "Left Miter" box there is not
just left bare -- it prints "Left miter not included in design" underneath).
`_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is
silent, because blank means "not yet decided", never "no" (same rule
`panelanalysisform._parse_bool` documents for its own checkboxes); a miter
answered explicitly "no" prints the sentence, in words, the way Eriksson
does.
"""
# `panelanalysisform` carries no Revit import at module scope (see its own
# docstring), so this succeeds on plain CPython under `tests/conftest.py`'s
# trick and under IronPython 2.7 alike. If the sibling agent restructuring it
# has, at some moment, left it unimportable, this module still has to load --
# so the import itself is guarded, and every reader of `paf` below is guarded
# again on top of that (see `_paf_field`).
try:
from vibe import panelanalysisform as paf
except ImportError:
paf = None
# --------------------------------------------------------------- utilities
def _num(value):
"""`value` (a form string, or already a number) as a float, or None.
None covers "blank", "not a number", and "not present" alike -- the
caller only ever needs to know whether there is a usable number, and
`validate()`-style error text is not this module's job (the button's
completeness gate already owns that; this module only lays out what it
is given).
"""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A float as the plain string this page prints -- '8', not '8.0'."""
if value is None:
return ''
if float(value) == int(value):
return str(int(value))
return ('%.4f' % value).rstrip('0').rstrip('.')
# Eighths-of-an-inch -> the fraction word Eriksson prints. A precast
# dimension finer than 1/8" is not meaningful, so `_feet_str` rounds to it
# rather than pretending to more precision than the field carries.
_EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2',
5: '5/8', 6: '3/4', 7: '7/8'}
def _feet_str(value):
"""Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2".
Internal units are decimal feet throughout this codebase (`CLAUDE.md`
§3), so a bare number arriving in a position/length field is read as
feet and converted here to the feet-inches form Eriksson prints (`8"`,
`2'-11.5"`). A value that is already a string is left untouched by the
caller (`_dim_or_dash`) rather than reparsed here -- a sibling agent may
hand this module an already-formatted dimension, and reformatting a
string that already reads correctly is how a "2'-11.5\"" becomes a wrong
"2'-11 1/2\"" glued onto a stray decimal.
Returns None only when `value` cannot be read as a number at all.
"""
try:
feet = float(value)
except (TypeError, ValueError):
return None
negative = feet < 0.0
feet = abs(feet)
whole_ft = int(feet)
inches = (feet - whole_ft) * 12.0
eighths_total = int(round(inches * 8.0))
whole_in, eighths = divmod(eighths_total, 8)
if whole_in >= 12:
whole_ft += whole_in // 12
whole_in = whole_in % 12
frac = _EIGHTHS[eighths]
if frac and whole_in:
in_part = '{0} {1}'.format(whole_in, frac)
elif frac:
in_part = frac
else:
in_part = str(whole_in)
if whole_ft > 0:
text = u'{0}\'-{1}"'.format(whole_ft, in_part)
else:
text = u'{0}"'.format(in_part)
return u'-' + text if negative else text
def _dim_or_dash(value):
"""A table cell for one dimension: Eriksson's own '-' for blank."""
if value is None:
return '-'
if isinstance(value, str):
value = value.strip()
return value if value else '-'
text = _feet_str(value)
return text if text is not None else '-'
def _from_word(word):
"""'start' -> 'Start'; blank/None -> ''. Never invents a datum word."""
if not word:
return ''
word = str(word).strip()
return word[:1].upper() + word[1:].lower() if word else ''
def _pos_cell(pos, frm):
"""A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'."""
dim = _dim_or_dash(pos)
if dim == '-':
return '-'
frm_text = _from_word(frm)
return '{0} from {1}'.format(dim, frm_text) if frm_text else dim
_TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal',
'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'}
def _type_label(value):
"""The Type column's text -- a known word capitalised Eriksson's way,
or, for anything this table does not recognise, the entered text back
in title case rather than a guessed category -- echoing what was typed
is honest; picking the "closest" known type is not.
"""
if not value:
return '-'
key = str(value).strip().lower().replace('_', ' ')
if key in _TYPE_LABELS:
return _TYPE_LABELS[key]
return str(value).strip().title() if str(value).strip() else '-'
def _tri_bool(text):
"""(True/False/None) for a checkbox's raw text. None = "not yet said".
Mirrors `panelanalysisform._parse_bool`'s own rule (blank is
"unspecified", never "no") without importing that private helper across
a module boundary neither module owns the other's internals of.
"""
if text is None:
return None
t = str(text).strip().lower()
if not t:
return None
if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'):
return True
if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded',
'unchecked'):
return False
return None
# --------------------------------------------------------- panelanalysisform
def _paf_field(key):
"""The raw field dict `panelanalysisform.FIELDS` has for `key`, or None.
None covers every way `paf` can fail to have the answer: the module
failed to import, it has no `FIELDS` (mid-restructure), or `key` is
simply not one it declares yet. All three are "not present", never an
error -- see the module docstring.
"""
if paf is None:
return None
try:
fields = paf.FIELDS
except AttributeError:
return None
try:
for f in fields:
if f.get('key') == key:
return f
except (TypeError, AttributeError):
return None
return None
def _field(key, editor, fallback_label, fallback_symbol, fallback_hint,
fallback_required, problems, choices=None):
"""One `field` block, sourced from `panelanalysisform` when it has the
key and from the fallback text when it does not.
A missing key still gets a field on the page -- the Eriksson layout this
section is copying has a slot there -- but `problems` names it, because
a value typed into a field `panelanalysisform` does not declare cannot
be saved into the deck yet. That is the honest state of a form being
restructured by another agent at the same time, not a bug in this file.
"""
spec = _paf_field(key)
if spec is not None and paf is not None:
try:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or fallback_hint
required = bool(spec.get('required'))
except AttributeError:
spec = None
if spec is None:
label = fallback_label
symbol = fallback_symbol or None
hint = fallback_hint
required = fallback_required
problems.append(
"{0!r} is not a field vibe.panelanalysisform declares yet; "
"shown here to match Eriksson's own Concrete Extents page, but "
"a value typed into it will not be saved until that module "
"adds this key.".format(key))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
def _miter_sentence(values, key, side):
"""Eriksson's own discipline: an OFF miter prints a sentence, not a gap.
Blank (unanswered) prints nothing here, same as Eriksson's page shows
nothing extra for a miter nobody has touched yet -- only an explicit
"no" earns the sentence, because blank means "not yet decided", not
"excluded" (see `_tri_bool`).
"""
if _tri_bool(values.get(key)) is False:
return {'kind': 'sentence',
'text': '{0} miter not included in this panel\'s design.'
.format(side)}
return None
# ------------------------------------------------------------- sub-sections
def _panel_geometry_columns(values, problems):
"""The three columns Eriksson's own Panel Geometry block prints:
(Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) |
(Width, Left Miter, Right Miter, Top In Form, Width, Height) |
(Thickness).
`length_ft` (this codebase's name for the panel's long dimension) is
what Eriksson calls "Height" on this page -- the same field, read
through `panelanalysisform`'s own label rather than renamed here, so a
reviewer sees the one name every section of the form uses for it.
"""
col1 = [
_field('top_elevation_ft', 'num', 'Top elevation', 'ft',
'How high the top edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft',
'How high the bottom edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('length_ft', 'num', 'Panel length', 'ft',
"The panel's long dimension, end to end. Required -- "
'everything downstream is measured against this span.',
True, problems),
_field('section_type', 'text', 'Section type', '',
'A short description of the cross-section (solid, '
'sandwich, ribbed, ...). Reference only.', False, problems),
_field('top_in_form', 'text', 'Which face is up in the casting '
'bed', 'Interior / Exterior',
'Which face of the panel faces up while it is cast. '
'Reference only.', False, problems,
choices=['Interior', 'Exterior']),
]
col2 = [
_field('width_ft', 'num', 'Panel width', 'ft',
"The panel's dimension across the span -- how wide it is. "
'Required.', True, problems),
_field('left_miter', 'check', 'Left miter', '',
'Whether the left end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems),
]
sentence = _miter_sentence(values, 'left_miter', 'Left')
if sentence:
col2.append(sentence)
col2.append(
_field('right_miter', 'check', 'Right miter', '',
'Whether the right end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems))
sentence = _miter_sentence(values, 'right_miter', 'Right')
if sentence:
col2.append(sentence)
col2.append(
_field('top_in_form_check', 'check',
'Top edge has a formed step', '',
'Whether the top edge of the panel has its own formed '
'step or insert built in, separate from which face faces '
'up (see "Which face is up" at left). Leave unchecked if '
'there is none.', False, problems))
col2.append(
_field('top_in_form_width_in', 'num', 'Formed step width', 'in',
'How wide the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
col2.append(
_field('top_in_form_height_in', 'num', 'Formed step height', 'in',
'How tall the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
col3 = [
_field('thickness_in', 'num', 'Panel thickness', 'in',
'How thick the panel is. Required.', True, problems),
]
return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2},
{'kind': 'columns', 'columns': [col1, col2, col3]}]
# The grouped header the Openings / Reveals / Solid Zones table prints,
# spanning the last 6 of its 7 columns: 'Type' stands alone at the left the
# way Eriksson's own page leaves it, and (2, 4) is what leaves the group
# spans adding up to exactly `len(headers) - 1`.
_OPENINGS_HEADERS = ['Type', 'Position', 'Length', 'Horizontal Position',
'Width', 'Vertical Position', 'Height']
_OPENINGS_GROUPS = [('Position along Length', 2), ('Position in Cross '
'Section', 4)]
def _openings_row_cells(row):
row = row or {}
return [
_type_label(row.get('type')),
_pos_cell(row.get('pos_along'), row.get('pos_along_from')),
_dim_or_dash(row.get('length')),
_pos_cell(row.get('pos_cross'), row.get('pos_cross_from')),
_dim_or_dash(row.get('width')),
_dim_or_dash(row.get('pos_vert')),
_dim_or_dash(row.get('height')),
]
def _openings_blocks(values, problems):
"""The Openings, Reveals, and Solid Zones heading + table (or fallback).
`values['openings_rows']` is the structured shape a sibling agent is
meant to be adding (see the module docstring); when it is missing, or
is not a list, this degrades to the raw `values['openings']` text field
and a `problems` entry -- never a guessed re-parse of that free text
into the grouped shape, which would be inventing datum words
("from Start" / "from Left") nobody has actually said.
"""
blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid '
'Zones', 'level': 2}]
rows = values.get('openings_rows') if values else None
if isinstance(rows, list):
table_rows = [_openings_row_cells(r) for r in rows]
blocks.append({'kind': 'table', 'headers': _OPENINGS_HEADERS,
'header_groups': _OPENINGS_GROUPS,
'rows': table_rows, 'row_key': None})
if not rows:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
problems.append(
"'openings_rows' is not present in the form values (or is not a "
'list), so the grouped Position-along-Length / Position-in-'
'Cross-Section table cannot be built; falling back to the raw '
"'openings' text field.")
raw = (values.get('openings') if values else None) or ''
raw = raw.strip() if isinstance(raw, str) else str(raw).strip()
if raw:
blocks.append({'kind': 'sentence',
'text': 'Openings were entered as plain text and '
'cannot be shown as the grouped table yet: '
'{0}'.format(raw)})
else:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
# Every Section Properties row Eriksson's own page prints. Only the first
# three (`_DERIVABLE_PROPS`) are ever computed here -- see the module
# docstring for why the rest are refused rather than guessed.
_DERIVABLE_PROPS = ('Area, in²', 'Width, in', 'Height, in')
_OMITTED_PROPS = ('Cx, in', 'Cy, in', 'Izz, in⁴', 'Iyy, in⁴',
'Izy, in⁴', 'Sb, in³', 'St, in³')
def _section_properties_column(values, problems):
"""The Section Properties kv list -- Area / Width / Height ONLY.
Both come straight from what the user entered (panel width, panel
thickness) with no engineering judgement in between, so they are the
only rows honest to print. `Height` here is Eriksson's name for the
section's own depth through the panel, i.e. the panel THICKNESS -- not
the panel's long dimension, which the Panel Geometry block above already
calls "Height" in its own different sense. Printing both under the same
word in two different places on the same page is exactly Eriksson's own
layout, confusing as that is; relabelling one of them would stop this
matching the page the owner asked for.
"""
col = [{'kind': 'heading', 'text': 'Section Properties', 'level': 3}]
width_ft = _num(values.get('width_ft'))
thickness_in = _num(values.get('thickness_in'))
if width_ft is not None and width_ft > 0 and \
thickness_in is not None and thickness_in > 0:
width_in = width_ft * 12.0
area_in2 = width_in * thickness_in
rows = [('Area, in²', _fmt_num(area_in2)),
('Width, in', _fmt_num(width_in)),
('Height, in', _fmt_num(thickness_in))]
col.append({'kind': 'kv', 'rows': rows})
problems.append(
'{0} are not shown: deriving them honestly needs an axis '
'convention and, wherever openings are present, a '
'net-of-openings deduction this module does not compute -- a '
'guessed section modulus is worse than none.'
.format(', '.join(_OMITTED_PROPS)))
else:
col.append({'kind': 'sentence',
'text': 'Section properties are waiting on Panel '
'width and Panel thickness; nothing is shown '
'until both are entered.'})
problems.append(
'{0} could not be derived -- Panel width and/or Panel '
'thickness is missing.'.format(
', '.join(_DERIVABLE_PROPS + _OMITTED_PROPS)))
return col
def _drawings_row(problems):
"""The Side Elevation / Front Elevation / Section Properties row.
Both drawings name `source: 'elevation'` -- see the module docstring's
"WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT" for
why that is today's correct, if unsatisfying, answer rather than a name
this module invents to look like two different pictures.
"""
problems.append(
"Side Elevation and Front Elevation both use panelsketch."
'elevation(), which currently draws the side and front views '
'together as ONE picture (per that function\'s own docstring) '
'rather than as two separate drawings; until panelsketch is split '
'in two, both captions below show the same combined image.')
side_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Side Elevation', 'w': 260, 'h': 520}]
front_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Front Elevation', 'w': 420, 'h': 520}]
return side_col, front_col
# ------------------------------------------------------------------ page()
def page(values, reads=None):
"""(blocks, problems) for the Concrete Extents section.
`values` is the form's current string-valued dict (plus, where a
sibling agent has landed it, the structured `openings_rows` list -- see
the module docstring). `reads` is what the button read straight off the
live framing element, the same shape `panelanalysisform.prefill` takes;
it is accepted for symmetry with the other section builders but not
read here -- everything this section needs is already folded into
`values` by the time `page` sees it, and reading `reads` again here
would risk showing a MODEL value beside a form value the user has since
typed over it, exactly the kind of stale-looking page the owner has
complained about.
Never raises. Every field this module cannot place is a `problems`
sentence, not a traceback -- see the module docstring for the two
running examples (a `panelanalysisform` key that is not there yet, and
an `openings_rows` shape that has not landed yet).
"""
values = values if values is not None else {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}]
blocks.extend(_panel_geometry_columns(values, problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.extend(_openings_blocks(values, problems))
blocks.append({'kind': 'space', 'h': 8})
side_col, front_col = _drawings_row(problems)
props_col = _section_properties_column(values, problems)
blocks.append({'kind': 'columns',
'columns': [side_col, front_col, props_col]})
blocks.append({'kind': 'space', 'h': 8})
blocks.append({'kind': 'drawing', 'source': 'section',
'caption': 'Cross Section', 'w': 900, 'h': 260})
return blocks, problems
+528
View File
@@ -0,0 +1,528 @@
# -*- coding: utf-8 -*-
"""The Panel Analysis form's "Handling" section, rendered as generic blocks.
WHY THIS EXISTS: the owner has now said three times that the Panel Analysis
input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能
看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂
的样子", make every part of it something an ordinary person can understand. The
"Handling" section is the worst offender: it used to be three lonely text
boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson
Wall's own printed page for this section is a whole per-stage MATRIX --
eleven questions asked once per handling stage, plus lifter/dunnage tables and
a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring
covers that ruling and the Eriksson-parity one before it) and now exposes a
real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`,
`stage_field_key`). This module is the second half: turning that data plus
the plain-language field metadata into the flat, renderer-agnostic BLOCK list
a generic WPF page consumes, matching Eriksson's own page 25/26 layout
(Stage Options matrix + legend + weight/centroid + plan drawing + lifter and
dunnage tables + Lifting Hardware) as closely as the two modules' real data
will honestly support.
THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this
module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' /
'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown
`kind` renders as nothing, so a typo here is a silently blank section rather
than a crash; `page()` is tested against exactly that vocabulary for that
reason.
READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and
was mid-restructure of exactly the stage matrix this file depends on while
this file was written. Every accessor this module leans on --
`STAGES` / `STAGE_OPTIONS` / `stage_field_key` / `FIELDS` / `label_of` /
`symbol_of` / `help_of` -- is reached through `getattr`/`hasattr` or a
try/except, never assumed. A shape that is not there yet (or has changed
shape) becomes one plain-English sentence in `problems`, and the rest of the
page still renders around the hole -- never a raised exception, and never an
invented value standing in for the one that could not be read. This is the
same "could not judge" vs "judged, found nothing" discipline as everywhere
else in this codebase (see CLAUDE.md SS3): a missing accessor is reported by
name, not silently skipped and not guessed at.
WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry
instead of a fabricated block: Eriksson's own "Handling" pages carry several
things `vibe.panelanalysisform` does not model yet --
* a per-lifter STRUCTURED row (quantity, longitudinal position, two
transverse positions, a tick per stage, "on top face") for Face / Side
Edge Lifters. The form only has `face_lifters` / `edge_lifters` as two
free-text fields ("column @ fraction of width", one line per lifter), so
the structured table is rendered as those two text fields instead of an
invented set of per-row keys nothing in the form can parse or validate.
* a dedicated "top edge lifters" field. There isn't one -- only
`face_lifters` / `edge_lifters` exist -- so the section renders a
sentence saying so, in the same voice Eriksson's own page uses for "no
top edge lifters on this design" (never a blank, per CLAUDE.md SS3's
"nothing here" vs "never asked" rule -- an omitted field and an empty
one must not look the same).
* a "Dunnage" field of any kind. Same treatment.
* the computed Panel Weight / Centroid block. `weight_lb` is a real field
(the user's override, or what the model could read) so its row renders
from `values`/`reads`; the three centroid components (X from start, Y
from bottom, Z from left) are not produced anywhere in this pipeline yet,
so those rows say so in plain words rather than printing a fabricated
0.000.
* the "Lifting Configurations" sketches on Eriksson's second page (one
small diagram per included stage, each with its own G/CG marker) and the
"Face Lifters / Name / Capacity Method" block above them. No
`panelsketch` entry point for either exists (only `plan` and
`weight_diagram` do, per the sibling agent adding them), and no
panelanalysisform field carries a lifter name or capacity-check method --
so both become one `problems` entry apiece and a short explanatory
sentence, not an invented drawing source or an invented field.
THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which
questions make sense for which stage, not a report of which stage happens to
be ticked on right now. Reading Eriksson's own page 25 by column: Rigging
Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt
Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the
two erection stages (Primary, Secondary) and dashed for Stripping and
Transportation in EVERY sample seen, independent of whether a given erection
stage is itself switched on -- a crane pick has a rigging configuration and a
tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below
is that fixed structural map. It is deliberately NOT keyed off each stage's
own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix`
is that switching a stage off must never make its OTHER cells un-requestable
or drop what the user already typed into them, and printing an em dash over a
cell the user could still type into would do exactly that. So the only cells
this module ever marks `{'na': True}` are ones no stage state can turn back
on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide.
Standard-library only. No Revit, no WPF, no `panelsketch` import (a source
name is enough -- see the block vocabulary's 'drawing' kind). Runs on
IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings,
no dataclasses/typing/enum, `'{0}'.format(...)` throughout.
"""
# Editor kind a FIELDS-style `kind` renders as, for the plain fields this
# section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb,
# self_weight_factor, face_lifters, edge_lifters). Anything not listed here
# falls back to a free-text box -- 'supports' / 'openings' / 'strands' are
# all typed as structured-but-still-one-text-box fields on the real form, and
# nothing in the Handling section uses them, but the fallback keeps this
# honest if that ever changes.
_EDITOR_OF_KIND = {
'text': 'text',
'num': 'num',
'int': 'num',
'supports': 'text',
'openings': 'text',
'strands': 'text',
}
# Fallback choice vocabulary for the free-text stage-matrix options, used
# ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no
# choices slot of its own (see `_unpack_stage_option`). Once the row carries
# its own choices, that is authoritative and this is never consulted -- two
# sources of truth for the same list would drift. These are OFFERED choices,
# never a default -- the current value still comes from whatever is in
# `values[key]`, blank if nothing was typed. Picking one is the user's
# decision; the owner's instruction is explicit that none of them stand in
# as a default.
_STAGE_CHOICES_FALLBACK = {
'lift_type': ['Static Lift', 'Rolling Block'],
'concrete_strength': ['Initial', 'In-Place'],
'stress_limits': ['Handling', 'In-Place'],
'rigging': ['Auto'],
}
def _unpack_stage_option(opt):
"""(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS
row, or None if the row cannot be read at all.
Defensive against exactly the kind of mid-refactor shape change this
module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot
on live disk between this module's first draft and this test run (a
7-tuple with the new slot, where the 6-tuple it replaced had none). Both
shapes are accepted; `choices` falls back to
`_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]`
for an option with no fixed vocabulary either way. `drives` (index 5 of
the 7-tuple) is not needed here -- NA-ness is decided structurally by
`_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked."""
try:
opt_key, label, symbol, help_text, kind, _drives, choices = opt
choices = list(choices) if choices else None
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
pass
try:
opt_key, label, symbol, help_text, kind, _drives = opt
choices = _STAGE_CHOICES_FALLBACK.get(opt_key)
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
return None
# The stage-matrix options that only make sense for a stage where the panel
# is actually being picked up and set (Primary / Secondary Erection). See the
# module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether
# a given stage is ticked on.
_ERECTION_ONLY = set([
'rigging', 'sling_angle', 'ground_reaction',
'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries',
])
_NOT_ERECTION_STAGES = set(['Stripping', 'Transportation'])
# Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS`
# by key, grouped the way this page presents them. Every key here is checked
# against the real module before use -- see `_field_block`.
_LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor')
_LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters')
def _import_paf():
"""The real `vibe.panelanalysisform`, or (None, reason).
A separate function so `page()` reads like the rest of this module: one
early check, one plain-English reason, never a raised ImportError.
"""
try:
from vibe import panelanalysisform as paf
except ImportError as exc:
return None, ('vibe.panelanalysisform could not be imported '
'({0}); the Handling section cannot be built at '
'all'.format(exc))
return paf, None
def _field_exists(paf, key):
"""Whether `key` is a real entry in `paf.FIELDS` right now.
Never assumes `FIELDS` itself is present or list-shaped -- the module
this reads from is being restructured by another agent as this is
written.
"""
fields = getattr(paf, 'FIELDS', None)
if not fields:
return False
try:
for f in fields:
if isinstance(f, dict) and f.get('key') == key:
return True
except TypeError:
return False
return False
def _field_kind(paf, key):
for f in getattr(paf, 'FIELDS', None) or []:
if isinstance(f, dict) and f.get('key') == key:
return f.get('kind') or 'text', bool(f.get('required'))
return 'text', False
def _field_block(paf, key):
"""A `{'kind': 'field', ...}` block for a real `panelanalysisform` key.
Caller must have already confirmed `_field_exists(paf, key)`. Labels,
symbols and hints all come from the module's own accessors rather than
being re-typed here, so this page can never drift from the field it is
describing -- and those accessors already degrade to the bare key if
something about the field record is missing, so this cannot raise.
"""
label = paf.label_of(key) if hasattr(paf, 'label_of') else key
symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else ''
hint = paf.help_of(key) if hasattr(paf, 'help_of') else ''
kind, required = _field_kind(paf, key)
editor = _EDITOR_OF_KIND.get(kind, 'text')
return {
'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint or None,
'required': required, 'editor': editor, 'choices': None,
}
def _row_label(label, symbol):
"""'label — symbol', matching `panelanalysisform.display_label`'s own
join -- the symbol rides beside the plain English, never replaces it."""
if symbol:
return u'{0} — {1}'.format(label, symbol)
return label
def _stage_options_editor(kind):
if kind == 'bool':
return 'check'
if kind == 'num':
return 'num'
return 'text'
def _stage_matrix_block(paf, problems):
"""The Stage Options matrix block, or None if it cannot be built.
Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing
or malformed piece is one `problems` sentence naming it, and a row that
cannot be unpacked is skipped rather than raising, so a partial
restructure of the source module degrades this one matrix instead of the
whole page.
"""
stages = list(getattr(paf, 'STAGES', ()) or ())
if not stages:
problems.append('vibe.panelanalysisform has no STAGES list yet; '
'the Stage Options matrix cannot be built')
return None
options = getattr(paf, 'STAGE_OPTIONS', None)
if not options:
problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list '
'yet; the Stage Options matrix cannot be built')
return None
stage_field_key = getattr(paf, 'stage_field_key', None)
if not callable(stage_field_key):
problems.append('vibe.panelanalysisform has no stage_field_key() '
'yet; the Stage Options matrix cells have no keys '
'to write to')
return None
rows = []
for opt in options:
unpacked = _unpack_stage_option(opt)
if unpacked is None:
problems.append('a Handling stage option row has an unexpected '
'shape ({0!r}); skipped'.format(opt))
continue
opt_key, label, symbol, help_text, kind, choices = unpacked
editor = _stage_options_editor(kind)
cells = []
for stage in stages:
if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES:
cells.append({'na': True})
continue
try:
key = stage_field_key(stage, opt_key)
except Exception as exc:
# A broken key builder must not take the whole page down;
# name it and leave that one cell blank instead.
problems.append(
'could not build the form key for {0} / {1} ({2}); that '
'cell is left blank'.format(stage, label, exc))
cells.append(None)
continue
cells.append({'editor': editor, 'key': key, 'choices': choices})
rows.append({'label': _row_label(label, symbol),
'hint': help_text or None, 'cells': cells})
if not rows:
problems.append('every Handling stage option row was unreadable; '
'the Stage Options matrix has nothing to show')
return None
return {'kind': 'matrix', 'columns': stages, 'rows': rows}
def _legend_block():
"""Eriksson's own two-column legend, redrawn generically.
Shape names ('triangle', 'bar') and fill names ('blank', 'light',
'hatch', 'solid', 'tan') are markers for the renderer to draw, not
engineering values -- there is nothing here for `panelanalysisform` to
supply, so this block is static.
"""
return {
'kind': 'legend',
'groups': [
('Shapes', [
('triangle', 'Face lifter'),
('bar', 'Dunnage'),
]),
('Fills', [
('blank', 'Not specified'),
('light', 'Stripping'),
('hatch', 'Erection'),
('solid', 'Stripping + Erection'),
('tan', 'Transportation'),
]),
],
}
def _weight_centroid_block(paf, values, reads, problems):
"""The Panel Weight / Centroid kv block.
Weight comes from the real `weight_lb` field -- the user's override, or
whatever the model could read, exactly like every other prefilled field
on this form -- converted to kips to match Eriksson's own display. The
three centroid components are not computed anywhere in this pipeline
yet, so they say that in plain words rather than printing a fabricated
0.000; see the module docstring.
"""
weight_row = ('Panel weight', 'not entered yet -- computed from the '
'geometry above once you run the analysis')
if _field_exists(paf, 'weight_lb'):
text = (values.get('weight_lb') or '').strip()
if not text and reads:
text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \
else str(reads.get('weight_lb') or '')
if text:
try:
kips = float(text) / 1000.0
weight_row = ('Panel weight',
'{0:.3f} kips (as entered)'.format(kips))
except (TypeError, ValueError):
weight_row = ('Panel weight',
'{0} lb (as entered, not a plain number)'
.format(text))
else:
problems.append('vibe.panelanalysisform has no weight_lb field; '
'panel weight cannot be shown')
not_tracked = 'not tracked on this form yet'
problems.append('panel centroid (X from start / Y from bottom / Z '
'from left) is not computed anywhere in this pipeline '
'yet; the Panel Weight / Centroid block shows weight '
'only')
return {
'kind': 'kv',
'rows': [
weight_row,
('Centroid, X (from start)', not_tracked),
('Centroid, Y (from bottom)', not_tracked),
('Centroid, Z (from left)', not_tracked),
],
}
def _lifter_section_blocks(paf, problems):
"""Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three blocks.
Only `face_lifters` / `edge_lifters` are real fields; the structured
per-lifter table Eriksson prints (qty, two positions, a tick per stage)
has no backing data model here, and "top edge lifters" / "dunnage" have
no field at all. See the module docstring for why each of those is a
sentence and a `problems` entry rather than an invented table.
"""
blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters',
'level': 2}]
any_lifter_field = False
for key in _LIFTER_TEXT_KEYS:
if _field_exists(paf, key):
blocks.append(_field_block(paf, key))
any_lifter_field = True
else:
problems.append('vibe.panelanalysisform has no {0} field; that '
'lifter position cannot be entered'.format(key))
if any_lifter_field:
blocks.append({
'kind': 'sentence',
'text': ('Eriksson prints one row per lifter with a quantity, '
'two positions and a tick per stage; this form does '
'not yet parse the text above into that shape, so '
'each line is kept as typed.'),
})
blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2})
if _field_exists(paf, 'top_edge_lifters'):
# Not expected today, but if a future restructure adds the field,
# render it rather than silently keeping the old sentence.
blocks.append(_field_block(paf, 'top_edge_lifters'))
else:
problems.append('vibe.panelanalysisform has no top edge lifters '
'field; this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no top edge lifters.'})
blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2})
if _field_exists(paf, 'dunnage'):
blocks.append(_field_block(paf, 'dunnage'))
else:
problems.append('vibe.panelanalysisform has no dunnage field; '
'this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no dunnage entered.'})
return blocks
def _lifting_hardware_blocks(problems):
"""Eriksson page 26: the weight-distribution drawing plus a note on what
could not be carried over (see the module docstring's last bullet)."""
blocks = [
{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2},
{'kind': 'drawing', 'source': 'weight_diagram',
'caption': 'PANEL WEIGHT DISTRIBUTION', 'w': 760, 'h': 220},
]
problems.append(
'Eriksson also prints a small lifting-configuration sketch per '
'included stage (a G/CG marker over each stage\'s lift points) '
'and a Face Lifters name / capacity-check method block above the '
'weight distribution; neither has a panelsketch drawing source or '
'a panelanalysisform field yet, so neither is shown')
return blocks
def page(values, reads=None):
"""(blocks, problems) for the Handling section of the Panel Analysis form.
`values` is the form's current string-valued dict, read-only here -- this
function never assigns into it, so the caller's dict comes back
unchanged no matter what this renders. `reads` is the optional dict of
what the model could read off the framing element (the same shape
`panelanalysisform.prefill` takes); it is used only as a fallback for the
weight row when `values` itself is blank, and its absence never raises.
Returns a flat list of block dicts and a list of plain-English problem
sentences -- never raises, per the module docstring's "could not judge"
discipline. An empty `problems` list means every accessor this section
depends on was present and shaped as expected; a non-empty one names
every gap, so the caller can decide whether to surface it to the user
(a stale accessor is worth knowing about even though the page itself
still renders around it).
"""
problems = []
paf, reason = _import_paf()
if paf is None:
problems.append(reason)
return ([{'kind': 'heading', 'text': 'Handling', 'level': 1},
{'kind': 'sentence',
'text': ('The Handling section could not be built -- see '
'the problem above.')}],
problems)
values = values or {}
blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}]
# --- Stage Options matrix -------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2})
matrix_block = _stage_matrix_block(paf, problems)
if matrix_block is not None:
blocks.append(matrix_block)
else:
blocks.append({'kind': 'sentence',
'text': ('The per-stage handling options could not '
'be built -- see the problem above.')})
blocks.append({'kind': 'space', 'h': 8})
blocks.append(_legend_block())
blocks.append({'kind': 'space', 'h': 8})
# --- Panel Weight / Centroid ------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid',
'level': 3})
blocks.append(_weight_centroid_block(paf, values, reads, problems))
blocks.append({'kind': 'rule'})
# --- Load factors (weight override / self-weight factor) --------------
load_factor_blocks = []
for key in _LOAD_FACTOR_KEYS:
if _field_exists(paf, key):
load_factor_blocks.append(_field_block(paf, key))
else:
problems.append('vibe.panelanalysisform has no {0} field'
.format(key))
if load_factor_blocks:
blocks.append({'kind': 'heading', 'text': 'Load Factors',
'level': 3})
blocks.extend(load_factor_blocks)
blocks.append({'kind': 'space', 'h': 8})
# --- plan drawing -------------------------------------------------
blocks.append({'kind': 'drawing', 'source': 'plan',
'caption': 'LIFTER AND DUNNAGE DETAIL', 'w': 760,
'h': 420})
blocks.append({'kind': 'space', 'h': 8})
# --- lifter / dunnage sections -----------------------------------
blocks.extend(_lifter_section_blocks(paf, problems))
blocks.append({'kind': 'rule'})
# --- Lifting Hardware (page 26) ------------------------------------
blocks.extend(_lifting_hardware_blocks(problems))
return blocks, problems
@@ -0,0 +1,365 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the Reinforcement section's block builder.
WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as
one raw eccentricity number in an unlabelled box, and the owner has now said
three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and
finally "全部给我做成普通人能看懂的样子" -- make every part of it something an
ordinary person can understand). This module rebuilds the Reinforcement half
of that form to read like Eriksson Wall's own printed page (strands, rebar,
mesh, interrupts, then the drawn cross-section), while staying an honest INPUT
form rather than a finished report: every optional block that is empty says so
in a sentence, exactly the way Eriksson prints "Design contains no rebar" /
"no mesh" / "no prestressing" rather than leaving a blank space that could
mean either "nothing here" or "nobody has typed it yet".
`page(values, reads=None)` returns `(blocks, problems)` and never raises.
`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see
the button's WPF renderer) -- 'heading', 'columns', 'field', 'table', 'kv',
'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as
nothing, so adding a new one here without also teaching the renderer is a
silent no-op, not a crash -- worth remembering while extending this file.
`problems` is the sentence list the button shows above the section: every
field key this module could not find in `vibe.panelanalysisform`, every
strand size with no known area, and every summary quantity (cgx, ex, ey) that
this module refuses to compute rather than invent.
WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own
rule for `strand_area_in2` (a strand size it does not recognise is refused BY
NAME, never guessed, because an invented area is a fabricated capacity):
* A strand size with no published area never gets a made-up one -- the
table shows the size, refuses the area, and `problems` names the size.
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
never printed. The per-strand `y` `_prestress_from` computes is an even
spread across the panel WIDTH assumed for the Stage-0 solve, not a
position the user typed or the model measured, so presenting it as cgx
would dress up an assumption as a fact. Depth (cgy) IS printed, because
depth-to-CG is exactly what the user types per strand group -- a
count-weighted average of real entered numbers, not an invented one.
* Horizontal strand spacing is not collected anywhere on this form, so no
table here carries a "Spacing" column or any other layout that implies
it is known -- Eriksson's own rebar table has one; this module's strand
table deliberately does not, matching the real page 27 strand table
(which has none either) rather than the rebar table on the same PDF.
WHY `strand_rows` IS READ DEFENSIVELY: a sibling agent is restructuring
`panelanalysisform` to replace the single `ps_ecc_in` box with structured
strand-group rows, exposed on `values['strand_rows']` as
`[{'count', 'depth_in', 'pull_pct'}, ...]`. That key does not exist on
`values` in every revision of that module while the restructure is in
flight, so its absence is read as "waiting on input", never as an error --
this module falls back to parsing the raw `ps_rows` text itself with
`panelanalysisform.parse_strand_rows` (the same parser `validate()` uses) and
says in `problems` that it had to.
No Revit import, no WPF import, no `panelsketch` import -- only
`vibe.panelanalysisform`, for field names/labels/hints and the strand-area
table, exactly as the rest of this input form does. Must run on IronPython
2.7 in the button (`vibe.helpers` never reaches this module) AND on plain
CPython under `WillTools.extension/tests/conftest.py`'s import trick: no
f-strings, no typing, no dataclasses.
"""
from vibe import panelanalysisform as pf
# --------------------------------------------------------------- constants
# Section/field keys this module owns, all declared under the 'Reinforcement'
# section in panelanalysisform.FIELDS. Kept as a list (not re-derived from
# FIELDS by section name) so a key panelanalysisform drops or renames shows up
# as an explicit MISSING in `problems` instead of just vanishing from the page.
_STRAND_FIELD_KEYS = ('strand_rail_location', 'strand_rail_offset_in',
'ps_dia_in', 'ps_pull_lb', 'ps_count', 'ps_loss_pct')
_REBAR_FIELD_KEYS = ('mild_as',)
_MESH_FIELD_KEYS = ('mesh',)
# panelanalysisform has no field for interrupts at all yet -- not "blank",
# genuinely absent. Kept as its own name (rather than an empty tuple reused
# for "no fields") so the one `problems` sentence that explains it is written
# once, beside the constant it is about.
_INTERRUPTS_MISSING = 'interrupts'
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
# page speaks. Nothing here is 'check' or 'choice' -- every Reinforcement
# field is free text or a number.
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
def _field_block(key, problems):
"""A 'field' block for a panelanalysisform key, or None + a MISSING note.
Reads the label/symbol/hint/required-ness straight off panelanalysisform
so this page can never drift from the form it is rendering -- a rename in
that module shows up here as a MISSING problem instead of a stale label.
"""
spec = pf._spec(key)
if spec is None:
problems.append(
"field {0!r} is not declared in panelanalysisform -- this page "
"cannot show it (MISSING)".format(key))
return None
editor = _EDITOR_OF_KIND.get(spec['kind'], 'text')
return {
'kind': 'field', 'key': key, 'label': spec['label'],
'symbol': spec['symbol'] or None, 'hint': spec['help'] or None,
'required': spec['required'], 'editor': editor, 'choices': None,
}
def _text_of(values, key):
"""The current string in `values` for `key`, '' if absent/None.
`values` is meant to be string-valued, but this page must never raise on
a caller's mistake -- a non-string value (an int typed straight into a
dict in a test, a stray object) is coerced rather than crashed on.
"""
raw = values.get(key)
if raw is None:
return ''
if not hasattr(raw, 'strip'):
try:
raw = str(raw)
except Exception:
return ''
return raw.strip()
def _num_of(values, key):
"""(float_or_None, error_or_None) for a numeric field's current text."""
text = _text_of(values, key)
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
return None, 'is not a number ({0!r})'.format(text)
# ------------------------------------------------------------------ strands
def _strand_rows_from(values, problems):
"""[{'count', 'depth_in', 'pull_pct'}, ...] read defensively.
Prefers the structured `values['strand_rows']` a sibling agent is adding;
falls back to parsing the raw `ps_rows` text with panelanalysisform's own
parser (the same one `validate()` uses) when that key is absent, and says
so in `problems` -- never treats the absence as an error by itself.
Genuinely blank input (`strand_rows` absent AND `ps_rows` blank) returns
`[]` with no problem: "nobody has typed any strand groups yet" is a real,
quiet state, not a fault.
"""
raw = values.get('strand_rows')
if isinstance(raw, list):
rows = []
for entry in raw:
try:
count = int(entry['count'])
depth_in = float(entry['depth_in'])
except (KeyError, TypeError, ValueError):
problems.append(
"a strand_rows entry from the form could not be read "
"(missing or non-numeric count/depth_in) -- skipped it "
"rather than guessing its numbers")
continue
pull_raw = entry.get('pull_pct') if hasattr(entry, 'get') else None
pull_pct = None
if pull_raw is not None:
try:
pull_pct = float(pull_raw)
except (TypeError, ValueError):
problems.append(
"a strand_rows entry's pull_pct could not be read "
"({0!r}) -- treated as not given".format(pull_raw))
rows.append({'count': count, 'depth_in': depth_in,
'pull_pct': pull_pct})
return rows
# Fall back to the raw text field this page also renders as a 'field'
# block below, so the user always has somewhere to type even while the
# structured key is not yet on `values`.
text = _text_of(values, 'ps_rows')
if not text:
return []
rows, parse_problems = pf.parse_strand_rows(text)
if parse_problems:
problems.append(
"structured strand_rows was not supplied by the form; tried to "
"read the strand-group text instead and could not: " +
'; '.join(parse_problems))
return []
if rows:
problems.append(
"structured strand_rows was not supplied by the form yet; "
"parsed the raw strand-group text (ps_rows) instead")
return rows
def _strand_block(values, problems):
"""The whole Strands sub-block: rail fields, then table or sentence.
Matches the real page's own shape: the Rail Location / Rail Offset pair
prints unconditionally, and what follows is either the strand table or
the single sentence "Design contains no prestressing" -- never a table
with a header row and no data, and never a blank space.
"""
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
rail_fields = []
for key in ('strand_rail_location', 'strand_rail_offset_in'):
fb = _field_block(key, problems)
if fb is not None:
rail_fields.append([fb])
if rail_fields:
blocks.append({'kind': 'columns', 'columns': rail_fields})
rows = _strand_rows_from(values, problems)
if not rows:
blocks.append({'kind': 'sentence', 'text':
'Design contains no prestressing'})
return blocks
for key in ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_loss_pct'):
fb = _field_block(key, problems)
if fb is not None:
blocks.append(fb)
dia_text = _text_of(values, 'ps_dia_in')
area, area_err = pf.strand_area_in2(dia_text if dia_text else None)
if area_err:
problems.append(area_err)
pull_lb_text = _text_of(values, 'ps_pull_lb')
headers = ['Size', 'Area (in²)', 'Diameter', 'Quantity', '% Pull',
'Depth to CG (in)']
table_rows = []
for row in rows:
size_cell = dia_text if dia_text else '?'
area_cell = ('%.4f' % area).rstrip('0').rstrip('.') if area is not \
None else '?'
if row['pull_pct'] is not None:
pull_cell = ('%g%%' % row['pull_pct'])
elif pull_lb_text:
pull_cell = 'default pull ({0} lb)'.format(pull_lb_text)
else:
pull_cell = 'default pull (not entered)'
depth_cell = ('%g"' % row['depth_in'])
table_rows.append([size_cell, area_cell, size_cell,
str(row['count']), pull_cell, depth_cell])
blocks.append({'kind': 'table', 'headers': headers,
'header_groups': None, 'rows': table_rows,
'row_key': None})
# Strand summary. Aps and cgy are real derived numbers (a count-weighted
# sum/average of what the user actually typed); cgx/ex/ey are refused --
# see the module docstring for why.
if area is not None:
total_count = sum(r['count'] for r in rows)
if total_count > 0:
aps = total_count * area
cgy = sum(r['count'] * r['depth_in'] for r in rows) / \
float(total_count)
kv_rows = [
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.')),
('cgy, in (depth to CG)',
('%.4f' % cgy).rstrip('0').rstrip('.')),
('cgx, in', 'not shown -- horizontal strand position is not '
'collected on this form, only assumed for the '
'solve'),
('ex, in', 'not shown -- see cgx above'),
('ey, in', 'not shown -- needs a defined section centroid, '
'not derived by this page'),
]
blocks.append({'kind': 'kv', 'rows': kv_rows})
problems.append(
'strand summary: cgx and both eccentricities (ex, ey) are '
'not computed -- horizontal strand position on this panel '
'is an assumption made for the solve, not a real input, and '
'printing it as cgx would dress up that assumption as a '
'measured fact')
else:
problems.append(
'strand summary (Aps, cgy) could not be computed -- the strand '
'area is unknown (see the strand-size problem above)')
return blocks
# --------------------------------------------------------------- rebar/mesh
def _rebar_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
fb = _field_block('mild_as', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mild_as'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no rebar'})
return blocks
def _mesh_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
fb = _field_block('mesh', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mesh'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no mesh'})
return blocks
def _interrupts_block(problems):
"""The Interrupts sub-block -- no field exists on the form for this yet.
This is deliberately NOT "Design contains no interrupts": that sentence
would assert a judged, empty answer, and nothing here has judged
anything -- panelanalysisform simply has no key to read. "Could not
judge" and "judged, found nothing" must never look alike (CLAUDE.md
section 3), so this prints the honest could-not-judge sentence and names
the gap in `problems` for whoever is meant to add the field.
"""
problems.append(
"interrupts: panelanalysisform has no field for interrupts yet "
"(MISSING) -- this page cannot say whether the design has any")
return [
{'kind': 'heading', 'text': 'Interrupts', 'level': 2},
{'kind': 'sentence', 'text':
'Could not judge -- this form does not yet collect interrupts '
'data'},
]
# ------------------------------------------------------------------- public
def page(values, reads=None):
"""(blocks, problems) for the Reinforcement section of the input form.
`values` is the form's current string-valued dict (plus, once the
sibling restructure lands, a `strand_rows` list -- see the module
docstring). `reads` is accepted for signature parity with the rest of
this form's per-section builders; nothing under Reinforcement is
prefilled from the model (`panelanalysisform.PREFILLABLE` does not
include any of these keys), so it is not read here. Never raises --
every failure becomes a sentence in `problems` instead.
"""
values = values or {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}]
blocks.extend(_strand_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_rebar_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_mesh_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_interrupts_block(problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.append({
'kind': 'drawing', 'source': 'section',
'caption': 'Reinforcement Cross-Section', 'w': 480, 'h': 360,
})
return blocks, problems
@@ -0,0 +1,666 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Structural Model" section, laid out.
WHY THIS EXISTS: the owner rejected the Panel Analysis form three times as
unreadable ("太丑了", "你这么输入用户上哪儿能看懂") and finally ruled, 2026-09-02,
"全部给我做成普通人能看懂的样子" -- make every part of it something an ordinary
person can understand. The fix is not a paint job: `page()` below rebuilds
the Structural Model section (restraints, bearing, releases, concentrated
loads, and the wind / seismic / temperature / soil load families) to look
like Eriksson Wall's own printed page for it, because that page is the
document a reviewing engineer already knows how to read. Every field still
carries the engineering `symbol` beside its plain-English `label` -- the
Unicode notation is not decoration, it is what gets checked against
Eriksson's own printout -- and every field carries a one-sentence `hint`
saying what it is for and what a blank means, because a symbol alone
("Vult, mph") is exactly the kind of label that got this form rejected.
`page(values, reads=None)` returns `(blocks, problems)` -- a flat list of
BLOCK dicts from a small fixed vocabulary a generic WPF renderer consumes
(an unrecognised `kind` renders as nothing, so this module can only ever
degrade a layout, never crash one), plus a list of plain-English sentences
describing anything that could not be built as designed. `page` never
raises: a field the shared field model does not have yet, or a `values`
dict missing a key entirely, becomes a `problems` sentence and a
stand-in label, not an exception and not an invented value.
TWO RULES FROM CLAUDE.md THIS MODULE IS BUILT AROUND, section 3:
* "Could not judge" and "judged, found nothing" must never look alike.
Every OPTIONAL block here (Releases, Concentrated Loads, the Wind /
Seismic zone tables, both Soil Loads, Additional Self Weight, Design
Strips) has to say something when it has nothing to show, and there
are two different somethings: the key existing in `values` but blank
means the user was asked and answered "none" (Eriksson's own wording,
e.g. "Design contains no exterior soil loads" -- generic boilerplate,
not a client's text, and kept because it is the plain-English answer a
non-engineer would also write); the key not existing in `values` at
all means this form has never asked yet, and the sentence says so
instead of quietly agreeing with "none". Collapsing those two into one
sentence is exactly the failure `knowledge.same_face()` returning
`None` instead of `False` guards against elsewhere in this codebase.
* Nothing here computes wind or seismic. The owner ruled it explicitly,
2026-09-02: "风的计算还没有你就留着空着,让用户输入" -- the wind engine is a
later stage, so every wind/seismic field is collected, labelled as not
yet used by the analysis, and left blank. No pressure, no Kz, no
exposure default, nothing invented to make the page look more finished
than the analysis actually is.
WHERE THE LAYOUT COMES FROM: Eriksson Wall's own printed "Structural
Model" page -- a Restraints table (foundation connection, one row per tie
elevation with a gravity/thermal restraint flag and a stiffness) beside a
Releases block, an elevation diagram, a Concentrated Loads table split into
dead/live/roof/wind-pressure/wind-suction/seismic components, Load
Options, then the Wind / Seismic / Temperature / Soil / Additional Self
Weight load families and a Design Strips note. Only the STRUCTURE and
generic field names are taken from that page -- no project, client,
licensee, engineer, job number, file name or date from it appears
anywhere below; every label is written from scratch in plain engineering
English. See CLAUDE.md's "No client or job information in the repo. Ever."
READING `vibe.panelanalysisform` DEFENSIVELY: that module owns the actual
key names and is being restructured by another agent while this one is
being written. `page()` imports it, reads its `FIELDS` list once to find
out which keys it already knows about, and for every key this section
wants that it does NOT find there, it renders the field anyway (with a
label and hint written locally, right here) and adds one sentence to
`problems` naming the key and what it is for -- never a raised exception,
never a guess passed off as the shared field model's answer. Some of the
keys used below (`supports_ft`, `restraints`, `conc_loads`, every
`wind_*` key, `seismic_cs`, `ext_soil_loads`, `int_soil_loads`) already
exist in `panelanalysisform.FIELDS` today and are read from there;
Eriksson's page has considerably more than that thin set, and the rest are
new key names this module proposes (`restraint_rows`, `releases`,
`wind_zone_rows`, `seismic_zone_rows`, `temp_ext_f`, ... -- see the field
list inline) for the field-model agent to land.
Standard library only, no Revit, no WPF, no `panelsketch` import -- the
`kind: 'drawing'` blocks below only NAME a `vibe.panelsketch` entry point
as their `source`; a sibling agent owns what that module draws.
IronPython 2.7 throughout: no f-strings, `'{0}'.format(...)` everywhere,
`# -*- coding: utf-8 -*-` up top, no `pathlib` / `dataclasses` / `typing`.
"""
import re
# --------------------------------------------------------------------------
# Small helpers shared by every block below. None of these touch `values` --
# they only ever read it, and `page()` never assigns into a caller-owned
# dict, so the same `values` a caller hands in comes back untouched.
# --------------------------------------------------------------------------
def _text(value):
"""Coerce whatever sits in `values` to a string, defensively.
`values` is documented as string-valued, but this form is fed by a
live model read and a WPF text box on one side and a mid-refactor
field model on the other -- a stray non-string (an `int` typed
straight into a dict during a test, a `None` a half-written prefill
left behind) must degrade to an empty string, never raise.
"""
if value is None:
return ''
try:
value.strip # duck-typed string check -- works for str/unicode both
return value
except AttributeError:
try:
return str(value)
except Exception:
return ''
def _known_field_keys(paf):
"""The set of keys `vibe.panelanalysisform.FIELDS` already defines.
Empty set (never an exception) if the module failed to import or its
`FIELDS` list could not be read -- the caller has already logged why in
`problems` by the time this runs, and an empty set makes every field
below fall through to its local stand-in label, which is the correct
degrade rather than a crash.
"""
if paf is None:
return set()
try:
return set(f['key'] for f in paf.FIELDS)
except Exception:
return set()
def _field(paf, known, problems, key, required, editor, choices=None,
fallback_label=None, fallback_symbol='', fallback_hint='',
what=''):
"""One `kind: 'field'` block, sourced from `panelanalysisform` when it
already knows `key`, and from the fallback text passed in when it does
not -- appending one `problems` sentence in that second case, naming
the key and what it is for, so the field-model agent has a punch list
rather than a form that silently drifted from `panelanalysisform`.
"""
if key in known:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or None
else:
label = fallback_label or key
symbol = fallback_symbol or None
hint = fallback_hint or None
problems.append(
"'{0}' ({1}) has no entry yet in vibe.panelanalysisform -- "
'shown with a stand-in label until the field model adds it.'
.format(key, what or fallback_label or key))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
def _optional_note(values, key, blank_sentence, missing_sentence, problems,
what):
"""(state, text) for one optional free-text block.
Three states, and the whole point of this helper is that the first two
never look alike:
'missing' -- `key` is not in `values` at all. This form has not asked
the question yet; `text` is `missing_sentence` and one
line is appended to `problems` naming the gap. This is
"could not judge", never "judged, found nothing".
'blank' -- `key` is in `values` but empty/whitespace. The user was
asked and the honest answer is none; `text` is
`blank_sentence`. This IS "judged, found nothing".
'filled' -- `key` has real text; `text` is that text, stripped, for
the caller to parse into a table.
"""
if key not in values:
problems.append(
"'{0}' ({1}) is not collected by this form yet -- rendered as "
"unknown, never silently as \"none\"."
.format(key, what))
return 'missing', missing_sentence
text = _text(values.get(key)).strip()
if not text:
return 'blank', blank_sentence
return 'filled', text
def _rows_from_lines(text, extra_cols=0):
"""[[id, col, col, ...], ...] -- one row per non-blank line, comma-split.
This is a DISPLAY parse, not `panelanalysisform`'s validating one: a
line that does not split into the expected shape is shown with
whatever columns it did produce rather than refused, because this
table exists to show the user what they typed back at them, not to
gate the Run button (that gate is `panelanalysisform.validate`'s job,
on the raw field text, elsewhere).
"""
rows = []
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
cols = [p.strip() for p in line.split(',')]
rows.append([str(i)] + cols)
return rows
def _restraint_rows_from(values, problems):
"""(rows, state) for the Restraints table's ID/Elevation/Type/Gravity/
Thermal/Stiffness columns.
Prefers the full `restraint_rows` key (one line per restraint: elevation,
type, gravity y/n, thermal y/n, stiffness) once the field model carries
it. Until then this falls back to the existing `restraints` key, which
today is elevations only -- so the Elevation column still fills in from
whatever the user already typed, and the Type/Gravity/Thermal/Stiffness
columns show blank rather than inventing a restraint type nobody typed.
Both cases add a `problems` line; only the outcome (which columns come
from where) differs.
"""
if 'restraint_rows' in values:
text = _text(values.get('restraint_rows')).strip()
if not text:
return [], 'blank'
rows = []
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split(',')]
parts = (parts + ['', '', '', '', ''])[:5]
rows.append([str(i)] + parts)
return rows, ('filled' if rows else 'blank')
problems.append(
"'restraint_rows' (restraint type, gravity/thermal restraint flags "
"and stiffness per elevation) has no entry yet in "
"vibe.panelanalysisform -- only plain elevations ('restraints') "
'are collected today, so those four columns show blank rather '
'than an invented restraint type.')
if 'restraints' not in values:
return [], 'missing'
text = _text(values.get('restraints')).strip()
if not text:
return [], 'blank'
toks = [t for t in re.split(r'[,\n]+', text) if t.strip()]
rows = []
for i, tok in enumerate(toks, 1):
rows.append([str(i), tok.strip(), '', '', '', ''])
return rows, 'filled'
def _table_from_text(text, headers):
return {'kind': 'table', 'headers': headers, 'header_groups': None,
'rows': _rows_from_lines(text), 'row_key': None}
# --------------------------------------------------------------------------
# page()
# --------------------------------------------------------------------------
def page(values, reads=None):
"""(blocks, problems) for the Structural Model section.
`values` is the form's current string-valued dict, keyed by
`vibe.panelanalysisform` field names (plus new names this module
proposes -- see the module docstring). Never mutated: every access
below is `values.get(...)` or `key in values`, nothing is assigned
into it. `reads` is accepted for symmetry with the rest of the form
(a future model-read prefill for this section) but nothing here is on
`panelanalysisform.PREFILLABLE` today, so it is not read.
Never raises. A field the shared field model does not know yet, an
unreadable `panelanalysisform`, or `values` missing a key entirely all
degrade to a `problems` sentence plus a reasonable stand-in in the
layout -- never an exception, never a fabricated value.
"""
values = values if values is not None else {}
problems = []
blocks = []
paf = None
try:
from vibe import panelanalysisform as paf
except Exception as exc:
problems.append(
'could not import vibe.panelanalysisform ({0}); every field '
'below is shown with a locally-written label instead of the '
'shared field model.'.format(exc))
known = _known_field_keys(paf)
blocks.append({'kind': 'heading', 'text': 'Structural Model', 'level': 1})
blocks.append({'kind': 'sentence', 'text':
'How this panel is held up, tied back and loaded once it is '
'standing in place, separate from its geometry and how it is '
'handled before it gets there.'})
# --------------------------------------------------------- Restraints
restraints_col = [
{'kind': 'heading', 'text': 'Restraints', 'level': 2},
{'kind': 'sentence', 'text':
'Where the panel is connected to the foundation, and then, '
'going up, where it is tied back to each floor as the '
'building is built around it.'},
]
restraints_col.append(_field(
paf, known, problems, 'restraint_foundation_connection', False,
'choice', choices=['Pin', 'Fixed', 'Roller', 'Slide'],
fallback_label='Foundation connection type',
fallback_hint='How the bottom of the panel is attached to the '
'foundation: pinned (free to rotate), fixed, on a '
'roller, or free to slide. Leave blank if this is '
'not decided yet.',
what='the foundation connection type'))
restraints_col.append(_field(
paf, known, problems, 'restraint_floor_tie', False, 'check',
fallback_label='Add a floor tie',
fallback_hint='Check this to add another restraint row above the '
'foundation for a floor tying the panel back. Leave '
'unchecked if there are no floor ties.',
what='whether an extra floor-tie restraint row is added'))
restraints_col.append({'kind': 'sentence', 'text':
"Gravity restraint means that tie also carries the panel's own "
'weight at that point; thermal restraint means it stops the '
'panel from bowing out of plane if one face gets hotter than the '
'other.'})
rrows, rstate = _restraint_rows_from(values, problems)
if rstate == 'missing':
restraints_col.append({'kind': 'sentence', 'text':
'No restraint elevations have been entered on this form yet '
'-- this is unknown, not "no restraints".'})
elif rstate == 'blank':
restraints_col.append({'kind': 'sentence', 'text':
'Design contains no restraints.'})
else:
restraints_col.append({'kind': 'table',
'headers': ['ID', 'Elevation (ft)', 'Type', 'Gravity Rest.',
'Thermal Rest.', 'Stiffness (k/in)'],
'header_groups': None, 'rows': rrows, 'row_key': None})
# ------------------------------------------------------------ Releases
releases_col = [
{'kind': 'heading', 'text': 'Releases', 'level': 2},
{'kind': 'sentence', 'text':
'Any restraint above that is deliberately freed in one '
'direction -- for example a tie that carries the weight but '
'is left free to slide sideways.'},
]
rel_state, rel_text = _optional_note(
values, 'releases',
blank_sentence='Design contains no releases.',
missing_sentence='Releases are not collected on this form yet -- '
'this is unknown, not "no releases".',
problems=problems, what='deliberately-freed restraint directions')
if rel_state == 'filled':
releases_col.append(_table_from_text(rel_text, ['ID', 'Release']))
else:
releases_col.append({'kind': 'sentence', 'text': rel_text})
blocks.append({'kind': 'columns',
'columns': [restraints_col, releases_col]})
blocks.append({'kind': 'drawing', 'source': 'elevation',
'caption': 'Restraint and support elevations',
'w': 640, 'h': 160})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------------- Bearing
blocks.append({'kind': 'heading', 'text': 'Bearing for this analysis',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Which elevations actually hold the panel up for THIS run -- the '
'analysis needs at least two, and they do not have to be every '
'restraint listed above.'})
blocks.append(_field(paf, known, problems, 'supports_ft', True, 'text',
what='the bearing positions the self-weight solve uses'))
blocks.append({'kind': 'space', 'h': 8})
# ---------------------------------------------------- Concentrated Loads
blocks.append({'kind': 'heading', 'text': 'Concentrated Loads',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Extra point loads hung on the panel once it is standing -- a '
'ledger, a beam pocket, attached equipment -- and where along the '
'wall each one acts. A positive offset pulls toward the outside '
'face of the wall.'})
cl_state, cl_text = _optional_note(
values, 'conc_loads',
blank_sentence='No concentrated point loads entered -- design '
'assumes none.',
missing_sentence='Concentrated loads are not collected on this '
'form yet -- this is unknown, not "no point '
'loads".',
problems=problems, what='point loads once the panel is in place')
if cl_state == 'filled':
blocks.append(_table_from_text(
cl_text, ['ID', 'Elevation / offset / dead / live (as typed)']))
problems.append(
"'conc_loads' only carries elevation, offset, dead and live "
"today; Eriksson's own table also splits each point load into "
'roof, wind pressure, wind suction and seismic components -- '
'those columns have no key yet in vibe.panelanalysisform.')
else:
blocks.append({'kind': 'sentence', 'text': cl_text})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Load Options
blocks.append({'kind': 'heading', 'text': 'Load Options', 'level': 2})
blocks.append(_field(
paf, known, problems, 'load_opt_pattern_live', False, 'check',
fallback_label='Pattern live loads for flexure',
fallback_hint='Whether live load is applied in a checkerboard '
'pattern (only some spans loaded at once) when '
'checking bending, instead of everywhere at once at '
'the same time. Leave unchecked if unsure.',
what='whether live load is patterned for the bending check'))
blocks.append(_field(
paf, known, problems, 'load_opt_selfweight_ecc_in', False, 'num',
fallback_label='Self-weight eccentricity', fallback_symbol='in',
fallback_hint="How far the panel's own weight is assumed to act "
'off its centerline. Leave blank to assume it acts '
'right on the centerline.',
what='the self-weight eccentricity'))
blocks.append(_field(
paf, known, problems, 'load_opt_offset_from', False, 'choice',
choices=['Panel CG', 'Other'],
fallback_label='Offset measured from',
fallback_hint='What point the point-load offsets above are '
"measured from. Leave blank to assume the panel's "
'own center of gravity.',
what='what point-load offsets are measured from'))
blocks.append({'kind': 'rule'})
# ------------------------------------------------------------- Wind
blocks.append({'kind': 'heading', 'text': 'Wind', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Nothing in this tool computes a wind pressure yet -- that engine '
'is a later stage. Every field below is collected so nothing '
'typed here is lost, and every one of them is plainly not used '
'by the analysis until the engine exists.'})
wind_fields = (
('wind_speed_vult', 'num', None, 'Design wind speed', 'Vult, mph'),
('wind_risk_category', 'text', None, 'Risk category', ''),
('wind_exposure', 'text', None, 'Exposure category', ''),
('wind_enclosure', 'text', None, 'Enclosure classification', ''),
('wind_asce_version', 'text', None, 'ASCE code version', ''),
('wind_cc_chapter', 'text', None,
'Components & Cladding chapter used', ''),
('wind_mwfrs_chapter', 'text', None,
'Main wind force resisting system chapter used', ''),
('wind_pressure_psf', 'num', None,
'Wind pressure (typed directly)', 'psf'),
('wind_suction_psf', 'num', None,
'Wind suction (typed directly)', 'psf'),
)
for key, editor, choices, flabel, fsym in wind_fields:
blocks.append(_field(
paf, known, problems, key, False, editor, choices=choices,
fallback_label=flabel, fallback_symbol=fsym,
fallback_hint='Not yet used by the solve -- nothing computes '
'wind pressure yet.', what=flabel.lower()))
wz_state, wz_text = _optional_note(
values, 'wind_zone_rows',
blank_sentence='No wind pressure zones entered.',
missing_sentence='Wind pressure zones (start/end elevation, '
'pressure or suction, magnitude) are not '
'collected on this form yet -- this is unknown, '
'not "no wind zones".',
problems=problems, what='wind pressure zones along the height')
if wz_state == 'filled':
blocks.append(_table_from_text(
wz_text,
['ID', 'Start / end / type / magnitude (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': wz_text})
blocks.append({'kind': 'rule'})
# ----------------------------------------------------------- Seismic
blocks.append({'kind': 'heading', 'text': 'Seismic', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Nothing in this tool computes a seismic force yet either -- the '
'same later-stage engine as wind. Shown here so it is not lost, '
'not used by the analysis.'})
blocks.append(_field(paf, known, problems, 'seismic_cs', False, 'num',
what='the seismic coefficient, as a percent of dead load'))
blocks.append(_field(
paf, known, problems, 'seismic_zone_location', False, 'text',
fallback_label='Seismic zone location(s)',
fallback_hint='Which part of the panel height the seismic load '
'applies over (e.g. full height). Not yet used by '
'the solve.',
what='where along the panel the seismic load applies'))
blocks.append(_field(
paf, known, problems, 'seismic_apply_to', False, 'text',
fallback_label='Apply seismic multiplier to',
fallback_hint='Which loads the seismic coefficient multiplies '
'(dead load, self weight, or both). Not yet used '
'by the solve.',
what='what the seismic coefficient is applied to'))
sz_state, sz_text = _optional_note(
values, 'seismic_zone_rows',
blank_sentence='No seismic load zones entered.',
missing_sentence='Seismic load zones (start/end position, load '
'percentage) are not collected on this form yet '
'-- this is unknown, not "no seismic zones".',
problems=problems, what='seismic load zones along the height')
if sz_state == 'filled':
blocks.append(_table_from_text(
sz_text, ['ID', 'Start / end / seismic load % (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': sz_text})
blocks.append({'kind': 'rule'})
# ------------------------------------------------------- Temperature
blocks.append({'kind': 'heading', 'text': 'Temperature Loads',
'level': 2})
blocks.append({'kind': 'sentence', 'text':
'How much hotter or colder one face of the panel gets than the '
'other, and how stiff the concrete is treated as being while it '
'moves. Not yet used by the solve.'})
temp_fields = (
('temp_ext_f', 'Exterior temperature', 'deg F'),
('temp_int_f', 'Interior temperature', 'deg F'),
('temp_elasticity_mult', 'Thermal elasticity multiplier', ''),
('temp_alpha_t', 'Coefficient of thermal expansion', u'αT × 10⁻⁶'),
)
for key, flabel, fsym in temp_fields:
blocks.append(_field(
paf, known, problems, key, False, 'num',
fallback_label=flabel, fallback_symbol=fsym,
fallback_hint='Not yet used by the solve.',
what=flabel.lower()))
blocks.append({'kind': 'rule'})
# ------------------------------------------------------------ Soil
blocks.append({'kind': 'heading', 'text': 'Soil Loads', 'level': 2})
ext_col = [{'kind': 'heading', 'text': 'Exterior Soil Loads',
'level': 3}]
es_state, es_text = _optional_note(
values, 'ext_soil_loads',
blank_sentence='Design contains no exterior soil loads.',
missing_sentence='Exterior soil loads are not collected on this '
'form yet -- this is unknown, not "no exterior '
'soil loads".',
problems=problems, what='soil pressing on the outside face')
ext_col.append({'kind': 'sentence', 'text': es_text})
if es_state == 'filled':
ext_col.append(_field(
paf, known, problems, 'ext_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the exterior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='exterior soil elevation'))
ext_col.append(_field(
paf, known, problems, 'ext_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='exterior soil surcharge pressure'))
int_col = [{'kind': 'heading', 'text': 'Interior Soil Loads',
'level': 3}]
is_state, is_text = _optional_note(
values, 'int_soil_loads',
blank_sentence='Design contains no interior soil loads.',
missing_sentence='Interior soil loads are not collected on this '
'form yet -- this is unknown, not "no interior '
'soil loads".',
problems=problems, what='soil pressing on the inside face')
int_col.append({'kind': 'sentence', 'text': is_text})
if is_state == 'filled':
int_col.append(_field(
paf, known, problems, 'int_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the interior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='interior soil elevation'))
int_col.append(_field(
paf, known, problems, 'int_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='interior soil surcharge pressure'))
blocks.append({'kind': 'columns', 'columns': [ext_col, int_col]})
blocks.append({'kind': 'rule'})
# ------------------------------------------------- Additional self weight
blocks.append({'kind': 'heading', 'text': 'Additional Self Weight',
'level': 2})
asw_state, asw_text = _optional_note(
values, 'addl_sw_magnitude_plf',
blank_sentence='No additional self weight entered -- the '
"analysis uses only the panel's own concrete "
'weight.',
missing_sentence='Additional self weight is not collected on '
'this form yet -- this is unknown, not "none".',
problems=problems, what='extra weight added over part of the panel')
if asw_state == 'missing':
blocks.append({'kind': 'sentence', 'text': asw_text})
else:
if asw_state == 'blank':
blocks.append({'kind': 'sentence', 'text': asw_text})
blocks.append(_field(
paf, known, problems, 'addl_sw_start_in', False, 'num',
fallback_label='Start from bottom', fallback_symbol='in',
fallback_hint='Where the extra weight starts, measured up '
'from the bottom of the panel. Not yet used by '
'the solve.', what='additional self weight start'))
blocks.append(_field(
paf, known, problems, 'addl_sw_end_in', False, 'num',
fallback_label='End from top', fallback_symbol='in',
fallback_hint='Where the extra weight ends, measured down '
'from the top of the panel. Not yet used by '
'the solve.', what='additional self weight end'))
blocks.append(_field(
paf, known, problems, 'addl_sw_magnitude_plf', False, 'num',
fallback_label='Magnitude', fallback_symbol='plf',
fallback_hint='How much extra weight per foot of height is '
'added over that stretch. Leave blank for none. '
'Not yet used by the solve.',
what='additional self weight magnitude'))
blocks.append({'kind': 'drawing', 'source': 'weight_diagram',
'caption': 'Point loads and self weight along the wall',
'w': 640, 'h': 200})
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Design Strips
blocks.append({'kind': 'heading', 'text': 'Design Strips', 'level': 2})
blocks.append({'kind': 'sentence', 'text':
'Whether the panel is analysed as one member across its full '
'width, or split into narrower strips first.'})
blocks.append(_field(
paf, known, problems, 'design_strips_method', False, 'choice',
choices=['Single Member', 'Design Strips'],
fallback_label='Analysis method',
fallback_hint='Whether the whole cross section is analysed as one '
'member, or split into design strips. Blank uses '
'the whole cross section. Not yet used by the '
'solve.', what='the design-strip analysis method'))
ds_state, ds_text = _optional_note(
values, 'design_strips_rows',
blank_sentence='No design strips are defined; the entire cross '
'section will be used.',
missing_sentence='Design strips are not collected on this form '
'yet -- this is unknown, not "no strips".',
problems=problems, what='the design strip boundaries')
if ds_state == 'filled':
blocks.append(_table_from_text(ds_text, ['ID', 'Strip (as typed)']))
else:
blocks.append({'kind': 'sentence', 'text': ds_text})
return blocks, problems
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""Where the PLANT knowledge layer lives on disk.
Split out of `designspec` for one reason: `designspec` imports the Revit API
at module scope (evidence scraping needs a live document), and choosing the
plant knowledge file is pure path arithmetic that a host CPython test can and
should exercise directly. `designspec` re-exports every name here, so nothing
that already says `designspec.plant_knowledge_path()` had to change.
THE LAYOUT, in one line:
<root>/knowledge.yaml the machine-wide plant file
<root>/<plant>/knowledge.yaml one folder per plant
A job's own knowledge file names its plant in `job.plant`; `knowledge.load()`
reads that name BEFORE it merges anything and passes it here. A job that
names no plant reads the machine-wide file, which is how every install worked
before per-plant folders existed and still does.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=invalid-name
import os
import re
from vibe.compat import string_types
# The plant-wide knowledge file: one per machine, above every project.
PLANT_FOLDER = 'WillTools'
PLANT_ENV = 'WILLTOOLS_KNOWLEDGE'
# What a plant id may be made of, because it is used as a PATH COMPONENT.
# It arrives from a YAML file, so `..`, a slash or a drive letter in it would
# be a directory traversal written by whoever edits a job file. Anything
# outside this set is refused outright rather than scrubbed into something
# else: a plant id that quietly became a different folder name is exactly the
# silence this codebase keeps paying for.
_PLANT_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
def valid_plant_id(plant):
"""Is this string usable as a plant id, i.e. as one folder name?
Returns False for None, for the empty string, and for anything carrying a
separator, a drive letter or a dot-dot. See _PLANT_ID_RE.
"""
if not plant or not isinstance(plant, string_types):
return False
return bool(_PLANT_ID_RE.match(plant.strip()))
def plant_root():
"""The folder the plant knowledge lives in -- one file, or one per plant.
THE ROOT IS A FOLDER AND THE MACHINE-WIDE FILE IS `knowledge.yaml` INSIDE
IT. That relationship has always held (%APPDATA%/WillTools/knowledge.yaml)
and is what lets WILLTOOLS_KNOWLEDGE keep meaning what it always meant
while also naming a root:
set to a FOLDER that folder is the root. Nothing else to say.
set to a FILE that file is the machine-wide plant file, so its
DIRECTORY is the root, by the same relationship.
Not a guess -- the definition, read backwards.
unset %APPDATA%/WillTools, as ever.
Which is why a machine already pointing the variable at a single file
picks up per-plant folders with no change to the variable: the folders
are siblings of the file it already names.
A path that is set but exists as neither is treated as a FILE, because
that is what the variable has meant for its whole life and the operator
almost certainly meant a file that has moved -- the missing path is then
reported by name instead of a different, existing path being read in
silence.
"""
override = os.environ.get(PLANT_ENV)
if not override:
base = os.environ.get('APPDATA') or os.path.expanduser('~')
return os.path.join(base, PLANT_FOLDER)
if os.path.isdir(override):
return override
return os.path.dirname(override) or override
def plant_knowledge_path(plant=None):
"""The plant knowledge file this run should read.
No plant named -- the ordinary case, and every install that predates the
key -- returns the machine-wide file exactly as this function always has:
WILLTOOLS_KNOWLEDGE if it names one, else <root>/knowledge.yaml.
A plant named (`job.plant` in the job layer) returns
<root>/<plant>/knowledge.yaml. A SECOND PLANT IS A NEW FOLDER, never an
edit to the first one's file. See plant_root() for what <root> is and why
a machine already pointing the variable at a single file needs no change.
The per-plant FOLDER and the machine-wide FILE share a root and cannot
collide: one is a directory, the other is called `knowledge.yaml`.
An INVALID plant id -- a separator, a `..`, an empty string -- resolves to
NOTHING and returns None. load() reports it. Scrubbing a malformed name
into some nearby folder is how a run ends up on another plant's rules
while looking entirely normal.
"""
if plant is None:
override = os.environ.get(PLANT_ENV)
if override and not os.path.isdir(override):
return override # the single-file arrangement, untouched
return os.path.join(plant_root(), 'knowledge.yaml')
if not valid_plant_id(plant):
return None
return os.path.join(plant_root(), plant.strip(), 'knowledge.yaml')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,807 @@
# -*- coding: utf-8 -*-
"""Reading the model for the reinforcing index. The Revit half only.
The split is the one `ticketpage.py` / `ticketchecks.py` already models:
everything that can be decided without a document lives in
`vibe.reinforcecore` and is unit-tested on host CPython; this file does the
gathering and nothing else. It is READ ONLY -- no Transaction, no parameter
write, no delete, anywhere in it, and there is no reason for that to change:
the feature writes nothing to the model.
WHERE THE CACHE LIVES
---------------------
Beside the model, under the same `_ticketqc` root the ticket evidence and
specs already use (`designspec.root_dir`), in `reinforce/index.json`. It
holds job geometry, so it may not enter the code repository; that root is
gitignored and, when `WILLTOOLS_JOBS` is set, is not inside the repository at
all. Nothing about this index is portable between models, so the model's own
folder is also where it is actually useful.
WHAT THE COST ACTUALLY IS -- measured, this model, 2026-08-30
-------------------------------------------------------------
755 assemblies collected + both flags read 0.29 s
every assembly's cheap signature 0.71 s
full geometry build, all 690 eligible donors 6.9 s (10 ms each)
slowest single piece 0.061 s
serialise + write the finished index (983 kB) 2.0 s
---- what a button press actually costs ----
full rebuild, cold cache 10.1 s
full rebuild, first press after a Revit restart 16.2 s
refresh with nothing changed 2.3 s
read the index without refreshing 1.4 s
---- schema 5, same model, measured 2026-08-30 ----
assemblies in the model 755
eligible donors 690
in-scope pieces added as TARGETS 28 (WP, not donors)
pieces in the file 718
index size 1.01 MB
Quote a rebuild as "~10-16 s", not as ten: the lower number was measured in a
session that had been open for hours, and the first press after a restart is
the one the owner actually experiences.
That is not the 30-60 s per piece this feature was warned about, and the
difference is worth stating because it moves the design: the expensive read
in this project is GENERATING A VIEW -- what `read_ticket` pays when it walks
a ticket sheet -- and nothing here touches a view. `concrete_solid` asks the
panel element for its own geometry and `_openings` reads faces off that
solid. So the index is not expensive to build; it is still cached, because
the owner's constraint is that a button press must not re-analyse the model,
and 6 s of frozen UI per click is 6 s too many.
Honesty about that number: this session was already open when it was
measured. Only a Revit restart restores a truly cold cache and a restart is
the owner's to make. The structural argument -- no view is generated -- is
what the number rests on, not a claim that the session was cold.
"""
# pylint: disable=import-error,broad-except,invalid-name
import os
import time
from vibe import reinforcecore as core
FOLDER = 'reinforce'
FILENAME = 'index.json'
# The two instance parameters on the assembly that decide donor eligibility.
# Named here rather than in the pure module because they are this job's
# ticketing-system parameters, read by name off the element.
P_REINFORCED = 'TICKET_IS_REINFORCED'
P_ON_HOLD = 'ON_HOLD'
# ------------------------------------------------------- the review signal
#
# WHERE 'MASTER' ACTUALLY LIVES -- measured on this model, 2026-08-30, before
# anything was written to match on it, because a guess about a hand-typed
# field is the kind of thing that quietly matches nothing.
#
# The owner said "identity description". Four candidate free-text fields were
# scanned across all 755 assemblies -- the assembly's identity comment, the
# panel instance's identity comment, and two identity fields on the panel
# type and instance. Only the first two ever contain the word:
#
# assembly identity comment 18 pieces <- what this reads
# panel-instance identity comment 16 pieces (a strict subset of it)
# panel-type identity field 0 -- 5 distinct values, all of the
# form 'PRECAST CONCRETE WALL
# PANEL'; a product class, not a
# review state
# instance short identity field 0 -- a category stub per piece
#
# The assembly field is a SUPERSET of the instance field on this model: every
# one of the 18 carries it on the assembly, two carry it ONLY there, and none
# carries it only on the instance. So this reads the assembly and nothing
# else -- one parameter read, no member walk, and the same source in the cheap
# signature pass as in the full extract, which is what keeps the staleness
# stamp honest about this field.
#
# THE PARAMETER IS NAMED BY ROLE, NEVER BY LITERAL. This extension is the
# shareable half of the project and a shared-parameter name is plant
# knowledge: another office's template calls the same field something else,
# and a name written into code is a plant that cannot be served without
# editing the code. `parameters.member.identity_description` is the role;
# `knowledge.param_names` resolves it to the office's real name(s), tried in
# order.
#
# UNMAPPED MEANS NOT RUN, NEVER A FALLBACK. With no name behind the role the
# signal STANDS DOWN: every piece gets `master = None`, which is the project's
# could-not-judge value and is not False. It must not quietly guess a name --
# that would work in this office and silently do nothing in every other one,
# while the report went on printing "no donor here is reviewed" as though
# somebody had looked. A plant that genuinely has no such field says so the
# way every other role does, under `parameters.not_applicable`.
R_IDENTITY = 'member.identity_description'
def master_token(know=None):
"""The plant's own word for a reviewed template.
The default lives in `reinforcecore.DEFAULT_MASTER_TOKEN`, so a plant
that maps the field but uses another word only has to set the key.
The path is written out as a LITERAL rather than through a module
constant on purpose: `tools/knowledge_drift.py` matches literal paths and
a key reached through a variable lands in its blind-spot list instead of
being credited with a reader -- which is how a key ends up reported as
declared-and-never-read while a check is quietly using it.
"""
from vibe import knowledge
return knowledge.get(know, 'reinforce.master_token',
core.DEFAULT_MASTER_TOKEN)
def identity_params(know=None):
"""The office's name(s) for the identity field, or [] when unmapped."""
from vibe import knowledge
return knowledge.param_names(know, R_IDENTITY)
def master_of(el, names, token):
"""(master, note) off one element. `master` is None when NOT RUN.
None is could-not-judge -- the role is unmapped and nothing was looked at.
False is judged: the field was read and does not carry the token.
"""
if not names:
return None, None
for name in names:
note = _text(el, name)
if note:
return core.is_master_text(note, token), note
return False, None
# ------------------------------------------------- the elevation accelerator
#
# THE OPTIONAL FAST PATH, NEVER THE SOURCE OF TRUTH. The source of truth for
# same-elevation is geometric (`_elevation_band_of`, below, in the expensive
# pass) and is computed for every piece regardless of this section. This is
# only a shortcut: a plant-declared GROUPING KEY a wall may already carry
# for an unrelated reason -- MC's is `Tagged_Sheet`, the drafting sheet a
# wall's elevation is drawn on. Owner, 2026-09-02: "对MC而言,所有的墙应该都
# 有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个
# elevation,不用跑哪个几何引擎。" Two panels sharing a non-empty value here
# are the same elevation with no geometry involved.
#
# ABSENCE IS THE NORMAL STATE, not a stand-down worth reporting. Owner, the
# same day, correcting the scope: other plants carry no such parameter at
# all, and even on MC it exists only on complex jobs -- a simple job with a
# handful of elevations carries none, by a deliberate human-vs-computer
# workload balance. So an unmapped role, or a blank value on a piece, is
# expected far more often than not, and `reinforcecore.elevation_match`
# simply falls through to the geometric floor without comment.
#
# Read off the PANEL, exactly as `design_number` is (by role, never a
# literal name) -- `parameters.panel.elevation_group`.
R_ELEVATION_GROUP = 'panel.elevation_group'
def _panel_member(doc, asm):
"""The Structural Framing member of one assembly, cheaply -- no geometry.
The same walk `extract()` does to find the panel it measures, pulled out
so the CHEAP pass can find it too: reading `elevation_group` needs the
panel, and the signature has to see the same source `extract()` records
or the two could disagree about what changed.
"""
try:
member_ids = list(asm.GetMemberIds())
except Exception:
return None
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing':
return member
return None
def elevation_group_of(doc, asm, know=None):
"""The plant's elevation-grouping text off one assembly's panel, or None.
None means could-not-judge, for any of: no Structural-Framing member to
read it off, the role is unmapped (the common case -- see the section
note above), or the field is blank on this piece. `Q.rstr` already
returns None for an unmapped role, the same stand-down `design_number`
uses. Never raises: a piece this cannot be read for still has the
geometric floor to fall back on.
"""
panel = _panel_member(doc, asm)
if panel is None:
return None
from vibe import ticketqc as Q
try:
return Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
return None
def index_path(doc):
from vibe import designspec
return os.path.join(designspec.sub_dir(doc, FOLDER), FILENAME)
def index_path_in(job_folder):
"""The index inside one model's job folder. THE OFFLINE RESOLVER.
It takes a folder rather than a document, so nothing on this path imports
Revit -- `index_path` reaches `designspec`, which imports the Revit API at
module scope, and that single line is what would otherwise drag the whole
API into an offline run. It also does not CREATE anything: reading an
index must never leave a folder behind on a machine that has no model.
The layout is `designspec`'s and is repeated here rather than shared,
because the doc-side function is the one that knows about the model-folder
and APPDATA fallbacks. When WILLTOOLS_JOBS is set -- the case that matters
offline -- the whole path is `<jobs root>/<model title>/reinforce/index.json`
and `jobs_folder()` builds the first half of it.
"""
return os.path.join(job_folder, FOLDER, FILENAME)
def jobs_folder(jobs_root, model):
"""`<jobs root>/<model title>` -- what WILLTOOLS_JOBS means, offline."""
return os.path.join(jobs_root, model or 'untitled')
def doc_key(doc):
"""Which model a cache belongs to, without writing a path into the repo."""
try:
return doc.Title or ''
except Exception:
return ''
# ------------------------------------------------------------- the cheap pass
def _name_of(el):
from Autodesk.Revit.DB import Element
try:
return Element.Name.GetValue(el)
except Exception:
return None
def _text(el, pname):
"""A string parameter's value, or None. Never raises. READ ONLY."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
value = p.AsString()
except Exception:
value = None
if not value:
return None
return value
def _flag(el, pname):
"""1 / 0 / None. None means the parameter is not on this element."""
try:
p = el.LookupParameter(pname)
except Exception:
return None
if p is None:
return None
try:
return int(p.AsInteger())
except Exception:
return None
def assemblies(doc):
"""Every assembly instance, as (name, element). Scoped collector only."""
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
out = []
for a in (FilteredElementCollector(doc)
.OfClass(AssemblyInstance)
.WhereElementIsNotElementType()
.ToElements()):
name = _name_of(a)
if name:
out.append((name, a))
return out
def live_signatures(doc, elements=None, know=None):
"""{name: (signature, reinforced, on_hold)} for every assembly.
No geometry is read here. This is the pass that decides what has to be
rebuilt, so it has to stay cheap enough to run on every button press --
measured at 0.23 s over 755 assemblies. The MASTER flag joined it in
2026-08-30 at the cost of one string parameter read per assembly:
measured over the same 755 assemblies, reading the identity field and
five other string fields together took 1.07 s including the member walk,
so one field on its own is well inside the same budget. It has to be here --
see `reinforcecore.signature` for why a hand-typed field that is not in
the stamp is a field that never takes effect.
THE GEOMETRIC ELEVATION FLOOR IS NOT HERE, and it briefly stood in this
spot under a different name for one day (2026-09-02): the owner's
ruling made it a purely GEOMETRIC fact, and geometry needs the panel's
measured solid -- unaffordable in a pass that has to stay cheap enough
to run on every button press. See `reinforcecore.elevation_band`'s
module note for the full reasoning; the band is captured only in
`extract()`, alongside `thickness_in` and `features`, and never feeds
this cheap stamp.
THE ELEVATION ACCELERATOR IS HERE, and costs about what MASTER does:
reading `elevation_group` needs the PANEL, not the assembly, so
`elevation_group_of` pays its own member walk per assembly
(`_panel_member`) rather than one parameter read off `a` directly. That
walk is the same one `extract()` already pays to find the panel it
measures -- it is not new work, only moved earlier. On most jobs the
role is unmapped (see the section note above `R_ELEVATION_GROUP`), so
this is one more `LookupParameter` call that returns nothing -- cheap
even when it never answers.
"""
token = master_token(know)
names = identity_params(know)
out = {}
for name, a in (elements if elements is not None else assemblies(doc)):
reinforced = _flag(a, P_REINFORCED)
on_hold = _flag(a, P_ON_HOLD)
master = master_of(a, names, token)[0]
elevation_group = elevation_group_of(doc, a, know)
try:
member_ids = [_int_id(i) for i in a.GetMemberIds()]
except Exception:
member_ids = []
out[name] = (core.signature(reinforced, on_hold, member_ids, master,
elevation_group),
reinforced, on_hold)
return out
def _int_id(element_id):
try:
return element_id.IntegerValue
except Exception:
return element_id.Value
# ------------------------------------------------------- the expensive pass
def _global_box_of_solid(solid):
"""The solid's own bounding box, in RAW GLOBAL (model) coordinates.
`Solid.GetBoundingBox()` over the solid's 8 box corners, mirroring
`ticketqc._one_solid_box`'s reasoning exactly: a curved edge's two
"endpoints" coincide at the seam, so walking `solid.Edges` directly
collapses a round feature to a sliver -- `Solid.GetBoundingBox()` does
not have that failure. Unlike `_one_solid_box` this stops at the
solid's own `box.Transform` and never applies the panel's inverse
afterward: a building elevation is measured in the MODEL's frame, not
the panel's own local one.
Returns `(lo, hi)`, each an (x, y, z) list, or `(None, None)`.
"""
if solid is None:
return None, None
try:
from Autodesk.Revit.DB import XYZ
box = solid.GetBoundingBox()
if box is None:
return None, None
local = box.Transform
lo = [None, None, None]
hi = [None, None, None]
for i in (0, 1):
for j in (0, 1):
for k in (0, 1):
corner = XYZ(box.Min.X if i else box.Max.X,
box.Min.Y if j else box.Max.Y,
box.Min.Z if k else box.Max.Z)
q = local.OfPoint(corner)
for axis, value in enumerate((q.X, q.Y, q.Z)):
if lo[axis] is None or value < lo[axis]:
lo[axis] = value
if hi[axis] is None or value > hi[axis]:
hi[axis] = value
if lo[0] is None:
return None, None
return lo, hi
except Exception:
return None, None
def _elevation_band_of(panel, extents, solid):
"""`core.elevation_band(...)` off the panel already being measured.
`extents` is `Q.concrete_extents(panel, frame)`'s (lo, hi) LOCAL-frame
feet triples -- the same measurement `thickness_in` already comes from,
read again here rather than reconverted from `lo_in` / `hi_in` to avoid
a needless feet -> inches -> feet round trip. `solid` is the same one
`extract()` already paid for to build `features` -- no second geometry
pass; only `panel.GetTransform()` is read again, and that is a
placement property, not a solve.
Never raises: any failure reading the transform or the box is
could-not-judge, exactly what `core.elevation_band` itself returns for
an ambiguous or off-axis panel -- a ranking signal going quiet, never a
reason to fail the whole piece.
"""
from vibe import knowledge
try:
spans_ft = [extents[1][i] - extents[0][i] for i in (0, 1, 2)]
transform = panel.GetTransform()
basis = (transform.BasisX, transform.BasisY, transform.BasisZ)
basis_xyz = [(b.X, b.Y, b.Z) for b in basis]
except Exception:
return None
box_lo, box_hi = _global_box_of_solid(solid)
try:
return core.elevation_band(
spans_ft, basis_xyz[0], basis_xyz[1], basis_xyz[2],
box_lo, box_hi,
near_square_ratio=knowledge.UP_END_NEAR_SQUARE_RATIO)
except Exception:
return None
def extract(doc, name, asm, sig, know=None):
"""One piece's index record. Never raises: a failure becomes `error`.
Every early return is a could-not-judge with a reason attached, never an
empty feature list. A piece whose solid would not read and a piece with
no openings are different answers.
"""
from vibe import ticketqc as Q, liftexport as L
reinforced = _flag(asm, P_REINFORCED)
on_hold = _flag(asm, P_ON_HOLD)
# The review signal. Read here as well as in the cheap pass, and from the
# same parameter, so a record and its own signature can never disagree
# about it. A piece that could not be measured still carries it -- being
# unreadable does not un-review a piece.
master, note = master_of(asm, identity_params(know), master_token(know))
def fail(why, **extra):
return core.piece_record(name, _int_id(asm.Id), sig,
reinforced, on_hold, error=why,
master=master, master_note=note, **extra)
panel = None
families = {}
try:
member_ids = list(asm.GetMemberIds())
except Exception as exc:
return fail('members unreadable: {0}'.format(exc))
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
fam = member.Symbol.Family.Name
except Exception:
continue
families[fam] = families.get(fam, 0) + 1
# NOT filtered on SuperComponent -- but the reason written here
# first was wrong in both halves, and it was corrected 2026-08-30
# against the live model:
#
# * SuperComponent set means the element IS A CHILD; it holds the
# PARENT. Measured: SP03 members return
# CONNX_HPKM39_COLUMN_SHOE(10 ANCHOR BOLTS) as their super
# component. The old comment had it backwards, claiming the
# property marks an element that HAS children.
# * It is not "nearly every member". Over 25 WP assemblies,
# 553 of 2755 members carry one -- 20.1%.
#
# So filtering would not empty the count. It would remove a fifth of
# it, and that fifth is nested children that a naive count counts
# twice: once as themselves and once inside their parent.
#
# The inventory is still recorded RAW and deliberately so -- this is
# an index, and a consumer that needs a de-duplicated count should
# decide its own rule and say which it used. But a consumer that
# counts `families` without de-duplicating is over-counting by
# roughly that 20%, and it should know that rather than inherit a
# comment saying the property is useless.
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing' and panel is None:
panel = member
if panel is None:
return fail('no structural-framing member to measure',
families=families)
try:
design_number = Q.rstr(panel, know, 'panel.design_number')
except Exception:
design_number = None
# THE ELEVATION ACCELERATOR -- read the same way and at the same point
# as design_number, since both are hand-set fields off the same panel.
# None the vast majority of the time (unmapped role, or blank on this
# piece) is expected, not a fault -- the geometric floor below is what
# actually answers on most jobs. See `reinforcecore.elevation_match`'s
# module note.
try:
elevation_group = Q.rstr(panel, know, R_ELEVATION_GROUP)
except Exception:
elevation_group = None
try:
frame = Q._panel_frame(panel)
except Exception as exc:
return fail('frame failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if frame is None:
return fail('no panel frame', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
try:
extents = Q.concrete_extents(panel, frame)
except Exception as exc:
return fail('extents failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
if extents is None:
return fail('no concrete extents', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group)
# concrete_extents is in FEET; _openings and opening_penetration both want
# INCHES. Getting this backwards is a 12x error, which is the single most
# expensive mistake this project has made.
lo_in = [v * 12.0 for v in extents[0]]
hi_in = [v * 12.0 for v in extents[1]]
axes = L._axes(lo_in, hi_in)
length_axis, width_axis, thick_axis = axes
length_ft = (hi_in[length_axis] - lo_in[length_axis]) / 12.0
width_ft = (hi_in[width_axis] - lo_in[width_axis]) / 12.0
thickness_in = hi_in[thick_axis] - lo_in[thick_axis]
try:
solid = L.concrete_solid(panel)
except Exception as exc:
return fail('solid failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
if solid is None:
return fail('no concrete solid', families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# THE ELEVATION FLOOR -- purely geometric, owner's ruling 2026-09-02;
# see `reinforcecore.elevation_band`'s module note for the definition.
# Computed for EVERY piece regardless of `elevation_group` above -- it
# is the answer a job with no grouping parameter has, and the one that
# keeps working if the parameter is added or dropped later. Off the
# SAME solid `features` is about to be measured from -- no second
# geometry pass -- plus the panel's own placement transform, which is a
# cheap property read, not a solve. A failure here is a ranking signal
# that could not be determined, never a reason to fail the whole piece:
# `thickness_in` and `features` are still worth having even when the
# panel turns out to be near-square or skewed off every global axis.
elevation_band = _elevation_band_of(panel, extents, solid)
try:
rects = L._openings(solid, frame, lo_in, hi_in, axes)
except Exception as exc:
return fail('openings failed: {0}'.format(exc), families=families,
design_number=design_number, panel_id=_int_id(panel.Id),
elevation_group=elevation_group,
elevation_band=elevation_band,
length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in)
# Penetration is a separate solve and can fail on its own. When it does,
# the rectangles are still real -- so the features are kept and `through`
# goes to None on each, which is the could-not-judge value, not False.
try:
pen = L.opening_penetration(panel, frame, solid, rects, axes,
lo_in, hi_in)
except Exception:
pen = []
features = []
for i, rect in enumerate(rects):
p = pen[i] if i < len(pen) else {}
features.append(core.feature(
name, i, rect[0], rect[1], rect[2], rect[3],
p.get('through'), p.get('concrete_in'),
length_ft, width_ft, thickness_in))
return core.piece_record(
name, _int_id(asm.Id), sig, reinforced, on_hold,
design_number=design_number, panel_id=_int_id(panel.Id),
length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in,
families=families, features=features,
master=master, master_note=note,
elevation_band=elevation_band, elevation_group=elevation_group)
# ------------------------------------------------------------------ refresh
def scope_of(know):
"""The declared scope, or an undeclared one. Never raises on a bad file."""
from vibe import scope as scopelib
if not know:
return scopelib.Scope()
try:
return scopelib.of(know)
except Exception:
return scopelib.Scope()
def scope_in(cache):
"""(Scope, stamped) out of a cache. NO DOCUMENT, NO KNOWLEDGE FILE.
`stamped` is False when the index carries no scope at all -- built before
the stamp existed, or by a caller that had no knowledge layer. That is not
the same as "no scope was declared", and an offline reader has to say
which: the first means nobody knows what the pool was bounded by, the
second means it was deliberately unbounded. Both come back as an
undeclared `Scope()`; only this flag tells them apart.
"""
from vibe import scope as scopelib
stamp = (cache or {}).get('scope')
if not isinstance(stamp, dict):
return scopelib.Scope(), False
return scopelib.Scope(prefixes=stamp.get('prefixes') or (),
note=stamp.get('note'),
declared_by=stamp.get('declared_by'),
source='the index, built ' +
str((cache or {}).get('built_at'))), True
def refresh(doc, budget_s=None, limit=None, rebuild_all=False, know=None,
on_piece=None, targets=True):
"""Bring the index up to date and return (cache, report). READ ONLY.
`budget_s` stops the build partway and leaves the rest for the next call.
That is what makes a cold index usable immediately: the first press
indexes what it can afford, answers with what it has, and says what it
could not see. `report['coverage']['missing']` is that list and a caller
that drops it is claiming a completeness the index does not have.
`rebuild_all` ignores the cached signatures. It is the escape hatch for
the thing signatures cannot see -- an opening moved or resized without
the member set changing -- and it is cheap: a full build of this model's
690 eligible donors measured 6.07 s (16.2 s on the first build after a
Revit restart, measured 2026-08-30).
`targets` (schema 5, default ON) also measures every piece INSIDE THE
DECLARED SCOPE that is not an eligible donor. Those are the pieces
somebody actually wants suggestions for -- not yet reinforced, so donor
eligibility left them out of the file altogether and the target had to be
measured through a live document on every press. With them in the file the
whole chain runs off the snapshot and `tools/reinforce_offline.py` can
answer with no Revit at all.
IT IS THE SCOPE THAT BOUNDS THIS, NOT A PREFIX IN THE CODE. The scope is
read from the job knowledge (`scope.mark_prefixes`), which is where this
project already keeps the answer to "whose pieces are these". An
UNDECLARED scope includes everything, by `vibe.scope`'s standing rule that
no declaration means judge everything -- which here costs measuring the
whole model rather than the donors, roughly one extra second per hundred
assemblies, and produces a `features=None` record for anything with no
structural-framing member to measure. Pass `targets=False` for the
donors-only index schema 4 built.
"""
started = time.time()
path = index_path(doc)
cache = core.load(path)
if cache.get('doc_key') not in (None, doc_key(doc)):
cache = core.new_cache(doc_key(doc))
cache['doc_key'] = doc_key(doc)
the_scope = scope_of(know)
# Stamped into the file so the offline reader, which cannot load a
# knowledge layer without a model, gets the same scope the button had.
cache['scope'] = {'prefixes': list(the_scope.prefixes),
'note': the_scope.note,
'declared_by': the_scope.declared_by}
# And the nomenclature, for the same reason and to the same reader. A
# donor must be the SAME DESIGN as its target (owner ruling, 2026-09-01),
# and reducing two design numbers to one family is done with the plant's
# `nomenclature.design_number_re`. The offline reader has no knowledge
# layer to get that from, so it is stamped here alongside the scope.
# Additive: a file written before this key falls back exactly as before.
from vibe import knowledge as K
cache['nomenclature'] = {
'design_number_re': K.get(know, 'nomenclature.design_number_re', None)
if know else None}
elements = assemblies(doc)
by_name = dict(elements)
t_sig = time.time()
live = live_signatures(doc, elements, know=know)
seconds_signatures = time.time() - t_sig
if rebuild_all:
cache['pieces'] = {}
the_plan = core.plan(cache, live,
include=the_scope.includes if targets else None)
todo = the_plan['build']
if limit is not None:
todo = todo[:limit]
records = []
t_build = time.time()
ran_out = False
for name in todo:
if budget_s is not None and (time.time() - t_build) > budget_s:
ran_out = True
break
asm = by_name.get(name)
if asm is None:
continue
sig = live[name][0]
records.append(extract(doc, name, asm, sig, know=know))
if on_piece is not None:
on_piece(name)
seconds_build = time.time() - t_build
# Rewriting an unchanged cache costs a full serialise for nothing, and the
# commonest press of all is the one where nothing moved. Measured: writing
# the 690-piece file back is the single largest slice of a no-change run.
changed = bool(records) or bool(the_plan['drop'])
cache = core.apply_plan(cache, the_plan, records,
built_at=_stamp() if changed else None)
# MAINTAIN THE FEATURE VOCABULARY on every refresh (schema 6). The registry
# lives on the cache and is NEVER reset by `rebuild_all` -- only
# `cache['pieces']` is cleared above -- so `get_or_add` keeps every existing
# id stable and only assigns ids for shapes genuinely seen for the first
# time. Registering the freshly-built records is sufficient: an unchanged
# (fresh) piece's descriptors were registered when it was first built and
# its ids never move. `_pack`/`_unpack` carry the registry to disk, so the
# offline reader gets the same vocabulary this build produced.
if records:
core.register_features(cache, records)
if changed:
core.save(path, cache)
report = {
'path': path,
'assemblies': len(elements),
'scope': the_scope.label(),
'targets_wanted': len(the_plan.get('targets') or ()),
'seconds_signatures': round(seconds_signatures, 3),
'seconds_build': round(seconds_build, 3),
'seconds_total': round(time.time() - started, 3),
'built': len(records),
'reused': len(the_plan['fresh']),
'dropped': len(the_plan['drop']),
'stopped_early': ran_out or (limit is not None
and len(the_plan['build']) > len(todo)),
'coverage': core.coverage(cache, the_plan),
}
return cache, report
def _stamp():
return time.strftime('%Y-%m-%d %H:%M:%S')
def read(doc):
"""The index as it stands, without rebuilding anything. READ ONLY.
For a caller that only wants to search: `refresh` first if you mean to be
current, `read` if you mean to be fast. Neither touches the model.
"""
return core.load(index_path(doc))
@@ -0,0 +1,805 @@
# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30): "我想 reinforce 一个 piece,然后 AI 帮我找最
# 合适的几个母版 ... 这几个母版拼吧拼吧能把这个 panel 给拼出来 ... 每一个细节
# 都要照顾到,但是尽量选少的母版,覆盖多的细节。"
# Owner, verbatim (2026-08-30), on the measurement unit: "实际测量全部用英寸,
# 带两位小数。"
# Owner, verbatim (2026-08-30), on exactness ranking, not classifying:
# "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排在后
# 面。"
# Owner, verbatim (2026-08-30), on the donor cap: "一个方案,只要找到足够覆盖
# 全部特征的母版,就够了。我期待是 1-4 个,最多不超过 4 个。"
"""The reinforcing-suggestion matcher: a set-cover solver over opaque features.
WHAT THIS ANSWERS
-----------------
Will, 2026-08-30: "I want to reinforce a piece, and have AI find me the
best-fitting master(s) ... these masters, pieced together, can reconstruct
this panel ... every detail has to be accounted for, but pick as few
masters as possible, covering the most detail."
That is not a similarity ranking (the 08-26 attempt built one of those, and it
answers a different question). It is SET COVER: every feature of the target
panel must be accounted for by some donor, and the number of donors must be as
small as possible. Donors may overlap; overlap is expected and is not a
conflict (ruling, 2026-08-30). Coverage is counted BY FEATURE, not by area --
the target's small end features appear identically in both candidate donors of
the worked example, so an area weighting would let two big openings drown them
out, while counting features keeps each one a thing that must be covered.
THE SEAM, AND WHY IT IS HERE ON PURPOSE
---------------------------------------
This module never reads a field off a feature record. Not one. Every
comparison of two features happens inside a single injected callable, `covers`,
whose contract is stated below. Everything else in this file -- coverage, set
selection, ranking, tie-breaking -- works on opaque records and on the numbers
that callable returns.
That boundary is not a scheduling convenience, and it should survive after the
index lands. Comparing two precast features is the one part of this problem
that needs engineering judgement about geometry: what makes a blockout "the
same" blockout, whether a notch at the same station but a different Z band is
the same feature, how a through opening compares to a blind one. That judgement
is exactly the thing this project has been burned by before -- the shim check
that measured the wrong axis, the clash check that reported nothing because a
cylinder's edge curves close on themselves. Isolating it in one small callable
means it can be argued about, measured, and replaced without touching a line of
the selection logic, and means the selection logic can be tested to death on
synthetic data with no model, no licence and no Revit.
So: do not add a fallback here that peeks at a field "just in case". If the
matcher needs something `covers` cannot give it, that is a finding to report,
not a thing to work around.
THE `covers` CONTRACT
---------------------
covers(donor_feature, target_feature) -> None or float
None this donor feature does not cover that target feature at all.
0.0 exact match.
> 0.0 covers it; the number is how far from exact, smaller being closer.
Requirements on any implementation:
1. TOTAL. It must accept any pair of records the caller passes and must not
raise. A pair it cannot judge is `None`, not an exception.
2. PURE AND DETERMINISTIC. Same two records, same answer, every time, with no
dependence on call order or on any global. The matcher's determinism rests
entirely on this.
3. DIRECTIONAL. `covers(a, b)` need not equal `covers(b, a)`. A donor feature
may stand in for a target feature without the reverse being true.
4. COMMENSURABLE, IN INCHES. The returned distances are compared and sorted
ACROSS DIFFERENT FEATURE PAIRS and across different feature kinds. A
distance of 0.4 on a notch must mean the same amount of "off" as 0.4 on a
blockout, or the ranking is comparing apples to oranges. ONE UNIT FOR ALL
KINDS, and that unit is INCHES (owner's ruling, 2026-08-30:
"all actual measurements use inches, to two decimal places"). The owner answered a UNIT question, not a
WEIGHTING one -- there is no per-kind weight here and none is invented; an
inch off on a blockout is an inch off on a notch.
THE STORED GEOMETRY IS STILL FEET and must stay that way: feet is Revit's
internal unit and `reinforcecore` caches feet. The production `covers`
(`reinforcebind.covers`) is the single place the crossing happens, via
`reinforcecore.inches()`. Nothing in THIS module converts anything; it
sorts numbers and does not know what they measure.
PRECISION IS NOT THE SAME AS DISPLAY. Two-decimal inches is 0.01", an
order of magnitude finer than any tolerance this tool uses (dimensions
3/8", interference 1/8"). The production `covers` therefore returns
distances QUANTIZED TO 1/8 in so the ranking cannot be steered by
differences the measurement cannot support. This module imposes nothing:
it will rank whatever grain it is handed. See `reinforcebind.GRAIN_IN`.
5. ABSOLUTE, NOT PROPORTIONAL. Positions compare as absolute distance from the
start of the panel, never as a fraction of panel length. Measured: the two
big openings of the worked example land at the identical feet-from-start
rectangle on panels of two different lengths, and a proportional comparison
would have missed the match entirely. Nothing in this module normalises by
length, and nothing in `covers` should either.
6. TWO-DIMENSIONAL. The same measurement showed two donor features sharing an
x-range while sitting in different Z bands. A single-axis comparison calls
those the same feature. They are not.
7. CHEAP. It is called exactly once per (donor, donor feature, target
feature) triple -- the matcher builds the whole comparison table up front
and never calls it again -- but that is still on the order of a few
thousand calls per run.
Note what the contract does NOT contain: a similarity threshold. There is none,
anywhere in this module, by ruling -- "if there is a completely identical
shape, that ranks at the front of the recommendation; if it is only similar,
it goes further back." Exactness RANKS, it does not CLASSIFY. The
matcher never asks "are these two the same?", only "which of these is closer?",
which is arithmetic rather than an unfalsifiable judgement call. The one
classification that does exist -- covers-at-all vs. does-not -- lives inside
`covers`, where the geometry knowledge is, and it is the only place it may live.
WHAT THE CALLER PASSES
----------------------
suggest(target_features, donors, covers, ...)
`target_features` is a sequence of opaque feature records. `donors` is a
sequence of `(key, feature_list)` pairs -- the key is any hashable value that
identifies the donor to the caller (an element id, a mark, a tuple); this
module treats it as a label and nothing else. Feature records are whatever the
index produces; this module only ever hands them to `covers`.
SAME ELEVATION, RULED 2026-09-02 -- superseding the note this module used to
carry about donor order being the lever for this. WP425 ranked five donors in
a dead tie on every criterion below, and WP234 -- which shares WP425's own
building elevation -- printed last, by mark order, because nothing here knew
elevation existed: owner, "WP234明明在同一个elevation,为什么排名没有提前?"
Approved as briefed the same day ("同意,就这么做"): elevation is an AFFINITY,
the LAST tie-break -- after fewest donors, most exact, most reviewed and
total distance, before mark order -- never a filter, exactly the shape
Will's standing correction on donor order already described (a cross-design
or cross-fit exclusion is not what this is; a better-shaped cross-elevation
donor still wins). It is now an explicit ranking term, `same_elevation`,
rather than a reordering of the input list: see `Proposal.same_elevation`
and the ranking notes below. Donor input order is still the true last
resort -- it decides a tie elevation cannot, same as it always has.
WHY A TERM AND NOT THE ORDERING TRICK THIS MODULE USED TO SUGGEST. Sorting
same-elevation donors to the front of the input list would have worked for
the reported case (a single donor covers everything, so the tie-break really
is just "which donor is listed first"), but it hides the reason inside sort
position -- a report that wants to say WHY WP234 won would have to
recompute the same ordering itself. An explicit term, exactly like `masters`
below, is a fact the caller can read straight off the winning `Proposal` and
print.
AT MOST FOUR DONORS IN A PROPOSAL
---------------------------------
Owner's ruling, 2026-08-30: "for one proposal, it is enough to find masters
that cover all the features between them. I'd expect 1-4, never more than
4." `DEFAULT_MAX_DONORS = 4`, and it is a HARD
cap on the donors WITHIN one proposal. Do not confuse it with `max_proposals`,
which is a different axis entirely: `max_proposals` is how many alternative
answers get printed, `max_donors` is how big any one answer may be.
WHAT HAPPENS WHEN FOUR CANNOT COVER EVERYTHING -- INFERRED, NOT STATED. The
owner ruled the cap; he did not rule the overflow. The reading implemented
here is that the cap is hard: the search returns the best partial cover of
four donors and NAMES what it could not reach, rather than relaxing to a
fifth. That is already this module's established behaviour for the other
reason a feature goes uncovered (nobody has it at all), it is the answer the
first live run gave, and a suggestion that quietly grew to six donors would
not be the thing that was asked for. The two causes are reported separately
and never merged -- see `CAPPED` below.
Everything here is IronPython 2.7 compatible: no f-strings, no pathlib, no
modern typing, no Python 3 `functools`.
"""
# --------------------------------------------------------------------------
# Outcome vocabulary. "Could not judge" and "judged and found nothing" are
# different answers and must read differently -- the standing convention on
# this project, learned from a check that reported a clean result when it had
# in fact measured nothing.
# --------------------------------------------------------------------------
NO_DONORS = 'no_donors' # nothing eligible was offered; nothing was judged
NO_COVERAGE = 'no_coverage' # donors were judged; not one covers any feature
PARTIAL = 'partial' # some feature no donor covers, in any combination
CAPPED = 'capped' # every feature IS coverable; four donors cannot reach them all
COMPLETE = 'complete' # every target feature is covered
# PARTIAL and CAPPED are different findings and must never be collapsed.
# PARTIAL means nobody in the pool has that feature -- the engineer details it
# from scratch. CAPPED means somebody does have it, but not within the four
# donors a proposal is allowed; the engineer can go and find that donor
# himself. When both are true the status is PARTIAL (the harder fact) and
# `Suggestion.capped_out` still names the second set.
# `suggest` returns None -- not an empty result -- when the input cannot be
# judged at all (no target features). An empty `proposals` with a status is
# always the result of an actual judgement.
DEFAULT_MAX_PROPOSALS = 5
# How many donors ONE proposal may contain. The owner's number. This is not
# `max_proposals` -- see the module docstring; conflating the two axes gives
# you either one giant answer or four tiny ones.
DEFAULT_MAX_DONORS = 4
class Proposal(object):
"""One set of donors, and what each target feature got from it."""
def __init__(self, donor_indexes, donor_keys, assignment, uncovered,
master_keys=(), same_elevation_keys=()):
self.donor_indexes = tuple(donor_indexes)
self.donors = tuple(donor_keys)
# Which of THIS proposal's donors are reviewed templates, in the same
# order as `donors`. A label and a tie-break, never a filter.
self.masters = tuple(k for k in self.donors if k in set(master_keys))
self.master_count = len(self.masters)
# Which of THIS proposal's donors share the target's building
# elevation, same shape as `masters` and ranked directly below it --
# see the module docstring's 2026-09-02 note and the ranking
# comment above `_proposal_sort_key`. A label and a tie-break,
# never a filter: a donor whose elevation is unknown, or differs, is
# simply absent from `same_elevation_keys` and this stays empty.
self.same_elevation = tuple(
k for k in self.donors if k in set(same_elevation_keys))
self.elevation_count = len(self.same_elevation)
# assignment[i] is None (feature i uncovered by this proposal) or a
# 3-tuple (donor_index, donor_feature_index, distance).
self.assignment = tuple(assignment)
self.uncovered = tuple(uncovered)
dists = [a[2] for a in self.assignment if a is not None]
# Ascending: the ranking key. See `_proposal_sort_key`.
self.distances = tuple(sorted(dists))
self.exact_count = len([d for d in self.distances if d == 0.0])
self.covered_count = len(self.distances)
self.total_distance = sum(self.distances) if self.distances else 0.0
self.is_exact = (self.covered_count > 0 and
self.exact_count == self.covered_count)
def donor_for(self, feature_index):
"""(donor_key, donor_feature_index, distance) for one target feature.
None when this proposal does not cover that feature.
"""
got = self.assignment[feature_index]
if got is None:
return None
return (self.donors[self.donor_indexes.index(got[0])], got[1], got[2])
def is_master(self, donor_key):
return donor_key in self.masters
def __repr__(self):
return ('<Proposal {0} exact={1}/{2} master={3} elevation={4} '
'total={5:.4f}>').format(
list(self.donors), self.exact_count, self.covered_count,
self.master_count, self.elevation_count, self.total_distance)
class Suggestion(object):
"""The whole answer: ranked proposals plus what could not be covered."""
def __init__(self, status, proposals, uncovered, donor_count,
target_feature_count, donors_needed, cover_count,
donor_cap=None, capped_out=()):
self.status = status
self.proposals = tuple(proposals)
# Target feature indexes that NO donor covers, in any combination.
self.uncovered = tuple(uncovered)
# The donor cap this answer was found under, and the features that
# SOME donor covers but the best proposal could not reach inside it.
# Disjoint from `uncovered` by construction: a feature nobody has is
# not a feature the cap kept out.
self.donor_cap = donor_cap
self.capped_out = tuple(capped_out)
self.donor_count = donor_count
self.target_feature_count = target_feature_count
# Size of the smallest cover FOUND. Not proven minimal -- the search
# is greedy; see the note above `_greedy_covers`. There is no
# `minimal` flag because the answer is always the same: no.
self.donors_needed = donors_needed
self.cover_count = cover_count # how many covers of that size exist
@property
def best(self):
return self.proposals[0] if self.proposals else None
def summary(self):
"""One sentence a button can print without knowing the vocabulary."""
n = self.target_feature_count
if self.status == NO_DONORS:
return ('No eligible donors were offered, so nothing was judged. '
'{0} target feature(s) unexamined.'.format(n))
if self.status == NO_COVERAGE:
return ('Judged {0} donor(s): not one of them covers any of the '
'{1} target feature(s).'.format(self.donor_count, n))
k = self.donors_needed
if self.status == CAPPED:
return ('At most {0} donor(s) per proposal; {1} of them cover {2} '
'of {3} target feature(s). {4} feature(s) are covered by '
'some donor but not by any {0}-donor set: {5}.'.format(
self.donor_cap, k, n - len(self.capped_out), n,
len(self.capped_out), list(self.capped_out)))
if self.status == COMPLETE:
return ('{0} donor(s) cover all {1} target feature(s); {2} such '
'cover(s) found, {3} shown.'.format(
k, n, self.cover_count, len(self.proposals)))
tail = ''
if self.capped_out:
tail = (' A further {0} feature(s) are covered by some donor but '
'not within the {1}-donor cap: {2}.'.format(
len(self.capped_out), self.donor_cap,
list(self.capped_out)))
return ('{0} donor(s) cover {1} of {2} target feature(s); {3} '
'feature(s) no donor covers at all: {4}.{5}'.format(
k, n - len(self.uncovered) - len(self.capped_out), n,
len(self.uncovered), list(self.uncovered), tail))
def __repr__(self):
return '<Suggestion {0} donors={1} features={2} proposals={3}>'.format(
self.status, self.donor_count, self.target_feature_count,
len(self.proposals))
# --------------------------------------------------------------------------
# The coverage table. This is the only place `covers` is called.
# --------------------------------------------------------------------------
def _coverage(target_features, donors, covers):
"""Build, for every donor, the best it can do on every target feature.
Returns (best, masks) where
best[d][i] = None, or (donor_feature_index, distance)
masks[d] = bitmask over target feature indexes donor d covers
A donor may carry several features that cover one target feature (two
notches near one blockout, say). The best one wins: lowest distance, and
among equal distances the lowest donor-feature index, so the choice does
not depend on iteration luck.
An exception raised by `covers` is NOT caught. A comparison that blows up
is a bug in the comparison, and swallowing it here would turn it into a
quiet "this donor covers nothing" -- a confident wrong answer, which is
the exact failure mode this project exists to prevent. A contract-breaking
return value (negative, NaN, not a number) is raised on, naming the pair,
for the same reason.
"""
best = []
masks = []
for d_index in range(len(donors)):
donor_features = donors[d_index][1] or []
row = [None] * len(target_features)
mask = 0
for f_index in range(len(donor_features)):
df = donor_features[f_index]
for t_index in range(len(target_features)):
got = covers(df, target_features[t_index])
if got is None:
continue
dist = _checked_distance(got, d_index, f_index, t_index)
current = row[t_index]
if current is None or dist < current[1]:
row[t_index] = (f_index, dist)
mask |= (1 << t_index)
best.append(row)
masks.append(mask)
return best, masks
def _checked_distance(value, d_index, f_index, t_index):
try:
dist = float(value)
except (TypeError, ValueError):
raise ValueError(
'covers() returned a non-number {0!r} for donor {1} feature {2} '
'vs target feature {3}; the contract is None or a float >= 0'
.format(value, d_index, f_index, t_index))
if dist != dist: # NaN, which compares false with itself
raise ValueError(
'covers() returned NaN for donor {0} feature {1} vs target '
'feature {2}'.format(d_index, f_index, t_index))
if dist < 0.0:
raise ValueError(
'covers() returned a negative distance {0!r} for donor {1} '
'feature {2} vs target feature {3}; 0.0 is exact and there is '
'nothing closer than exact'.format(value, d_index, f_index,
t_index))
return dist
# --------------------------------------------------------------------------
# Set cover, by greedy with restarts.
#
# WHY GREEDY, AND WHERE IT IS WRONG.
#
# Set cover is NP-hard, so this does not solve it; it approximates it, and the
# approximation is chosen deliberately over an exhaustive search. A panel
# carries on the order of five to ten features, an exhaustive search over
# subsets of that universe would also be affordable, and the honest reason to
# take greedy anyway is that it is twenty lines instead of a hundred, has no
# recursion, no node budget and no depth cap to get wrong, and can be read in
# one sitting by whoever inherits it. If evidence ever turns up that it is
# picking badly on real panels, replacing it is a local change: everything
# else in this module works off the donor sets it returns.
#
# WHERE IT CAN RETURN A WORSE ANSWER THAN OPTIMAL. Greedy takes the donor that
# covers the most still-uncovered features, and that first bite can be the
# wrong one. Nine features 1..9, and donors
# D = {3, 6, 9} A = {1, 2, 3} B = {4, 5, 6} C = {7, 8, 9}
# Plain greedy takes D first (it is tied for the biggest bite and happens to
# be listed first), and then still needs all three of A, B and C to finish:
# four donors where {A, B, C} covers everything with three. In general greedy
# can return a factor of ln(n) more donors than the minimum, and a suggestion
# that says "you need four donors" when three would do is exactly the kind of
# wrong that gets a tool stopped being used.
#
# Restarting from every possible first donor (below) removes that whole family
# of failures -- the walk seeded from A finds {A, B, C} -- and at five-to-ten
# features it is hard to construct a case that beats it. It is still not a
# proof, there is no guarantee here, and nothing in the result claims one. If
# a real panel ever shows this picking badly, swap `_greedy_covers` for an
# exhaustive search over subsets: the universe is the target's features, which
# is small enough that a complete search is affordable, and everything else in
# this module works off the donor sets that function returns.
#
# THE RESTARTS. Plain greedy gives one answer, and one answer is not a
# suggestion -- the engineer needs to see the second and third options and be
# able to disagree with the ranking. So greedy is run once per donor, each run
# forced to start from that donor and then filling greedily; the resulting
# covers are deduplicated, the smallest size wins (fewest donors is the
# objective), and exactness ranks what is left. It costs D passes over D
# donors, which at these sizes is nothing.
# --------------------------------------------------------------------------
def _popcount(mask):
n = 0
while mask:
mask &= mask - 1
n += 1
return n
def _greedy_fill(masks, full, chosen, covered, max_donors=None):
"""Extend `chosen` greedily until nothing more can be added.
Takes the donor adding the most still-uncovered features; ties go to the
lowest donor index, so the walk is fixed and the whole module stays
reproducible run to run.
`max_donors` stops the walk once the proposal is that big, even if
features remain. That is the owner's cap, and it is enforced HERE rather
than by trimming a finished cover -- trimming would drop whichever donor
happened to be last, while stopping early means every donor in the four
was chosen for being the biggest remaining bite.
"""
chosen = list(chosen)
while covered != full:
if max_donors is not None and len(chosen) >= max_donors:
break
pick = None
gain = 0
for d in range(len(masks)):
if d in chosen:
continue
added = _popcount(masks[d] & ~covered)
if added > gain:
pick, gain = d, added
if pick is None:
break
chosen.append(pick)
covered |= masks[pick]
return tuple(sorted(chosen)), covered
def _greedy_covers(masks, full, max_donors=None):
"""Candidate covers: greedy restarted from every donor that covers anything.
Returns (k, covers, reached) -- the smallest cover size found among the
covers that get FURTHEST, every distinct cover of that size, and the union
of what they reach. `k` is None and `covers` empty when no donor covers
anything.
REACH BEFORE SIZE, and only because of the cap. Without a cap every greedy
walk ends at `full` (it stops only when no donor adds anything, and every
feature in `full` has some donor), so all candidates reach equally far and
this ordering is the old "fewest donors wins" unchanged. With a cap the
walks can end short, and then a two-donor set covering four features must
not beat a four-donor set covering eighteen: covering the target is the
objective, fewest donors is the tie-break under it.
"""
if full == 0:
return None, [], 0
seen = {}
for d in range(len(masks)):
if not masks[d]:
continue
cover, covered = _greedy_fill(masks, full, (d,), masks[d], max_donors)
seen[cover] = covered
if not seen:
return None, [], 0
reach = max([_popcount(m) for m in seen.values()])
best = [c for c in seen if _popcount(seen[c]) == reach]
k = min([len(c) for c in best])
keep = sorted([c for c in best if len(c) == k])
reached = 0
for c in keep:
reached |= seen[c]
return k, keep, reached
# --------------------------------------------------------------------------
# Ranking.
#
# Fewest donors is the objective and it is not negotiable; exactness only
# breaks ties between covers of equal size. Within one size, the question is
# how per-feature distances combine into something comparable.
#
# NOT BY SUM. Summing loses precisely the information the owner's ruling is
# about. A cover that is exact on four features and 2.0 off on the fifth, and
# a cover that is 0.4 off on all five, have the same sum of 2.0 -- so a sum
# calls them equal, when the owner said plainly that the exact shapes go in
# front: "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排
# 在后面。"
#
# THE KEY IS THE SORTED DISTANCE VECTOR, ASCENDING, COMPARED LEXICOGRAPHICALLY.
# Sort each cover's per-feature distances smallest-first and compare position
# by position. Since exact matches are 0.0 and sort to the front, this makes
# "has more exact features" the first thing that decides, then "its next-best
# feature is closer", and so on down. It is the owner's rule written as
# arithmetic, and it needs no weights, no normalisation and no threshold.
#
# WHAT THAT COSTS, STATED PLAINLY. It is a strict preference for exactness
# over evenness, and it will sometimes look odd. Cover A = [0.0, 5.0] beats
# cover B = [0.01, 0.02], because A is exact on one feature and B is exact on
# none, even though B is obviously the better fit overall. That is the chosen
# behaviour, not an oversight: it is what "完全一样的形状排在前面" means, and
# the alternative (rank by the worst feature, i.e. compare the vectors
# descending) is the "mediocre everywhere wins" rule the brief warns against.
# `Proposal.total_distance` carries the sum anyway, so a UI can show the
# overall figure next to the ranking without it steering the ranking.
#
# THE MASTER TIE-BREAK, AND WHERE IT SITS.
#
# Some donors are marked MASTER by hand in the model, and the owner's ruling
# (2026-08-30) is that those have been REVIEWED: "某一些模板带有 MASTER,给这些
# MASTER 当母版的优先级提升到最高。他们是被审核过的。" It is the only provenance
# signal this feature has -- `TICKET_IS_REINFORCED` says somebody drew bars,
# not that anybody checked them.
#
# Asked directly how it interacts with exactness -- a loosely-fitting MASTER
# against a perfectly exact non-MASTER -- the owner ruled: 拟合更好的排在前面.
# FIT WINS. So MASTER sits BELOW the distance vector and ABOVE input order.
# It cannot reorder anything the distance vector has already separated; it
# decides only between covers the geometry has declared indistinguishable,
# and that is exactly where "who checked this?" is the right question.
#
# Counted, not flagged: a cover with two masters ranks above a cover with one,
# because more of it has been reviewed. Negated in the key so that MORE sorts
# FIRST while everything else still sorts ascending.
#
# THE SAME-ELEVATION TIE-BREAK, AND WHERE IT SITS.
#
# Owner, 2026-09-02, on WP425: five donors tied on every criterion above --
# 1 donor, 5/5 covered, 4 exact, 0 reviewed, 0.04 in total distance, all
# five identical -- and the tie fell through to mark order, so WP133
# printed first though WP234 shares WP425's own building elevation:
# "WP234明明在同一个elevation,为什么排名没有提前?" Ruled the same day,
# approved as briefed ("同意,就这么做"): elevation is an AFFINITY, and it
# sits LAST, below MASTER -- after fewest donors, most exact, most reviewed
# and total distance, and above only the donors' input order. It is ranked
# below MASTER on the same reasoning MASTER is ranked below the distance
# vector: a fact about WHERE a donor sits does not say whether it is a good
# template, and a piece somebody has actually reviewed is worth more than
# one that merely happens to be on the right facade. Nothing here reorders
# what MASTER or the distance vector have already separated -- it only
# decides among covers every criterion above it left dead level, exactly
# the case WP425 hit.
#
# Counted the same way `master_count` is, for the same reason: a cover with
# two same-elevation donors ranks above a cover with one. Negated in the key
# so MORE sorts FIRST.
#
# UNKNOWN IS NEVER A MATCH. `same_elevation_keys` (built by
# `reinforcebind.propose`) never contains a donor whose own elevation could
# not be read, and it is empty outright when the TARGET's elevation could
# not be read -- an unread elevation is could-not-judge, not a wildcard that
# matches everything. See `reinforcecore.elevation_key`.
#
# LAST TIE-BREAK: the donors' input order. Two covers with identical distance
# vectors, master counts and elevation counts are, on the evidence available
# here, equally good, and the matcher has nothing further to say. Falling
# back to input order makes the answer reproducible -- an engineer who runs
# it twice and gets two different answers will not trust it a third time --
# and it is now genuinely the LAST word: mark order, nothing more, no
# remaining lever a caller is expected to pull by reordering donors.
# --------------------------------------------------------------------------
def _proposal_sort_key(proposal):
# `-covered_count` first, and it is a NO-OP everywhere except under the
# donor cap: uncapped, every candidate cover reaches the same feature set,
# so this term is constant and the ranking is exactly what it always was
# (fewest donors, then the distance vector, then MASTER, then same
# elevation, then input order). Capped, it is the difference between
# "eighteen of nineteen" and "four of nineteen, but with only two
# donors".
return (-proposal.covered_count,
len(proposal.donor_indexes),
proposal.distances,
-proposal.master_count,
-proposal.elevation_count,
proposal.donor_indexes)
def _build_proposal(donor_indexes, donors, best, target_count,
master_keys=(), same_elevation_keys=()):
assignment = []
uncovered = []
for t_index in range(target_count):
pick = None
for d_index in donor_indexes:
got = best[d_index][t_index]
if got is None:
continue
if pick is None or got[1] < pick[2]:
pick = (d_index, got[0], got[1])
if pick is None:
uncovered.append(t_index)
assignment.append(pick)
keys = [donors[d][0] for d in donor_indexes]
return Proposal(donor_indexes, keys, assignment, uncovered, master_keys,
same_elevation_keys)
# --------------------------------------------------------------------------
# The entry point.
# --------------------------------------------------------------------------
def suggest(target_features, donors, covers,
max_proposals=DEFAULT_MAX_PROPOSALS, masters=None,
max_donors=DEFAULT_MAX_DONORS, same_elevation=None):
"""Rank the smallest sets of donors that between them cover the target.
target_features -- sequence of opaque feature records for the panel being
reinforced. Order matters only as identity: every index
in the result refers back into this sequence.
donors -- sequence of (key, feature_list) pairs. `key` is opaque
and is only ever used as a label and, at the very end,
as the source of the input order that breaks a dead-even
tie. See the module docstring on why that is a lever.
covers -- the comparison callable. Contract in the module
docstring. This is the only thing in this module that
may read a feature's fields.
masters -- the donor keys that are REVIEWED templates, as any
iterable of keys. Purely a tie-break, ranked below the
distance vector: see the ranking notes above. Keys not
present among `donors` are ignored, and the default of
none is a matcher that ranks exactly as it did before.
same_elevation -- the donor keys that share the TARGET's building
elevation, as any iterable of keys. Purely a
tie-break, ranked below `masters` and above input
order -- see the ranking notes above and
`Proposal.same_elevation`. Keys not present among
`donors` are ignored; the default of none ranks
exactly as before this term existed.
max_proposals -- how many ranked proposals to return. Five by default:
enough that the engineer can see the second and third
options and disagree with the ranking, few enough to
read. `Suggestion.cover_count` reports how many there
actually were.
max_donors -- how many donors ONE proposal may contain. FOUR by the
owner's ruling, and a different axis from
`max_proposals`: this bounds the size of an answer, that
bounds the number of answers. `None` removes the cap.
When four donors cannot reach every coverable feature,
the search returns the best four and names what it could
not reach in `Suggestion.capped_out`, with status
`CAPPED`. It does NOT relax to a fifth. That overflow
rule is inferred, not ruled -- see the module docstring.
Returns a `Suggestion`, or None when the input cannot be judged at all --
that is, when there are no target features. No features means there is
nothing to cover and no basis for any suggestion; that is "could not
judge", and it must not be confused with the empty-but-real results this
returns when donors were examined and found wanting. `Suggestion.status`
separates those: NO_DONORS (nothing eligible was offered, so nothing was
judged) from NO_COVERAGE (donors were examined; none covers anything).
PARTIAL COVERS ARE RETURNED, deliberately. When some feature is covered by
no donor at all, "nothing found" and "here is a cover of nine of your ten
features, and this is the one nobody has" are very different answers, and
only the second is any use: it tells the engineer which detail they have to
reinforce by hand, which is a much shorter job than the whole panel. So the
search runs over the coverable features, the proposals cover all of those,
and `Suggestion.uncovered` names the rest by index. `status` is PARTIAL so
no caller can mistake it for a complete answer.
"""
if not callable(covers):
raise ValueError('suggest() needs a covers(donor_feature, '
'target_feature) callable; got {0!r}'.format(covers))
if not target_features:
return None # could not judge -- see docstring
target_count = len(target_features)
all_features = tuple(range(target_count))
donors = list(donors or [])
master_keys = frozenset(masters or ())
same_elevation_keys = frozenset(same_elevation or ())
if not donors:
return Suggestion(NO_DONORS, [], all_features, 0, target_count,
None, 0)
best, masks = _coverage(target_features, donors, covers)
full = 0
for m in masks:
full |= m
if full == 0:
return Suggestion(NO_COVERAGE, [], all_features, len(donors),
target_count, None, 0)
uncovered = tuple([i for i in all_features if not (full & (1 << i))])
k, cover_sets, _reached = _greedy_covers(masks, full, max_donors)
proposals = [_build_proposal(cs, donors, best, target_count, master_keys,
same_elevation_keys)
for cs in cover_sets]
proposals.sort(key=_proposal_sort_key)
# What the CAP kept out, read off the proposal that will be shown first
# rather than off the union: the engineer acts on the answer he is given.
# A feature nobody has is never counted here -- that is `uncovered`, a
# different finding with a different remedy.
nobody = set(uncovered)
capped_out = tuple([i for i in (proposals[0].uncovered if proposals else ())
if i not in nobody])
if uncovered:
status = PARTIAL
elif capped_out:
status = CAPPED
else:
status = COMPLETE
return Suggestion(status, proposals[:max_proposals], uncovered,
len(donors), target_count, k, len(cover_sets),
donor_cap=max_donors, capped_out=capped_out)
# --------------------------------------------------------------------------
# A reference `covers`, for tests and for reading.
#
# THIS IS NOT THE PRODUCTION COMPARISON and must not be mistaken for one. It
# makes no claim about the index's feature schema -- it operates on a bare
# 4-tuple chosen because it is the smallest thing that can express the worked
# example, and it encodes no precast engineering judgement whatsoever. Its job
# is to be a fixed, legible stand-in so the selection logic above can be tested
# exhaustively before the real comparison exists, and to be an executable
# statement of the contract.
# --------------------------------------------------------------------------
def rect_distance(donor_rect, target_rect):
"""Reference `covers` over rectangles `(x0, x1, y0, y1)`, unit-agnostic.
Coordinates are absolute, measured from the start of the piece -- never a
fraction of its length.
Covers-at-all: each rectangle must contain the other's centre. That is a
two-dimensional test on purpose, and it is why it is written as a mutual
condition rather than a range overlap: in the worked example two donor
features share an x-range with a target feature while sitting in a
different Z band, and a single-axis check calls those the same feature.
They are not.
Distance: the sum of the four absolute corner offsets, IN WHATEVER UNIT
THE COORDINATES ARE IN. Zero for identical rectangles, and one unit for
every kind of feature, as the contract requires.
DELIBERATELY UNIT-AGNOSTIC, unlike the production comparison. This is a
stand-in over bare tuples for testing the selection logic; it declares no
unit because its inputs declare none, and it applies no quantization. The
production `covers` -- `reinforcebind.covers` -- reads feet off index
records and returns INCHES on a 1/8 in grain, which is where the owner's
unit ruling actually lands. Do not read this function as the unit
contract; read requirement 4 above.
"""
try:
dx0, dx1, dy0, dy1 = [float(v) for v in donor_rect]
tx0, tx1, ty0, ty1 = [float(v) for v in target_rect]
except (TypeError, ValueError):
return None # not judgeable -> not covered
def contains(x0, x1, y0, y1, cx, cy):
return x0 <= cx <= x1 and y0 <= cy <= y1
if not contains(dx0, dx1, dy0, dy1, (tx0 + tx1) / 2.0, (ty0 + ty1) / 2.0):
return None
if not contains(tx0, tx1, ty0, ty1, (dx0 + dx1) / 2.0, (dy0 + dy1) / 2.0):
return None
return (abs(dx0 - tx0) + abs(dx1 - tx1) +
abs(dy0 - ty0) + abs(dy1 - ty1))
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
"""The DECLARED SCOPE: which pieces of a job this engineer is answerable for.
WHY THIS EXISTS
---------------
Several engineers work one job. The owner of this tool details the wall
panels; the beams, the slabs and the columns belong to other people. The
plant-level knowledge every check in here runs on -- naming, hardware,
bed layout, tolerances -- was validated against the panels and NOT against
the rest, and the owner cannot validate it against the rest, because he does
not hold the job knowledge for somebody else's pieces.
So a tool that judges all of them is not being thorough. It is producing
verdicts on work it has no basis to judge, in the same report and the same
typeface as the verdicts it can stand behind.
WHY THIS IS NOT AN IGNORE LIST
------------------------------
An exclusion that silently drops things is the exact failure this project has
already paid for: one unauthorised exclusion hid forty tickets and every
check that ran came back clean. Nothing here may repeat it, so:
* OUT OF SCOPE IS A THIRD STATE. Not a pass, not a failure, not an absence.
Every report that leaves work alone says so, in a line, naming how much
and of what kind.
* THE SCOPE IS DECLARED, NEVER INFERRED. It is read from the JOB knowledge
layer -- `scope.mark_prefixes` -- because who is responsible for what is
a fact about this job and this office, not about the plant and certainly
not about the code. No prefix is written into this file.
* NO DECLARATION MEANS JUDGE EVERYTHING. An undeclared scope is not an
empty scope. `Scope()` includes every piece, and its report line says
that nothing was set aside AND that the knowledge behind those verdicts
may never have been validated outside the usual work.
* A PIECE WHOSE KIND CANNOT BE READ IS NEVER SET ASIDE. If a mark carries
no readable letter prefix, the tool does not know whether it is in scope,
and "I could not tell" may only ever resolve towards judging it. Those
are counted and named on the same line.
* A NEAR MISS IS REPORTED, NEVER RESOLVED. See below.
THE NEAR MISS, AND WHY THE MATCH IS NOT WIDENED
------------------------------------------------
`prefix_of` reads the WHOLE leading letter run, so a declaration of `AAA`
does not reach a mark `AAAB001` -- its prefix is `AAAB`, which is not `AAA`,
and the piece is set aside as somebody else's. That is a real hazard: on a
job whose marks carry a suffix letter for a variant, a genuine piece of this
engineer's is quietly handed to another engineer.
The obvious repair -- match a declared prefix as a PREFIX, so `AAA` reaches
`AAAB` -- is not made here, and deliberately:
* It would also reach every OTHER letter run that starts the same way,
pulling pieces that really are somebody else's INTO the checked set.
* A false in-scope is worse than a false out-of-scope. Out of scope is
reported, in a line, by kind. In scope is judged silently against
knowledge that was never validated for it -- which is the exact failure
this module exists to prevent.
* Which of the two a given job means is a JOB FACT. It belongs in the job
file, declared by a person, not in a rule this code guessed.
So the behaviour is unchanged and the near miss is REPORTED: a set-aside
kind that is a prefix of a declared one, or that a declared one is a prefix
of, is named on the report with both spellings and the sentence saying the
tool did not guess. A human widens the declaration -- by adding the longer
prefix to `scope.mark_prefixes` -- or leaves it alone. Nothing here decides.
WHAT THE SCOPE IS EXPRESSED IN, AND WHY
---------------------------------------
The MARK PREFIX -- the letters a piece mark starts with. Reasons, in order:
* Every entry point already has a mark, or an assembly name that is the
mark, in its hand. Sheets are named for their mark too. One rule reaches
all of them.
* The prefix is the ONE stable classifier. A design number moves -- new
designs are added to a live job weekly, and a scope listed as design
numbers goes stale silently, which is the failure mode this file exists
to prevent.
* It is what the reinforcing donor filter already discriminated on, so
adopting it is not a new concept in this codebase, it is the existing
one lifted out of the code and into the job file.
A job whose marks do NOT encode the discipline in their prefix cannot use
this key, and must not be given a guessed answer: it declares nothing, gets
"no scope declared", and every report says every piece was judged. Adding a
second expression -- design numbers, a category, an explicit mark list -- is
a new key beside this one, never a reinterpretation of this one.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.compat import string_types
# Where the declaration lives. JOB layer -- see the module docstring.
KEY = 'scope.mark_prefixes'
NOTE_KEY = 'scope.note'
BY_KEY = 'scope.declared_by'
# The leading letters of a mark. Deliberately letters-only: a mark is
# <letters><number> at every plant seen so far, and anything else is
# 'could not read', which is a reported state rather than a guess.
_PREFIX_RE = re.compile(r'^([A-Za-z]+)')
# Letters followed by a number: the shape of a piece mark or a design number.
# Used where a caller holds a name that MIGHT be a mark -- a sheet name, say
# -- and must not treat 'the general arrangement drawing' as somebody else's
# piece. Anything that is not this shape is left alone by the scope entirely.
_MARK_RE = re.compile(r'^[A-Za-z]+\s*\d')
# What a piece with no readable prefix is called on a report line.
UNREADABLE = '(no readable mark prefix)'
def prefix_of(name):
"""The leading letters of a mark, upper case, or None if there are none."""
if not isinstance(name, string_types):
name = '' if name is None else str(name)
m = _PREFIX_RE.match(name.strip())
return m.group(1).upper() if m else None
def looks_like_mark(name):
"""True when this text has the shape of a piece mark or design number.
A caller holding something that is only SOMETIMES a mark (a sheet name)
passes names through this first, so a drawing that is not a piece is
never counted as another engineer's piece.
"""
if not isinstance(name, string_types):
name = '' if name is None else str(name)
return bool(_MARK_RE.match(name.strip()))
def as_mark(name):
"""The name if it reads as a mark, else '' -- which every predicate here
treats as 'could not tell', i.e. left in."""
return name if looks_like_mark(name) else ''
class Scope(object):
"""The declared scope as a predicate. Construct with no argument for
'nobody has declared one', which includes everything."""
def __init__(self, prefixes=(), note=None, declared_by=None,
source='job knowledge: ' + KEY):
cleaned = []
for p in (prefixes or ()):
text = ('' if p is None else str(p)).strip().upper()
if text and text not in cleaned:
cleaned.append(text)
self.prefixes = tuple(cleaned)
self.note = note or None
self.declared_by = declared_by or None
self.source = source
# -------------------------------------------------------------- state
@property
def is_declared(self):
return bool(self.prefixes)
def label(self):
"""What the scope IS, in words a reader can check against reality."""
if not self.is_declared:
return 'no scope declared'
return 'piece marks beginning {0}'.format(
' or '.join(self.prefixes))
# ---------------------------------------------------------- predicate
def includes(self, name):
"""True when this mark is the engineer's to judge.
True for everything when no scope is declared, and true for a mark
whose prefix cannot be read -- 'I could not tell' never removes work.
"""
if not self.is_declared:
return True
prefix = prefix_of(name)
if prefix is None:
return True
return prefix in self.prefixes
def is_unreadable(self, name):
return prefix_of(name) is None
def kind(self, name):
"""The group a set-aside item is reported under."""
return prefix_of(name) or UNREADABLE
# ------------------------------------------------------------ splits
def split(self, items, key=None):
"""(inside, outside) preserving order. `key` reads the mark."""
key = key or (lambda x: x)
inside, outside = [], []
for item in (items or []):
(inside if self.includes(key(item)) else outside).append(item)
return inside, outside
def split_names(self, names):
return self.split(list(names or []))
def tally(self, names):
"""[(kind, count)] for the things set aside, biggest first."""
counts = {}
for name in (names or []):
kind = self.kind(name)
counts[kind] = counts.get(kind, 0) + 1
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
# -------------------------------------------------------- the near miss
def nearly(self, kind):
"""The declared prefix `kind` NEARLY is, or None.
'Nearly' is deliberately narrow: one of the two spellings is a
prefix of the other. `AAAB` nearly matches a declared `AAA`, and a
bare `AAA` nearly matches a declared `AAAB` -- either way somebody
wrote one and meant, perhaps, the other. `BBB` nearly matches
nothing. An exact match is not a near miss, it is a match.
"""
if not self.is_declared or not kind or kind == UNREADABLE:
return None
for declared in self.prefixes:
if kind == declared:
return None
if kind.startswith(declared) or declared.startswith(kind):
return declared
return None
def near_misses(self, names):
"""[(kind, count, declared)] for set-aside kinds that nearly match.
Biggest first. Empty when the scope is undeclared, when nothing was
set aside, or when nothing set aside resembles the declaration --
which is the ordinary case and prints nothing.
"""
out = []
for kind, count in self.tally(names):
declared = self.nearly(kind)
if declared:
out.append((kind, count, declared))
return out
def near_miss_line(self, outside_names, unit='piece'):
"""The sentence a human has to read, or None.
Says both spellings and says the tool did not choose between them.
Never a verdict: a near miss is a question for the person who holds
the job knowledge, and the only two answers -- widen the declaration
or leave it -- are both his.
"""
misses = self.near_misses(outside_names)
if not misses:
return None
bits = ', '.join(
'{0} {1}(s) with mark prefix {2} (the declaration says {3})'
.format(count, unit, kind, declared)
for kind, count, declared in misses)
return ('NEAR MISS -- SOMEBODY SHOULD LOOK: {0} were set aside. A '
'prefix that only NEARLY matches is either work of yours the '
'declaration does not reach, or somebody else\'s work that '
'happens to start the same way, and this tool will not guess '
'which -- guessing wrong towards "yours" would judge another '
'engineer\'s piece silently. If they are yours, add the '
'longer prefix to the declaration ({1}); if they are not, '
'nothing needs doing and this line is the record that it was '
'looked at.'.format(bits, self.source))
# ------------------------------------------------------------ report
def line(self, outside_names, total=None, unit='piece',
unreadable_names=None):
"""ONE line saying what was left alone and why. Never a pass.
`outside_names` are the marks that were NOT judged. `total` is how
many there were altogether, so the reader can see the proportion;
omit it and the line simply does not claim one.
A NEAR MISS RIDES ON THIS LINE rather than on a line of its own, so
that every consumer of `line()` reports it without having to know it
exists. A report that wants it on a shut fold's summary as well asks
for `near_miss_line()` directly.
"""
outside = list(outside_names or [])
unreadable = list(unreadable_names or [])
tail = ''
if unreadable:
tail = (' {0} {1}(s) carry no readable mark prefix; they could '
'not be placed inside or outside the scope and were '
'judged rather than set aside.'
.format(len(unreadable), unit))
near = self.near_miss_line(outside, unit=unit)
if near:
tail += ' ' + near
if not self.is_declared:
return ('NOT JUDGED, OUT OF SCOPE: none set aside. No scope is '
'declared for this job ({0} is empty), so every {1} was '
'judged -- including work this plant knowledge has never '
'been validated against. Declare a scope to be told what '
'is being taken on trust.'.format(self.source, unit)
+ tail)
if not outside:
# Deliberately does NOT say 'everything here was in scope'. The
# user may have selected an out-of-scope piece himself, which is
# judged and annotated rather than set aside; this line's job is
# only to say that nothing was removed from the run.
return ('NOT JUDGED, OUT OF SCOPE: none -- nothing was set aside '
'from this run. The declared scope is {0} ({1}).'
.format(self.label(), self.source) + tail)
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
of_total = ' of {0}'.format(total) if total else ''
return ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) were left alone -- '
'{3}. The declared scope is {4} ({5}); everything else '
'belongs to another engineer and the knowledge this tool '
'runs on is not validated for it. Not judged is not clean, '
'and not a failure either.'
.format(len(outside), of_total, unit, kinds, self.label(),
self.source) + tail)
def short_line(self, outside_names, total=None, unit='piece'):
"""The same three states in ONE SHORT CLAUSE, for a console.
`line()` is the sentence; this is the headline. It exists because
some consumers truncate -- `tools/sweep.py` prints the first 200
characters of what the in-Revit half returns -- and a scope line
that gets cut off the end of a progress line is a scope line nobody
read. It says the COUNT, which is what tells a reader there is a
full sentence somewhere worth opening.
It never replaces `line()`. Anything printing this must also write
`line()` where the reader can reach it.
"""
outside = list(outside_names or [])
if not self.is_declared:
return ('SCOPE: none declared -- every {0} was judged'
.format(unit))
of_total = ' of {0}'.format(total) if total else ''
if not outside:
return ('SCOPE {0}: no {1}{2} was set aside'
.format(self.label(), unit, of_total))
kinds = self.tally(outside)
shown = ', '.join('{0} {1}'.format(k, n) for k, n in kinds[:3])
if len(kinds) > 3:
shown += ', +{0} more'.format(len(kinds) - 3)
text = ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) left alone [{3}]'
.format(len(outside), of_total, unit, shown))
if self.near_misses(outside):
text += ' -- NEAR MISS'
return text
def annotation(self, outside_names, unit='piece'):
"""The line for work the USER picked that falls outside the scope.
Nothing the user selected is ever dropped -- see `honoured_selection`
in the design notes. It is judged, and this says the verdict rests on
knowledge nobody has validated for that kind of piece.
"""
outside = list(outside_names or [])
if not self.is_declared or not outside:
return None
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
# A near miss matters MORE here than on `line()`: the reader is being
# told his own selection rests on unvalidated knowledge, and if the
# prefix only nearly missed, that warning may be the wrong one.
near = self.near_miss_line(outside, unit=unit)
return ('JUDGED OUTSIDE THE DECLARED SCOPE, AT YOUR REQUEST: {0} '
'{1}(s) -- {2}. You selected them, so nothing was dropped; '
'but the declared scope is {3}, and these verdicts rest on '
'knowledge that has not been validated for those pieces. '
'Read them as opinions, not as checks.'
.format(len(outside), unit, kinds, self.label())
+ ((' ' + near) if near else ''))
def declaration_lines(self):
"""Who declared it and what they said, when they said anything."""
out = []
if self.declared_by:
out.append('scope declared by {0}'.format(self.declared_by))
if self.note:
# A YAML folded scalar (`note: >`) arrives with a trailing
# newline, which puts a blank line into every report that prints
# it. Stripped here rather than at four call sites.
out.append(' '.join(str(self.note).split()))
return out
def _as_list(value):
if value is None:
return []
if isinstance(value, string_types):
return [value]
return list(value)
def of(know, get=None):
"""The declared scope out of a loaded knowledge dict.
`get` is `knowledge.get`. It is a parameter so a test can pass its own
and this module never has to import `knowledge` at module scope; the
ordinary path imports it lazily, and reads the three keys through the
alias `K` with LITERAL paths so `tools/knowledge_drift.py` can see that
they are genuinely read. A key nobody can prove is read is a key the
file only claims to honour.
"""
if not know:
return Scope()
if get is None:
from vibe import knowledge as K
return Scope(prefixes=_as_list(K.get(know, 'scope.mark_prefixes', None)),
note=K.get(know, 'scope.note', None),
declared_by=K.get(know, 'scope.declared_by', None))
return Scope(prefixes=_as_list(get(know, 'scope.mark_prefixes', None)),
note=get(know, 'scope.note', None),
declared_by=get(know, 'scope.declared_by', None))
@@ -0,0 +1,720 @@
# -*- coding: utf-8 -*-
"""Naming, finding and vouching for model snapshots. NO REVIT, NO DOCUMENT.
This is the half of the snapshot feature that can be reasoned about without a
licence: what a timestamped detached copy is called, which one is newest, what
a snapshot has to say about where it came from, and what the run log means.
The Revit half is the button
(`WillTools.tab/Tools.panel/SnapshotClose.pushbutton` -- a large button on the
Tools panel since 2026-08-30, a small one in a stack before that) and
the deferred runner (`WillTools.extension/runners/detach_snapshot.py`); both
import this and neither duplicates any of it.
IronPython 2.7 on the extension side, CPython 3.12 in `tools/` -- so no
f-strings, no pathlib, no dataclasses, and `os.path` throughout.
THE THREE PHASES, because every function here is named after one
-----------------------------------------------------------------
The owner's flow is a once-a-day close-model ritual, and the ordering is what
makes it cheap:
(he syncs, himself, outside all of this)
A press the button -> index the OPEN document. The model is already in
RAM and already paid for. Seconds.
B close Revit. The press IS the authorisation; that is the button's name.
C AFTER Revit has exited and the 10 GB is free, a deferred host process
launches one Revit, writes the timestamped detached copy, and quits.
THE ONE-MODEL PREMISE, AND WHY IT NO LONGER RULES THE DESIGN
------------------------------------------------------------
Everything above was built on "the machine holds ONE model at a time, ever":
a 300+ MB file costing 10+ GB open, so Phase C had to wait until the
engineer's Revit was gone. **That premise is empirically false and was
retired on 2026-09-01** -- the live cloud session and a detached sandbox
session ran side by side for a whole morning on this machine. What is still
true is the weaker statement, and it is the one the code now enforces:
NEVER THREE. Two Revits are fine. A third is the one nobody measured.
So `tools/snapshot_cycle.py` sequences rather than waits: it closes the
sandbox BEFORE it launches the Revit that writes the new copy, and launches
the new sandbox only once that one has exited. At every instant there are at
most the live session and one other. The machine-wide "wait until no Revit is
running at all" gate in `tools/snapshot_defer.py` still exists and is still
correct for the close-model ritual, but it is now opt-in
(`--machine-free require`) rather than the law.
The CYCLE that replaced Phase C's tail: make the timestamped copy, close the
sandbox Revit, reopen it on the new copy, run a batch inside it over the
bridge, close it again, journal the lot. Its events are CYCLE_START ..
CYCLE_DONE below, in this same log.
WHY PHASE A NEEDS NO DETACHED COPY, WRITTEN DOWN SO IT IS NOT RE-LITIGATED
--------------------------------------------------------------------------
A snapshot read from the live document immediately after a sync and a
snapshot read from a detached copy of that same sync state contain the same
numbers: the detach is a transport, not a measurement. The detached copy
therefore is not evidence for today's index at all. Its remaining purpose is
FUTURE RE-SCANS -- extractors that do not exist yet, run against the state as
it was on a day nobody can go back to -- and the provenance says exactly that
so a reader does not mistake it for the source of the current numbers.
THE LAYOUT DECISION, AND THE TRAP UNDER IT
-------------------------------------------
The owner asked for the model file to be called
`<name>_detached_YYYYMMDD-HHMM`. Put the stamp in the FILE name and `doc.Title`
changes on every snapshot -- and `doc.Title` is what
`designspec.root_dir` keys the whole job folder on (`WILLTOOLS_JOBS` makes the
job folder `<jobs root>/<doc.Title>` literally). A per-snapshot title means a
per-snapshot job folder: no `knowledge.yaml` in it, so no declared scope and
no parameter-role mapping, and an index built there is both wrong and
invisible to the live session. This office has already met that problem once
and solved it by hand, with a directory junction from the detached title back
to the real job folder -- a fix that does not survive a name that changes
daily.
So the DEFAULT puts the stamp in the FOLDER:
<root>/<title>_20260830-1612/<title> layout 'folder' (default)
<root>/<title>_20260830-1612 layout 'filename'
(both with the model extension on the last segment)
Same string the owner asked for, one path segment up. The title stays
constant, one junction serves every snapshot forever, and the stamp is still
right there in the path Revit prints in its title bar. `layout 'filename'` is
kept, selectable, and `plan()` returns the warning that goes with it rather
than refusing -- a plant that does not use `WILLTOOLS_JOBS` is not exposed to
the trap and should not be lectured about it in code it cannot change.
Discovery reads BOTH layouts, so a folder that already holds hand-made copies
in either shape is understood without being converted.
"""
# pylint: disable=broad-except,invalid-name
import json
import os
import re
import time
# `20260830-1612`. Minute resolution: the process that writes one of these
# takes minutes and nobody presses this button twice inside sixty seconds.
STAMP_FMT = '%Y%m%d-%H%M'
STAMP_RE = re.compile(r'^(?P<base>.+)_(?P<stamp>\d{8}-\d{4})$')
# Named MODEL_EXT rather than after the extension itself: `tools/scrub.py`
# matches <word> + extension case-insensitively, so a constant called RVT
# reads as a model filename at every call site and fails the leak audit.
MODEL_EXT = '.rvt'
# The layouts `plan()` can produce and `snapshots()` can read.
FOLDER = 'folder'
FILENAME = 'filename'
LAYOUTS = (FOLDER, FILENAME)
FILENAME_WARNING = (
"layout 'filename' puts the timestamp in the model's own name, so "
"doc.Title changes on every snapshot. With WILLTOOLS_JOBS set, the job "
'folder is <jobs root>/<doc.Title> -- a new, empty one per snapshot, with '
'no knowledge.yaml, so nothing downstream sees the declared scope. Use '
"layout 'folder' unless you know this job does not resolve that way.")
# What produced a snapshot. `kind` in the provenance block.
LIVE_DOC = 'live-doc' # Phase A: the open model, in session
FRESH_DETACH = 'fresh-cloud-detach' # Phase C: detached from the cloud now
EXISTING_COPY = 'existing-detached-copy' # a re-scan of a file already on disk
KINDS = (LIVE_DOC, FRESH_DETACH, EXISTING_COPY)
PROVENANCE_SCHEMA = 1
# One JSON object per line, appended, never rewritten. Chosen over a single
# JSON document because two processes write it -- the button in Revit and the
# deferred host tool -- and an append of one line cannot corrupt what is
# already there, while a read-modify-write of a whole document can.
LOG_NAME = 'snapshots.jsonl'
# Log event names. The pair that matters is REQUESTED / DONE: a REQUESTED with
# nothing after it is a Phase C that never reported, and that must be visible.
REQUESTED = 'phase-c-requested'
DONE = 'phase-c-done'
FAILED = 'phase-c-failed'
SKIPPED = 'phase-c-skipped'
SNAPSHOT = 'phase-a-snapshot'
# The CYCLE, added 2026-09-01. The old three phases describe one close-model
# ritual; these describe the pipeline that replaced its tail --
# `tools/snapshot_cycle.py`: make the copy, cycle the sandbox Revit onto it,
# run a batch inside it, close it again. Same log, same file, appended by the
# same never-raising `append()`, because a reader wants ONE ledger per job and
# not two that have to be interleaved by hand.
#
# CYCLE_START is written before anything is launched and CYCLE_DONE /
# CYCLE_FAILED after the sandbox is closed, so the same "requested and never
# reported" question `outstanding()` asks of phase C can be asked of a cycle:
# a CYCLE_START with nothing after it is a run that died with a Revit still
# open, which is the one outcome that costs the machine 10 GB until somebody
# notices.
CYCLE_START = 'cycle-start'
CYCLE_DONE = 'cycle-done'
CYCLE_FAILED = 'cycle-failed'
SANDBOX_OPENED = 'sandbox-opened'
SANDBOX_CLOSED = 'sandbox-closed'
BATCH_DONE = 'batch-done'
CYCLE_TERMINAL = (CYCLE_DONE, CYCLE_FAILED)
# `tools/snapshot_defer.py`'s own lifecycle, added 2026-09-02 after an
# incident where it crashed (or was never even reachable) with nothing
# anywhere saying so. WATCHER_LAUNCHED is written once the request has been
# read and the wait/launch is genuinely about to happen -- proof that the
# process this ritual depends on actually started, which a REQUESTED line
# alone cannot prove (the button can write REQUESTED and then fail to spawn
# the host process, or spawn it into a crash before it opens a single file).
# WATCHER_CRASHED is the backstop for anything the existing FAILED paths
# do not already name -- an exception nobody anticipated, caught at the
# outermost level so it is journaled before the process exits rather than
# only printed to a stderr nobody is redirecting (the button spawns this
# tool with `CreateNoWindow = True` and no output redirection at all: an
# unjournalled stderr line there is not merely easy to miss, it is provably
# unread by anything).
WATCHER_LAUNCHED = 'watcher-launched'
WATCHER_CRASHED = 'watcher-crashed'
# ------------------------------------------------------------------- stamping
def stamp(when=None):
"""`YYYYMMDD-HHMM` for a time, or now."""
return time.strftime(STAMP_FMT, time.localtime(when)
if when is not None else time.localtime())
def is_stamp(text):
"""True for exactly `YYYYMMDD-HHMM`. Never raises."""
try:
return bool(re.match(r'^\d{8}-\d{4}$', text or ''))
except Exception:
return False
def split_stamped(name):
"""('base', 'YYYYMMDD-HHMM') out of a stamped name, or (None, None).
The stamp is matched at the END and the base is whatever precedes it, so a
base that itself contains underscores, spaces, or the word `detached` --
all three occur in real titles -- survives intact.
"""
try:
hit = STAMP_RE.match(name or '')
except Exception:
return None, None
if not hit:
return None, None
return hit.group('base'), hit.group('stamp')
def stamped_name(base, stamp_text):
"""`<base>_<stamp>`. The one place the two are joined."""
return '{0}_{1}'.format(base, stamp_text)
# ----------------------------------------------------------- where one goes
def plan(root, base, stamp_text=None, layout=FOLDER):
"""Where the next detached copy goes, and what it will be called.
Returns a dict, never a tuple: this grew a field twice already and a
caller unpacking three values is a caller that breaks on the fourth.
root the configured detached-copies folder
base the SaveAs base name -- normally the detached document's own
title as Revit reports it, so the title never moves
layout 'folder' (default) or 'filename'; see the module docstring
-> {'layout', 'stamp', 'folder', 'path', 'title', 'title_is_stable',
'warning'}
`title` is what `doc.Title` will be after the SaveAs, which is the value
every job-folder lookup downstream keys on. It is returned, rather than
left implicit, because getting it wrong is the expensive failure here and
a caller should be able to print it before committing to the write.
"""
if layout not in LAYOUTS:
layout = FOLDER
stamp_text = stamp_text or stamp()
if layout == FILENAME:
title = stamped_name(base, stamp_text)
return {'layout': FILENAME, 'stamp': stamp_text, 'folder': root,
'path': os.path.join(root, title + MODEL_EXT), 'title': title,
'title_is_stable': False, 'warning': FILENAME_WARNING}
folder = os.path.join(root, stamped_name(base, stamp_text))
return {'layout': FOLDER, 'stamp': stamp_text, 'folder': folder,
'path': os.path.join(folder, base + MODEL_EXT), 'title': base,
'title_is_stable': True, 'warning': None}
# --------------------------------------------------------- finding the newest
def _model_in(folder):
"""The single model file inside a snapshot folder, or None.
More than one is not an error worth raising over -- Revit leaves backups
(a `.0001` counter glued into the name) beside a saved central -- so the SHORTEST name wins,
which is the one without the backup counter glued on.
"""
try:
names = [n for n in os.listdir(folder)
if n.lower().endswith(MODEL_EXT) and
os.path.isfile(os.path.join(folder, n))]
except Exception:
return None
if not names:
return None
names.sort(key=lambda n: (len(n), n))
return os.path.join(folder, names[0])
def snapshots(root):
"""Every timestamped detached copy under `root`, OLDEST FIRST.
Both layouts, in one list, each row saying which it is. A row is only
produced when the model file actually exists: a stamped folder whose model was
deleted or never finished writing is not a snapshot, and reporting it as
one is how a caller ends up handing Revit a path to nothing.
Never raises. A `root` that does not exist is an empty list, because "not
configured yet" and "configured and empty" are the caller's to tell apart
(see `newest`) and neither is exceptional here.
"""
rows = []
try:
names = os.listdir(root)
except Exception:
return rows
for name in names:
full = os.path.join(root, name)
try:
is_dir = os.path.isdir(full)
except Exception:
continue
if is_dir:
base, stamp_text = split_stamped(name)
if not stamp_text:
continue
path = _model_in(full)
if not path:
continue
title = os.path.splitext(os.path.basename(path))[0]
rows.append({'layout': FOLDER, 'stamp': stamp_text, 'base': base,
'folder': full, 'path': path, 'title': title})
continue
if not name.lower().endswith(MODEL_EXT):
continue
base, stamp_text = split_stamped(os.path.splitext(name)[0])
if not stamp_text:
continue
rows.append({'layout': FILENAME, 'stamp': stamp_text, 'base': base,
'folder': root, 'path': full,
'title': os.path.splitext(name)[0]})
# The stamp is fixed-width and zero-padded, so string order IS time order;
# `path` breaks a tie so the answer does not depend on listdir order.
rows.sort(key=lambda r: (r['stamp'], r['path']))
return rows
def newest(root):
"""The most recent snapshot under `root`, or None. Never raises."""
rows = snapshots(root)
return rows[-1] if rows else None
# ------------------------------------------------------------- provenance
def provenance(phase, kind, source_path=None, source_stamp=None,
source_title=None, revit=None, pyrevit=None, job_folder=None,
note=None, when=None):
"""Where a snapshot came from. Attached to the index the snapshot wrote.
A snapshot that cannot say where it came from is the staleness trap this
whole feature exists to close, so every field it needs to answer that is
here whether or not the caller has it:
phase 'A' (from the open document) or 'C' (deferred, detached)
kind one of KINDS -- live document, fresh detach from the
cloud, or a re-scan of a file already on disk. These
are NOT interchangeable and a reader must not have to
guess which happened.
source_path the file the numbers were read from. None for a live
document that has never been saved -- which is itself
worth knowing, so it is recorded as None rather than ''.
source_stamp that file's snapshot timestamp, when it has one. A live
document has none; the field is None and not the run
time, because a source's age and a run's age are
different facts and conflating them is the exact lie
this block exists to prevent.
revit/pyrevit the versions that did the reading.
job_folder where the index was written.
`kind` is validated: an unrecognised value is kept but flagged, so a typo
surfaces as a question instead of quietly becoming a fourth category.
"""
block = {
'schema': PROVENANCE_SCHEMA,
'phase': phase,
'kind': kind,
'kind_known': kind in KINDS,
'source': {'path': source_path, 'stamp': source_stamp,
'title': source_title},
'revit': revit,
'pyrevit': pyrevit,
'job_folder': job_folder,
'at': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(when)
if when is not None else time.localtime()),
'note': note,
}
return block
def provenance_line(block):
"""One printable sentence out of a provenance block. Never raises."""
if not isinstance(block, dict):
return 'no provenance -- this snapshot cannot say where it came from'
src = block.get('source') or {}
where = src.get('path') or src.get('title') or '(unsaved document)'
when = src.get('stamp') or 'no snapshot stamp'
return ('phase {0}, {1}, from {2} ({3}), Revit {4}, written {5}'
.format(block.get('phase') or '?',
block.get('kind') or 'unknown source',
where, when, block.get('revit') or '?',
block.get('at') or '?'))
# --------------------------------------------------------------- the run log
def log_path(folder):
return os.path.join(folder, LOG_NAME)
def append(path, row):
"""One event onto the end of the log. Never raises, ever.
Called from a button that is about to close Revit and from a background
host process, and in both a failure to journal must not become a failure
to do the work. A lost line is acceptable; a crash on the way out is not.
"""
try:
row = dict(row or {})
row.setdefault('at', time.strftime('%Y-%m-%d %H:%M:%S'))
folder = os.path.dirname(path)
if folder and not os.path.isdir(folder):
os.makedirs(folder)
text = json.dumps(row, sort_keys=True)
handle = open(path, 'ab')
try:
handle.write(text.encode('utf-8'))
handle.write(b'\n')
finally:
handle.close()
return True
except Exception:
return False
def read(path, limit=None):
"""The log as a list of dicts, oldest first. A damaged line is skipped.
A half-written last line is the normal consequence of a process being
killed, and it must cost that one line and nothing else -- a log that
raises on read is a log that stops being read.
"""
rows = []
try:
handle = open(path, 'rb')
try:
raw = handle.read().decode('utf-8', 'replace')
finally:
handle.close()
except Exception:
return rows
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
if limit is not None and len(rows) > limit:
rows = rows[-limit:]
return rows
def outstanding(rows):
"""The Phase C that was asked for and never reported back, or None.
A deferred step that silently did not run is the failure mode this design
is most exposed to: the button has already closed Revit, so nobody is
watching, and the next day looks exactly like a day it worked. So the
ledger is paired -- one REQUESTED line written before the close, one
DONE/FAILED/SKIPPED line written by the deferred process -- and this
reports a REQUESTED with nothing after it.
Matching is by `run` id, not by position, so an old unfinished request
still shows after a later one succeeded rather than being papered over by
it. Requests with no id at all fall back to "any later terminal event
clears it", which is the best a line written by an older version allows.
"""
terminal = (DONE, FAILED, SKIPPED)
closed = set()
for row in (rows or ()):
if row.get('event') in terminal and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != REQUESTED:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
# No id: cleared only by a terminal line that comes after it.
later = [r for r in (rows or ())[i + 1:] if r.get('event') in terminal]
if not later:
pending = row
return pending
def last(rows, event):
"""The most recent row with this event, or None."""
for row in reversed(list(rows or ())):
if row.get('event') == event:
return row
return None
def summary(rows):
"""What the button prints about snapshots that already happened.
A list of plain sentences, newest fact first, and it says the awkward
thing out loud: a Phase C that was requested and never reported is named
here, with its timestamp, because the alternative is that it stays
invisible until somebody wonders why the detached folder is empty.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot has been recorded for this job yet']
pending = outstanding(rows)
if pending is not None:
lines.append('WARNING: a detached copy was requested {0} and never '
'reported back. It may have failed, or the machine may '
'have been shut down before it ran.'
.format(pending.get('at') or 'at an unknown time'))
done = last(rows, DONE)
if done is not None:
lines.append('last detached copy: {0} ({1})'
.format(done.get('path') or '?', done.get('at') or '?'))
failed = last(rows, FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last detached copy FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
snap = last(rows, SNAPSHOT)
if snap is not None:
lines.append('last index snapshot: {0}, {1} pieces'
.format(snap.get('at') or '?',
snap.get('pieces')
if snap.get('pieces') is not None else '?'))
return lines
# ------------------------------------------------- the command lines, exactly
def pyrevit_run_argv(pyrevit_exe, script, revit_year=None, purge=True):
"""`pyrevit run <script> [--revit=YYYY] [--purge]` as an argv list.
Built here rather than formatted at the call site so the string the owner
is asked to paste and the string the deferred tool actually runs are
produced by the same code and cannot drift apart.
`--purge` removes the temporary run environment afterwards and is ON:
without it every run leaves a runtime folder behind, and this runs daily.
"""
argv = [pyrevit_exe, 'run', script]
if revit_year:
argv.append('--revit={0}'.format(revit_year))
if purge:
argv.append('--purge')
return argv
def quote(argv):
"""An argv list as a copy-pasteable command line. Windows quoting rules."""
out = []
for arg in argv:
arg = '{0}'.format(arg)
if arg and not re.search(r'[\s"]', arg):
out.append(arg)
else:
out.append('"' + arg.replace('"', '\\"') + '"')
return ' '.join(out)
def revit_exe_guess(year):
"""Where Revit installs itself, for a year. A GUESS -- the caller checks.
Returned unchecked and named `_guess` on purpose: `snapshot.revit_exe` is
the configured answer and this is only what to try when nobody has given
one. A path returned from here that does not exist is the pipeline's cue
to say "configure snapshot.revit_exe", not to invent a second guess.
"""
if not year:
return None
return os.path.join(
os.environ.get('ProgramFiles', 'C:\\Program Files'), 'Autodesk',
'Revit {0}'.format(year), 'Revit.exe')
def pyrevit_exe_guess():
"""Where the pyRevit CLI installs itself. A GUESS -- the caller checks.
Same discipline as `revit_exe_guess`: returned only when the file is
actually there, and never a substitute for `snapshot.pyrevit_exe` stated
and WRONG -- a caller that read an explicit, bad path must never silently
fall back to this. Both `tools/snapshot_cycle.py` and the SnapshotClose
button already did this guess, each with its own copy; this is the one
copy, so `tools/snapshot_defer.py` -- which used to have none at all and
would `argparse.error()` (no journal, nothing) if `--pyrevit` was left
off -- can make the same guess a caller forgot to make for it.
"""
guess = os.path.join(os.environ.get('APPDATA', ''), 'pyRevit-Master',
'bin', 'pyrevit.exe')
return guess if os.path.isfile(guess) else None
_YEAR_RE = re.compile(r'(20\d{2})')
def revit_year_from_exe(path):
"""A 4-digit year recovered from a `Revit.exe` path, or None.
`pyrevit run` accepts an optional `--revit=<year>`; on a machine with
several Revit versions attached (this office had five on 2026-09-01:
2022 through 2026) omitting it is not safe to leave to whatever the CLI
picks by default -- `pyrevit revits` reproducibly throws a bare
"Object reference not set to an instance of an object" on this machine,
and `pyrevit run` with no `--revit=` appears to walk the same path.
`snapshot.revit_exe` almost always carries the year in its own path
(`...\\Autodesk\\Revit 2024\\Revit.exe`), so this recovers it as a
fallback when `snapshot.revit_year` was never separately configured --
which is exactly the job layer shape that produced the 2026-09-01
incident: `revit_exe` set, `revit_year` not.
"""
hit = _YEAR_RE.search(path or '')
return hit.group(1) if hit else None
def revit_exe_argv(revit_exe, model_path):
"""`Revit.exe <model>` as an argv list -- the sandbox launch.
Deliberately not `pyrevit run`: that plays a journal and quits, which is
what a one-shot detach wants and the opposite of what a session an agent
is going to drive over the bridge for ten minutes wants. This starts an
ordinary interactive Revit on one file; pyRevit loads in it exactly as it
does when the file is double-clicked, which is what puts the bridge up.
"""
return [revit_exe, model_path]
_UNSAFE = re.compile(r'[^A-Za-z0-9._-]+')
def batch_log_name(index, command):
"""`batch-01-python-tools-sweep.log` -- one log file per batch command.
The command is in the NAME as well as in the log, because a folder of
`batch-01.log`, `batch-02.log` is a folder nobody opens twice. Truncated
hard: the index is what makes it unique, the words are only there to be
recognised.
"""
text = _UNSAFE.sub('-', '{0}'.format(command or '')).strip('-')
if len(text) > 48:
text = text[:48].rstrip('-')
return 'batch-{0:02d}{1}.log'.format(int(index),
'-' + text if text else '')
def outstanding_cycle(rows):
"""A CYCLE_START with no terminal line after it, or None.
The cycle's version of `outstanding()`, and it matters more: an
unfinished phase C left a copy unwritten, while an unfinished cycle can
have left a Revit open on a 300 MB model with nobody in front of it.
Matched on the `run` id, same as `outstanding()`.
"""
closed = set()
for row in (rows or ()):
if row.get('event') in CYCLE_TERMINAL and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != CYCLE_START:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
later = [r for r in (rows or ())[i + 1:]
if r.get('event') in CYCLE_TERMINAL]
if not later:
pending = row
return pending
def cycle_summary(rows):
"""What the last cycle did, as plain sentences. Newest fact first.
Kept separate from `summary()` rather than folded into it: the button
prints the phase ledger and the pipeline prints this, and a run that
reported nothing has to be as loud in one as in the other.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot cycle has been recorded for this job yet']
pending = outstanding_cycle(rows)
if pending is not None:
lines.append(
'WARNING: a snapshot cycle started {0} and never reported back. '
'A sandbox Revit (pid {1}) may still be open on this machine.'
.format(pending.get('at') or 'at an unknown time',
pending.get('sandbox_pid') or 'unknown'))
done = last(rows, CYCLE_DONE)
if done is not None:
lines.append('last cycle: {0}, {1} batch command(s), {2} s'
.format(done.get('at') or '?',
done.get('batch_count')
if done.get('batch_count') is not None else '?',
done.get('seconds')
if done.get('seconds') is not None else '?'))
failed = last(rows, CYCLE_FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last cycle FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
opened = last(rows, SANDBOX_OPENED)
if opened is not None:
lines.append('last sandbox opened: {0} (pid {1}) on {2}'
.format(opened.get('at') or '?',
opened.get('pid') or '?',
opened.get('path') or '?'))
return lines
+526
View File
@@ -0,0 +1,526 @@
# -*- coding: utf-8 -*-
"""The shape of a design spec, and how it reads and writes as text.
Nothing in this module touches Revit, a document, or the filesystem -- it is
pure data in, text or data out. That is deliberate: it is the part of the
design-spec pipeline that can be exercised by an ordinary CPython test with no
licence, no model and no running Revit, the same way `panellift` is the pure
side of the handling-check boundary (see CLAUDE.md). `designspec.py` is the
Revit side of THIS boundary -- it gathers evidence from the model and reads
and writes these shapes to disk. Anything about what a spec MEANS, or how it
prints, belongs here; anything about reading THIS model belongs there.
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib, no
dataclasses.
"""
# pylint: disable=invalid-name
import datetime
import json
import re
from vibe import yamlkit
from vibe.compat import string_types
SCHEMA_VERSION = 1
# ------------------------------------------------------------- spec shape
#
# The contract every downstream check relies on. Anything the reader is
# unsure about belongs in 'unresolved' rather than being guessed at.
#
# reinforcing.strand_layout is a MATRIX, not four parallel lists: columns_in
# is where the strands sit across the piece (cumulative distance from datum,
# same numbers a detailer would read off the dimension chain), and rows is
# one entry per layer through the thickness, each carrying which of those
# columns actually holds a strand at that height. A design where every column
# has a strand at every row -- the ordinary case -- just has 'strands' filled
# solid; a design that is not a full grid says so directly, column by column,
# rather than forcing a human to reverse-engineer it from a total count.
SPEC_TEMPLATE = {
'schema': SCHEMA_VERSION,
'design_number': None,
'piece_description': None,
'source': {'view': None, 'view_id': None, 'sheet': None,
'model': None, 'extracted': None, 'extractor': None,
'reviewed_by': None, 'reviewed_on': None},
'concrete': {'fc_psi': None, 'fci_psi': None,
'unit_weight_pcf': None, 'weight_class': None},
'reinforcing': {
'mild_grade_ksi': None,
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
'strand_groups': [], # {qty, dia_in, grade_ksi, it_lb, role, note}
'strand_layout': {
'datum': None, # 'left_rail' | 'right_rail' | 'centerline'
'columns_in': [], # strand positions across the piece,
# cumulative distance from the datum
'rows': [], # [{'from_bottom_in':, 'strands': [...]}]
# one entry per layer through the
# thickness; 'strands' marks which column
# holds a strand at that height
'total_in': None, # the chain total; should equal the piece
# width
'note': None,
},
},
'envelope': {'length': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None},
'width': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None}},
'thickness_in': None,
'notes': [], # instructions that stay prose
'unresolved': [], # [{text, reason}] nothing would guess at
'confidence': {}, # field path -> high | medium | low
}
REQUIRED_PATHS = (
'design_number',
'concrete.fc_psi',
'concrete.fci_psi',
)
def get_path(spec, dotted, default=None):
node = spec
for part in dotted.split('.'):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return node if node is not None else default
def validate_spec(spec):
"""Cheap sanity check. Returns a list of complaints, empty when happy."""
problems = []
if not isinstance(spec, dict):
return ['spec is not an object']
if spec.get('schema') != SCHEMA_VERSION:
problems.append('schema is {0}, expected {1}'
.format(spec.get('schema'), SCHEMA_VERSION))
for path in REQUIRED_PATHS:
if get_path(spec, path) is None:
problems.append('missing ' + path)
for i, group in enumerate(get_path(spec, 'reinforcing.strand_groups', []) or []):
for key in ('qty', 'dia_in'):
if group.get(key) is None:
problems.append('strand_groups[{0}] missing {1}'.format(i, key))
if group.get('qty') is not None and group['qty'] <= 0:
problems.append('strand_groups[{0}] qty is {1}'.format(i, group['qty']))
fc = get_path(spec, 'concrete.fc_psi')
fci = get_path(spec, 'concrete.fci_psi')
if fc and fci and fci > fc:
problems.append("f'ci ({0}) is higher than f'c ({1})".format(fci, fc))
for field, lo, hi in (('concrete.fc_psi', 2000, 15000),
('concrete.fci_psi', 1500, 12000),
('concrete.unit_weight_pcf', 90, 200)):
val = get_path(spec, field)
if val is not None and not (lo <= val <= hi):
problems.append('{0} = {1} is outside {2}..{3}'.format(field, val, lo, hi))
return problems
# ---------------------------------------------------------- design numbers
#
# THE ONE PLACE TWO DESIGN NUMBERS ARE COMPARED. It used to live in
# `designspec.py`, which imports `Autodesk.Revit.DB` at module scope, so the
# only way to ask "are these the same design?" was through a module that needs
# a licence. `reinforcebind` has to ask it -- both in Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all -- so the
# function moved down here, where nothing imports Revit, and `designspec`
# re-exports it. Same rule, same name, one implementation: a second spelling
# of this question is how two answers start.
# Used only when nomenclature is unmapped. Letters plus the first number is
# the commonest convention, but it is a fallback, not a rule -- the plant's
# knowledge file is where the real pattern belongs.
FALLBACK_DESIGN_RE = r'^([A-Za-z]+)\s*(\d+)'
def _design_pattern(know, pattern=None):
"""The plant's design-number pattern, or the fallback.
`pattern` wins outright when it is given: it is how a caller that already
resolved the key -- `reinforcebind.DonorFilter`, which must stay free of
any knowledge import -- hands the same rule in without re-reading it.
Imported late: `knowledge` reaches for `designspec`, which imports this
module, so a module-level import would be circular.
"""
if pattern:
return pattern
if know is None:
return FALLBACK_DESIGN_RE
from vibe import knowledge as K
return K.get(know, 'nomenclature.design_number_re', FALLBACK_DESIGN_RE)
def design_key(value, know=None, pattern=None):
"""Reduce anything that names a design to the key everything matches on.
A design packet's cover sheet is numbered with a page suffix, so the same
design is written PNL1 on a piece and PNL1.0 on its summary sheet, with
PNL1.A and PNL1.B1 further into the packet. Comparing those raw splits one
design into several that each fail to find the others -- so every design
number, wherever it comes from, passes through here first.
Returns None for anything the pattern does not recognise, which keeps
junk out of the picker rather than inventing a design for it. None is
COULD NOT READ THIS, never "no design": a caller that treats it as a
value will happily match two pieces whose design numbers nobody parsed.
"""
text = (value or '')
try:
text = text.strip()
except AttributeError:
return None
if not text:
return None
try:
m = re.match(_design_pattern(know, pattern), text)
except Exception: # pylint: disable=W0703
m = re.match(FALLBACK_DESIGN_RE, text)
if not m:
return None
groups = [g for g in m.groups() if g] or [m.group(0)]
return ''.join([g.strip() for g in groups]).upper()
def new_spec(design, evidence=None):
"""A blank spec pre-filled with provenance."""
spec = json.loads(json.dumps(SPEC_TEMPLATE)) # deep copy
spec['design_number'] = design
if evidence:
views = evidence.get('summary_views') or []
sheets = evidence.get('sheets') or []
spec['source']['view'] = views[0]['name'] if views else None
spec['source']['view_id'] = views[0]['id'] if views else None
spec['source']['sheet'] = ('{0} - {1}'.format(sheets[0]['number'],
sheets[0]['name'])
if sheets else None)
spec['source']['model'] = evidence.get('model')
spec['source']['extracted'] = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
return spec
# An extractor that renames a field breaks every check that reads it, silently
# and in the safe-looking direction: the value reads as None and the check
# just does not run. Known synonyms are mapped back onto the canonical names
# on load, and validate_spec still complains about anything left missing.
_ALIASES = {
'qty': ('count', 'quantity', 'number', 'n'),
'dia_in': ('diameter_in', 'dia', 'diameter'),
'grade_ksi': ('grade', 'grade_k', 'ksi', 'strength_ksi'),
'it_lb': ('it_lbs', 'initial_tension_lb', 'it', 'tension_lb',
'initial_tension', 'it_lb_force'),
}
def _first_number(value):
"""270, '270K', '270 ksi' -> 270.0."""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
m = re.search(r'[\d.]+', str(value).replace(',', ''))
return float(m.group(0)) if m else None
def normalize_spec(spec):
"""Map known field synonyms back onto the canonical names, in place."""
if not isinstance(spec, dict):
return spec
groups = get_path(spec, 'reinforcing.strand_groups', []) or []
for group in groups:
if not isinstance(group, dict):
continue
for canonical, synonyms in _ALIASES.items():
if group.get(canonical) is None:
for alt in synonyms:
if group.get(alt) is not None:
group[canonical] = group[alt]
break
for numeric in ('qty', 'dia_in', 'grade_ksi', 'it_lb'):
if group.get(numeric) is not None:
group[numeric] = _first_number(group[numeric])
if group.get('qty') is not None:
group['qty'] = int(group['qty'])
concrete = spec.get('concrete')
if isinstance(concrete, dict):
for canonical, alt in (('fc_psi', 'fc'), ('fci_psi', 'fci'),
('unit_weight_pcf', 'pcf')):
if concrete.get(canonical) is None and concrete.get(alt) is not None:
concrete[canonical] = concrete[alt]
if concrete.get(canonical) is not None:
concrete[canonical] = _first_number(concrete[canonical])
return spec
# --------------------------------------------------------- the strand matrix
_BLANK_MARKS = ('', 'null', 'none', '.', '-', '0')
def strand_positions(layout):
"""Cumulative distances from the datum, whichever spec shape wrote them.
New specs carry 'columns_in'; a spec written before the matrix shape
carries the same numbers under 'positions_in'. Either way this is what
check_strand_layout compares against what was actually modelled.
"""
if not isinstance(layout, dict):
return []
cols = layout.get('columns_in')
if cols is None:
cols = layout.get('positions_in')
return [c for c in (cols or []) if c is not None]
def strand_row_marks(row, n_columns):
"""Which of n_columns holds a strand in this row.
A row from a spec written before the matrix shape has no 'strands' list
at all -- it only ever said WHERE the row sits, not which columns it
fills -- so it is read as a full row, which is what every such spec
actually was.
"""
marks = row.get('strands') if isinstance(row, dict) else None
if marks is None:
return [True] * n_columns
out = []
for i in range(n_columns):
value = marks[i] if i < len(marks) else None
out.append(value is not None and
str(value).strip().lower() not in _BLANK_MARKS)
return out
def _fmt_in(value):
if value is None:
return '?'
text = '{0:.3f}'.format(float(value))
text = text.rstrip('0').rstrip('.')
return text if text else '0'
_DIAGRAM_WIDTH = 68 # target character width of the scaled ruler
def strand_diagram(layout):
"""An ASCII elevation of the strand matrix, drawn from the data itself.
Datum on the left, the chain's segment lengths along a scaled ruler, and
one row of marks per strand row at its height -- the same three things a
detailer reads off the dimension chain on the drawing, in the same left-
to-right order, so the two can be held side by side and compared. There
is nowhere for the picture to get a number the data does not have: every
character position is computed from columns_in / total_in / rows.
"""
if not isinstance(layout, dict):
return ['(no strand layout)']
columns = strand_positions(layout)
total = layout.get('total_in')
rows = [r for r in (layout.get('rows') or []) if isinstance(r, dict)
and r.get('from_bottom_in') is not None]
datum = layout.get('datum') or '(datum not set)'
if not columns or not total:
return ['datum: {0}'.format(datum),
'(not enough data to draw yet -- fill in columns_in, '
'total_in and rows)']
scale = min(1.0, float(_DIAGRAM_WIDTH) / float(total))
def col(pos):
return int(round(pos * scale))
width = col(total) + 1
ticks = [0] + [col(c) for c in columns] + [col(total)]
lengths = []
prev = 0.0
for c in columns:
lengths.append(c - prev)
prev = c
lengths.append(total - prev)
ruler = [u'-'] * width
for t in ticks:
ruler[t] = u'+'
for i in range(len(ticks) - 1):
a, b = ticks[i], ticks[i + 1]
label = _fmt_in(lengths[i])
gap = b - a - 1
if gap >= len(label):
start = a + 1 + (gap - len(label)) // 2
for j, ch in enumerate(label):
ruler[start + j] = ch
lines = [u'datum: {0} (rail on the left, {1}" total)'.format(datum,
_fmt_in(total)),
u''.join(ruler)]
for r in sorted(rows, key=lambda row: -row['from_bottom_in']):
marks = strand_row_marks(r, len(columns))
line = [u' '] * width
for pos, has in zip([col(c) for c in columns], marks):
line[pos] = u'X' if has else u'.'
n = sum(1 for m in marks if m)
lines.append(u'{0} {1}" from bottom ({2} strand{3})'.format(
u''.join(line), _fmt_in(r['from_bottom_in']), n,
u'' if n == 1 else u's'))
lines.append(u'columns (in, from datum): ' +
u', '.join(_fmt_in(c) for c in columns))
return lines
# --------------------------------------------------------- human-readable IO
SPEC_HEADER = u"""# WillTools design spec -- {design}
#
# A HAND-CORRECTABLE reading of the design summary sheet, not a certified
# transcription. Every value below was read by machine (see source.extractor)
# and NONE of it has been checked by a person unless source.reviewed_by is
# set. Ticket QC and Panel Lift Check read this file, not the drawing --
# compare the two before trusting either one.
#
# Edit it by hand where the machine got it wrong. It is read back exactly as
# written: comments and blank lines are ignored, nothing here is re-derived
# from anything else in the file.
"""
_SOURCE_NOTE = u'''# Where this reading came from, and whether anyone has checked it since.
# extractor says HOW it was read: 'regex' is the mechanical text/geometry
# reader -- no judgement applied, every value either matched a rule or is
# null. Nothing branches on the value; it is recorded so a spec written by
# one reader is not mistaken for a spec written by another.
# reviewed_by / reviewed_on are blank until a person sets them by hand after
# checking this file against the drawing.'''
_CONCRETE_NOTE = u'''# f'c and f'ci feed the lift check's concrete-strength gates directly
# (concrete.fc_psi, concrete.fci_psi) -- get one wrong and every handling
# stage is checked against the wrong strength, in whichever direction makes
# the piece look better than it is.'''
_MILD_NOTE = u'''# Mild reinforcing grade, where the design summary states one.'''
_WWF_NOTE = u'''# Welded wire fabric: the mesh designation as printed, how many layers, and
# whether it is one layer per face or shared.'''
_STRAND_GROUPS_NOTE = u'''# One entry per strand group the design summary calls out separately --
# shielded or de-tensioned strands get their own line. it_lb (initial
# tension) feeds the lift check's prestress force directly
# (reinforcing.strand_groups[].it_lb): a wrong qty or it_lb over- or
# understates the precompression credited on every handling stage.'''
_ENVELOPE_NOTE = u'''# The min/max piece size this design is good for, read by pairing each
# MAX./MIN. LENGTH/WIDTH label with the dimension text beside it on the
# drawing. Checked against the piece actually modelled.'''
_UNRESOLVED_NOTE = u'''# Every text note on the design summary that no rule above could place,
# verbatim, with why. This is not optional and it is not a bug list -- it is
# the mechanism that keeps a line of the drawing from being silently dropped
# just because nothing recognised it. A rule that does not match returns
# nothing on its own; landing here is what makes that visible instead of
# quiet. Read every line before trusting this spec, and fold anything real
# into the fields above by hand.'''
_NOTES_NOTE = u'''# Instructions that stay prose rather than being encoded as a field --
# stripping/blocking callouts, cut-down rules, anything that is an
# instruction rather than a number.'''
_CONFIDENCE_NOTE = u'''# Per-field confidence, field path -> high | medium | low. Only ever set
# where a reading made a judgement call -- and only for a field that same
# reading actually just filled, never for one the mechanical reader had
# already placed. The mechanical reader makes no judgement calls to rate, so
# this stays empty on a 'regex' spec.'''
def _strand_layout_note(layout):
diagram = strand_diagram(layout if isinstance(layout, dict) else {})
lines = [u'# Where the strands sit, as a matrix: columns_in is the position of',
u'# each strand across the piece (cumulative distance from datum, the',
u"# same numbers a detailer reads off the dimension chain); each row is",
u'# one layer through the thickness, marking which columns it fills. A',
u'# correct strand COUNT in the wrong PLACE is still a wrong piece, which',
u'# is why this is a grid rather than a total.',
u'#',
u'# The picture below is drawn from the data beneath it and cannot drift',
u'# from it -- if a number here looks wrong, the picture will look wrong',
u'# the same way. Hold it next to the drawing.',
u'#']
for line in diagram:
lines.append(u'# ' + line if line else u'#')
return u'\n'.join(lines)
_REINFORCING_ORDER = ('mild_grade_ksi', 'wwf', 'strand_groups', 'strand_layout')
_REINFORCING_NOTES = {
'mild_grade_ksi': _MILD_NOTE,
'wwf': _WWF_NOTE,
'strand_groups': _STRAND_GROUPS_NOTE,
}
def _reinforcing_block(reinforcing):
reinforcing = reinforcing or {}
lines = [u'reinforcing:']
for key in _REINFORCING_ORDER:
if key not in reinforcing:
continue
lines.append(u'')
if key == 'strand_layout':
lines.append(_strand_layout_note(reinforcing.get('strand_layout')))
else:
for note_line in _REINFORCING_NOTES[key].split(u'\n'):
lines.append(u' ' + note_line)
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
for key in reinforcing:
if key not in _REINFORCING_ORDER:
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
return u'\n'.join(lines)
_SECTION_ORDER = ('schema', 'design_number', 'piece_description', 'source',
'concrete', 'reinforcing', 'envelope', 'thickness_in',
'notes', 'unresolved', 'confidence')
_SECTION_NOTES = {
'source': _SOURCE_NOTE,
'concrete': _CONCRETE_NOTE,
'envelope': _ENVELOPE_NOTE,
'notes': _NOTES_NOTE,
'unresolved': _UNRESOLVED_NOTE,
'confidence': _CONFIDENCE_NOTE,
}
def spec_text(spec):
"""The whole spec, as hand-editable, commented YAML."""
spec = spec or {}
out = [SPEC_HEADER.format(design=spec.get('design_number') or '?')]
for key in _SECTION_ORDER:
if key not in spec:
continue
out.append(u'')
if key == 'reinforcing':
out.append(_reinforcing_block(spec.get('reinforcing')))
continue
note = _SECTION_NOTES.get(key)
if note:
out.append(note)
out.extend(yamlkit.emit({key: spec[key]}, 0))
for key in spec:
if key not in _SECTION_ORDER:
out.append(u'')
out.extend(yamlkit.emit({key: spec[key]}, 0))
return u'\n'.join(out) + u'\n'
def parse_spec_text(text):
"""The inverse of spec_text(): hand-edited YAML back to a spec dict."""
return yamlkit.parse(text)

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