Files
willtools-demo/docs/revit-bridge-context.md
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

237 lines
12 KiB
Markdown

# The Revit bridge, and the two languages either side of it
Context for anyone asked to read the live model. Read this before writing a
script that talks to Revit.
## What the bridge is
Revit runs a pyRevit routes server. `tools/rvt.py` posts a snippet of Python to
it, that snippet executes **inside Revit's own process**, and whatever it leaves
in a variable named `result` comes back as JSON.
PYTHONIOENCODING=utf-8 <python3.12> tools/rvt.py exec <script.py>
<python3.12> tools/rvt.py status # which document is open
<python3.12> tools/rvt.py exec -c "..." # one-liner
`python` is not on PATH. `<python3.12>` is the Python 3.12 install under the
current user's `AppData\Local\Programs`; `CLAUDE.md` spells out the full path,
and so does whoever hands you a task.
`PYTHONIOENCODING=utf-8` is not optional. Ticket text in this job contains
`U+200E`, an invisible mark, and printing a result that carries it raises
`UnicodeEncodeError` on a cp1252 console — the bridge succeeded and the client
crashed on the way out.
## Two languages, and they are not the same one
**Inside Revit: IronPython 2.7.** No f-strings, no `pathlib`, no `dataclasses`,
no `subprocess` worth using. `unicode` and `basestring` exist; `vibe.compat`
exists for the differences. Write code you would have written in 2015.
**Outside: CPython 3.12.** `panellift/` and `tools/` are this side. The two
halves never import each other; they communicate through a JSON job file.
## Rules that are not negotiable
- **Read only, unless you have been told otherwise in this specific task.** No
`Transaction`, no `doc.Delete`, no parameter writes. If the script you are
about to write would modify the document, do not run it.
Measured 2026-08-30: **no button and no check writes to the model.** Ticket
QC, Panel Lift, Extract Design and Self Check all read the document and
write only to disk. `helpers.transaction()` exists and nothing calls it;
`startup.py` wraps bridge execs in a transaction, which is why a snippet run
through the bridge *can* write. The tool does not modify models because
nobody has made it, not because it cannot.
That is a property worth keeping deliberately rather than by accident. A
read-only tool's worst failure is a wrong report, which the reader can
disbelieve. Once it writes, its worst failure is wrong geometry in a model
somebody builds from, discovered days later.
**The first feature with a real reason to write is reinforcing-suggestion**,
and the engineer's call on 08-30 was to build it read-only first: find the
closest donor and show it, leave the copying to a person. The reason is not
timidity. Picking the right donor is the only hard part -- copying is
mechanical -- and if both ship together, a bad result cannot be attributed:
eighty-four elements in the wrong place look the same whether the donor was
wrong or the transform was.
- **Never use pyRevit's Reload.** It leaves the routes handler pointing at a
dead scripting engine and the next request kills Revit outright — an
unhandled exception on a non-main thread, which Revit cannot catch. Restart
Revit instead. `rvt.py` refuses to talk to a bridge that has survived a
reload, which is the only protection there is.
- **Your script freezes Revit for exactly as long as it runs.** The bridge
executes on Revit's API thread. There is no background about it: while your
snippet is running, the person at the keyboard has a frozen application.
Assume that cost before you write the loop, batch the work into small pieces,
and ask before running anything long against a model somebody is using.
The expensive move is enumerating a view's contents. `FilteredElementCollector(doc, view.Id)`
on a view nobody has opened **forces Revit to generate that view**, which is
what "generating graphics" in the status bar means. Walking every placed view
of twelve sheets froze a cloud model for five minutes. If all you need is
which views are on a sheet, `GetAllPlacedViews()` gives you their names
without touching their contents — reading the contents is the part that costs.
- **The second person to run something measures the cache, not the code.**
Most of what a view or a schedule costs is paid once per session, by
whoever asks for it first; everyone after that gets it for a hundredth of
the price. Measured here on one design: the first ticket read took 47.2 s,
the next two took 0.04 s and 0.05 s, and the reading half was all of it --
the checking half was 0.19 s. A bigger design cost minutes on its first
ticket and seconds on every one after.
So a benchmark that runs second proves nothing, and "I cannot reproduce
your slow run" usually means "you warmed it up for me". Before believing a
timing, ask whether anything touched the same views earlier in the session.
Only a restart restores the cold state, and a restart is the user's to
make -- ask, do not engineer a way around it.
This matters most for anything a person clicks. Their first click of the
day pays the warm-up; every benchmark you ran pays nothing.
- **A client timeout is not a rollback.** The default timeout is 300 s. A
request that times out on this side may still have executed and committed on
the other. Re-probe the state before doing anything else, always.
- **A bridge that does not answer is working, until the CPU says otherwise.**
The default explanation for silence is that your snippet is still running,
not that anything broke. The evidence is not on the bridge side, it is on
the process:
Revit's CPU time still climbing -> it is working. Wait.
CPU flat and silent for a while -> now consider other explanations.
Measured: a batch of 57 pieces was declared "a dead exec holding the API
queue, only a restart will clear it". Nothing was dead. The same process
went on to burn another hour of CPU and finished on its own, and the person
who was asked to restart had simply slept instead. A restart would have
thrown away every warm view and solved geometry in the session -- the most
expensive thing this model has -- to fix a fault that did not exist.
**Never ask someone to restart Revit before checking whether its CPU is
climbing.** And when a diagnosis arrives with the symptom, verify the
diagnosis, not just the symptom.
- **A timeout does not stop the work.** The 300 s limit belongs to the client.
Revit keeps executing your snippet to the end, still frozen. Re-sending the
same script after a timeout starts a second run while the first is still
going, and freezes the model again for its whole duration. Wait, then probe.
- **Check that the response is yours, and make that possible to check.**
Result files can carry another request's payload into your output file. It
has happened twice in one afternoon, and not from timeouts: **there is one
routes server on one port, and anyone working on this model shares it.** Two
people debugging at once trade results.
This is the worst failure mode on the bridge, because a foreign payload is
well-formed JSON that answers a question you did not ask, and the wrong
conclusion it produces looks exactly like a bug in your own code.
So put a signature key in every result and check it before reading anything
else:
result = {'MYNAME_WHAT_THIS_IS': True, ...}
If the key is missing, the answer belongs to somebody else. Send it again;
do not squint at it for clues.
## Units
Revit's internal unit is **decimal feet**. `XYZ()` takes feet. Every length,
every bounding box, every location point is feet. Feeding an inch value into an
`XYZ` is a 12x error, and the most expensive incident on this project was
exactly that: copies landed 6,500 ft away and produced a week of false symptoms
that all looked like API problems.
Areas are ft2, volumes ft3, and `unit_weight_pcf` is per cubic foot. Only
lengths are ambiguous; be explicit in every variable name you write.
## The library, in `WillTools.extension/lib/vibe/`
- `knowledge.load(doc)` — the merged configuration: shipped defaults, then the
plant file, then the job file, then the project config. Later layers win, and
**lists replace rather than merge**.
- `ticketqc` — reading a ticket. `collect_context(doc)` returns
`(summaries, sheets)`; `assemblies_by_design(doc, know=know)` groups pieces;
`read_ticket(doc, asm, sheets, know=know)` gathers one ticket;
`name_of(el)` because `.Name` throws in IronPython on some elements.
- `ticketchecks.run_all` — the computed checks.
- `liftexport` — measuring a piece for the handling calculation.
- `designspec.list_specs(doc)` — reviewed design specs by design number.
A worked read-only example that gets insert stations right:
`_scratch/claude_wp199_verify.py`.
## Two shapes that have caused wrong answers
**Element ids.** `ticketqc.eid(el)` returns an int. `doc.GetElement` wants an
`ElementId`, so `doc.GetElement(ElementId(int(the_int)))`. Passing the int
raises `TypeError: expected Reference, got int`.
**Insert positions.** `read_ticket` gives each lifter a `local` tuple — `(X, Y,
Z)` in the panel's own frame. There is no `x` or `y` key; asking for one returns
`None` and looks exactly like missing data. `local[0]` is measured from the
panel **centre**:
station_ft = ticket['length'] / 2.0 + lifter['local'][0]
Checked: WP200's four P136 sit at `local[0] = ±7.208` on a 22.917 ft piece,
giving 4.25 ft from each end, and its ticket prints `4'-3"`.
**A bar mark is a configuration, not an object.** `SR612` says what the bar
IS -- #6, 60 ksi, a 17A bend, A/B/C to these dimensions -- and says nothing
about which one. The same mark turning up on eight different tickets is eight
different bars built to one recipe, and that is the normal, wanted case.
The reason is not a modelling convention, it is how the shop works. **Every
distinct mark is a SKU**: the bending machine has to be set up for it, by hand,
once. So a plant works to keep the number of marks down and bend each one in a
big batch. Reusing a mark across many pieces is good practice, not a mistake to
be reported.
What follows for anything that names a bar: **the mark and the element id are
both required, and neither substitutes for the other.** The id identifies the
object; the mark is what a person recognises -- shown "24227001 overlaps
17599760" an engineer asks what those are, shown "SR701 x P106" he answers in
one sentence. A report that drops the mark is unreadable; a report that drops
the id cannot be acted on, because the mark does not point at anything in
particular.
**Nested families.** An element whose `SuperComponent` is not None belongs to
another member. It still appears in `GetMemberIds()`, so counting members
without filtering double-counts: `SR5602` sits inside the `P76` embed and
`RL_OVAL_PATCH_4T` inside `QLPD4T5WG`.
## Scratch
Put scripts in `_scratch/` with a prefix of your own. It is gitignored. Do not
write the job name, the client's name, a model filename or an absolute user
path into anything you create.
## What a running session picks up, and what it does not
Measured 2026-08-30, and more precise than the rule this file carried before.
brand-new modules import fine
already-imported ones stay on the code they were loaded with
YAML knowledge re-read on every run
Ten modules created that day all imported through the bridge with no restart.
In the same session `liftexport.weight_volumes` -- added to a module that had
already been imported -- was still absent, and knowledge roles added to the
plant YAML minutes earlier took effect immediately.
So the rule is not "a code change needs a restart". It is **a change to a
module this session has already imported needs a restart.** New work is
verifiable straight away; edits to existing modules are not.
The trap: importing a module through the bridge to look at it CACHES it. A
module read early and edited later is then stuck for the rest of the session,
and the bridge will keep reporting the old behaviour convincingly. If a fix
seems not to have taken, check whether the session imported that module before
the edit -- that is more likely than the fix being wrong.
Never solve this with pyRevit -> Reload. It kills Revit. Restarting Revit is
the owner's action, never an agent's.