WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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.
@@ -0,0 +1,15 @@
|
||||
# Line endings. Issue #18: on a Windows clone with core.autocrlf=true the
|
||||
# served static files came out of the checkout with CRLF, while the test that
|
||||
# proves /app.js is PA's single copy read the same file in text mode (which
|
||||
# folds CRLF to LF) -- so the bytes the server sent and the bytes the test
|
||||
# expected differed on every Windows clone and on no Linux one.
|
||||
#
|
||||
# Everything text is normalised to LF in the repository (the index already
|
||||
# was: `git ls-files --eol` showed 381 i/lf and 21 i/-text the day this was
|
||||
# added, so `text=auto` renormalised nothing). The files pa_web / ba_web hand
|
||||
# to a browser byte-for-byte are pinned to LF in the WORKING TREE too, so a
|
||||
# served asset is the same bytes on every machine.
|
||||
* text=auto
|
||||
|
||||
tools/pa_web/static/** text eol=lf
|
||||
tools/ba_web/static/** text eol=lf
|
||||
@@ -0,0 +1,38 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install
|
||||
# requirements.txt is the single source of truth for host-side deps.
|
||||
# PyYAML fixes collection (ticketpage._read_yaml falls back to it off
|
||||
# IronPython; without it eight test files die before running). pymupdf
|
||||
# is also required, or test_libindex.py's 77 tests silently SKIP via
|
||||
# importorskip instead of running -- a green run that tested less.
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: panellift tests
|
||||
run: python -m pytest panellift -q -p no:cacheprovider
|
||||
|
||||
- name: vibe tests (the Revit-free half of the extension)
|
||||
run: python -m pytest WillTools.extension -q -p no:cacheprovider
|
||||
|
||||
- name: Syntax smoke (extension + tools + panellift)
|
||||
run: python -m compileall -q WillTools.extension tools panellift/src
|
||||
|
||||
- name: Scrub worktree with the committed generic sample terms
|
||||
# Real client names never leave the local machine: .scrub-terms is
|
||||
# gitignored, so CI runs the generic .scrub-terms.example instead.
|
||||
run: python tools/scrub.py --worktree --terms .scrub-terms.example
|
||||
@@ -0,0 +1,96 @@
|
||||
# local Revit/bridge state
|
||||
.bridge-sessions.log
|
||||
# one per port: "the last request on this bridge never came back", written by
|
||||
# tools/rvt.py so the next call drains the channel before it trusts an answer.
|
||||
.bridge-timeout-*.json
|
||||
_ticketqc/
|
||||
|
||||
# build output
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
|
||||
# editors / local tool settings
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/settings.local.json
|
||||
|
||||
# scratch
|
||||
scratch/*.py
|
||||
!scratch/scratch.py
|
||||
# _scratch is where the agents work: probe scripts, bridge responses, run logs.
|
||||
# It is not the same directory as scratch/ above, and the rule there never
|
||||
# covered it -- 745 untracked files accumulated in it carrying client names,
|
||||
# job names and absolute user paths, one `git add -A` from the history. Work
|
||||
# that is meant to be kept gets moved OUT of here into its real home.
|
||||
_scratch/
|
||||
# Real exported tickets, kept locally to check against. They carry the job
|
||||
# name and the client's name in the title block, and scrub.py cannot read a
|
||||
# PDF -- the gate would pass them straight through. Never commit them.
|
||||
sample data/
|
||||
*.pdf
|
||||
*.log
|
||||
|
||||
# client-name denylist, must never be committed
|
||||
.scrub-terms
|
||||
|
||||
# QNA: 约定进仓库,问答内容不进
|
||||
QNA/*
|
||||
!QNA/README.md
|
||||
|
||||
# The knowledge folder is the OTHER repository's territory. It sits inside this
|
||||
# working tree for convenience during development and is tracked by KNOWLEDGE,
|
||||
# never by this one -- that split is the whole reason there are two repos: this
|
||||
# one can be shown to anybody, that one cannot.
|
||||
#
|
||||
# catalogue.yaml is the exception and is deliberately tracked here: it is the
|
||||
# public layer, published manufacturer data, and it says so in its own header.
|
||||
knowledge/plant/
|
||||
knowledge/job/
|
||||
knowledge/_archive/
|
||||
# ...and the SYMLINK spellings: on the host machine these three are links into
|
||||
# the private KNOWLEDGE checkout (git matches a trailing-slash rule against
|
||||
# directories only, so the link itself would show up untracked -- same lesson
|
||||
# as knowledge/library below, 2026-09-05).
|
||||
knowledge/plant
|
||||
knowledge/job
|
||||
knowledge/_archive
|
||||
|
||||
# knowledge/library/ is the PDF drop and its ledger -- manuals, manufacturer
|
||||
# catalogues, datasheets, plus the record of what has been mined out of each
|
||||
# and where every fact landed. Same territory as the three lines above and
|
||||
# excluded for the same reason: it is KNOWLEDGE's, never this one's.
|
||||
#
|
||||
# The PDFs themselves are already caught by `*.pdf`, and that rule exists
|
||||
# because scrub.py cannot read a PDF -- a title block carrying the job and the
|
||||
# client goes straight through the gate. The ledger and the extraction notes
|
||||
# beside it are worse, not better: quoting the source is their whole job, so
|
||||
# a name in them is certain rather than possible. The FOLDER is what is
|
||||
# excluded, not the extension, so a note, an index or a scratch transcript
|
||||
# dropped in there is covered the day it is written and not the day somebody
|
||||
# remembers to add a rule for it. `scrub.py --private` is what reads it.
|
||||
# Both spellings on purpose: with the slash git matches only a real directory,
|
||||
# and on this machine `knowledge/library` is a SYMLINK to the private repo's
|
||||
# checkout -- the link itself showed up untracked (2026-09-05) and one `git add`
|
||||
# would have committed a path into the owner's home directory.
|
||||
knowledge/library/
|
||||
knowledge/library
|
||||
|
||||
# knowledge/MC/ is a second producer's document drop, same territory as
|
||||
# library/ above and excluded whole-folder for the same reason: the files
|
||||
# carry the producer's name in their own filenames, which *.pdf alone does
|
||||
# not cover (there is a spreadsheet in there today).
|
||||
knowledge/MC/
|
||||
|
||||
# machine-local install choices (tools/setup_machine.py); .setup-opts.example is the tracked template
|
||||
.setup-opts
|
||||
|
||||
# CPython virtualenv on the Debian dev box
|
||||
.venv/
|
||||
.gitea-issue-handler.lock
|
||||
@@ -0,0 +1,30 @@
|
||||
# Terms that must never appear in the repo, one per line. Case-insensitive.
|
||||
#
|
||||
# This is the GENERIC sample file, committed so CI can run the scrub without
|
||||
# ever seeing a real client name. It is also the copy-you-fill-in for local
|
||||
# use: copy it to .scrub-terms (gitignored) and REPLACE the sample parameters
|
||||
# below with your real client / job / plant names.
|
||||
#
|
||||
# .scrub-terms is gitignored on purpose: the real list of owner/job names must
|
||||
# not itself become the thing that publishes them.
|
||||
#
|
||||
# List anything that identifies an owner or a job: client name, building
|
||||
# owner, project name, site address, job number, plant name, Windows
|
||||
# usernames that appear in scraped output.
|
||||
#
|
||||
# When you need to refer to one in code or a comment, write a placeholder
|
||||
# instead of deleting the sentence (e.g. a client -> sample_client, a job ->
|
||||
# sample_job, a plant -> sample_plant). Different real clients change only the
|
||||
# parameters below -- one file per parallel job, never hard-coded in code.
|
||||
#
|
||||
# Product codes are not client information and stay as they are: design
|
||||
# numbers (WPA2.0), piece marks (WPA170), and hardware part numbers
|
||||
# (LPA4T512) are naming conventions, not identities.
|
||||
|
||||
# ---- GENERIC SAMPLE TERMS (CI-safe). Replace in local .scrub-terms. ----
|
||||
SAMPLE_CLIENT_MOTORS
|
||||
SAMPLE_JOB_PROJECT_2026
|
||||
SAMPLE_PLANT_WEST
|
||||
SAMPLE_USER_ABC123
|
||||
CLIENT1_SAMPLE
|
||||
CLIENT2_SAMPLE
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# AGENTS.md
|
||||
|
||||
**Read [CLAUDE.md](CLAUDE.md) first and treat it as binding.** It is written
|
||||
for whichever agent is driving this repo, not for one vendor: the live-Revit
|
||||
bridge, the IronPython 2.7 rules, the unit conventions, and the rules that
|
||||
exist because breaking them killed Revit. Nothing in it is restated here,
|
||||
because two copies of a hard rule drift and the copy that drifts is the one
|
||||
that takes the model down.
|
||||
|
||||
Then read [docs/handoff.md](docs/handoff.md) for the state of the work: what
|
||||
is done, what is knowingly approximate, and what to pick up first.
|
||||
|
||||
## Harness notes
|
||||
|
||||
Instruction files are loaded by the harness's `agent-instructions` plugin,
|
||||
which reads `AGENTS.md` and `CLAUDE.md` in each directory from the project
|
||||
root down. Two files with the same content collapse to one; two with
|
||||
different content both load. That is why this file is a pointer and not a
|
||||
copy — it costs a few lines and leaves CLAUDE.md as the single source.
|
||||
|
||||
**The plugin ships disabled in the default `web` profile.** If neither file
|
||||
appears in the session's context, enable it in
|
||||
`~/.dsh/profiles/web/cordis.patch.yml`:
|
||||
|
||||
```yaml
|
||||
- id: agent-instructions
|
||||
disabled: false
|
||||
```
|
||||
|
||||
Check what is actually composed with `dsh --profile web --dump-config`. An
|
||||
agent working here without CLAUDE.md loaded will reload pyRevit sooner or
|
||||
later, and that ends the Revit session.
|
||||
@@ -0,0 +1,899 @@
|
||||
# pyrevit-dev — you are wired to a live Revit session
|
||||
|
||||
You were launched from a button inside Revit. **Revit is running right now**,
|
||||
with a model open, and you can read and modify it directly. Use that — do not
|
||||
write Revit code you have not run.
|
||||
|
||||
Engine: **IronPython 2.7.12**. The Revit and pyRevit versions differ between
|
||||
the machines this is developed on (2025.4 / pyRevit 6.5.3 on one, 2024.3 /
|
||||
pyRevit 5.0.1 on the other), so ask rather than assume — `rvt.py status`
|
||||
answers it — and guard any API name that moved between versions. Two already
|
||||
known: `BrowserOrganization.GetFolderFields()` does not exist on the older
|
||||
binding, and `Dimension.Curve` throws on drafting views.
|
||||
|
||||
---
|
||||
|
||||
## 1. Talking to the running Revit
|
||||
|
||||
```bash
|
||||
python tools/rvt.py status # which model is open
|
||||
python tools/rvt.py exec -c "print(doc.Title)" # run a line inside Revit
|
||||
python tools/rvt.py exec somefile.py # run a file inside Revit
|
||||
python tools/rvt.py exec somefile.py --tx "名字" # wrap in a transaction
|
||||
python tools/rvt.py sel # what the user has selected
|
||||
python tools/rvt.py collect OST_Walls --limit 20
|
||||
python tools/rvt.py params 123456 # every parameter of one element
|
||||
```
|
||||
|
||||
`exec` returns `{ok, stdout, result, error, traceback}` — real stack traces, so
|
||||
iterate until it works. Snippets get `doc`, `uidoc`, `uiapp`, `view`, `DB`,
|
||||
`UI`, `vibe`, and the common Revit types pre-imported. Assign to `result` (or
|
||||
make the snippet a single expression) to send a value back.
|
||||
|
||||
If `status` cannot connect: Revit is closed, or a **modal dialog** is open in
|
||||
Revit — handlers run in Revit's API context and cannot fire behind a dialog.
|
||||
Say so rather than guessing.
|
||||
|
||||
### Every answer is checked against the request that asked for it
|
||||
|
||||
Since 2026-09-01 `rvt.py` stamps every request with an id (in the JSON body,
|
||||
GET routes included — request headers never reach handler code either),
|
||||
`startup.py` echoes it back, and an answer carrying a **different** id is
|
||||
refused outright: both ids named, nothing returned. The reason is that pyRevit
|
||||
serves every route through **one shared response slot** —
|
||||
`routes/server/server.py`'s module-level `REQUEST_HNDLR` / `EVENT_HNDLR`, whose
|
||||
single `_response`/`_done` pair every connection thread resets, waits on and
|
||||
reads — so a client-side timeout cancels nothing, and the abandoned request's
|
||||
late answer is handed to whoever asks next. Measured that day, twice: one sweep
|
||||
call timed out at 300s, and the `doc.Title` calls after it came back with the
|
||||
sweep's progress string, which the sweep printed as the document title and an
|
||||
agent read as the model having changed underneath it. The files on disk were
|
||||
right; only the attribution was wrong, and **a response from the wrong request
|
||||
is indistinguishable from a valid answer**. So a timeout is now an explicit
|
||||
could-not-judge (exit 1, `COULD NOT JUDGE`, no value) that latches the channel
|
||||
in `.bridge-timeout-<port>.json`; the next call drains the stale answer out of
|
||||
that slot with a self-identifying probe before it sends anything real, and
|
||||
refuses to send at all if the channel cannot be proven clean. An in-Revit half
|
||||
that echoes **no** id — which is every session until Revit is restarted after
|
||||
this lands — is warned about on stderr and flagged `_unverified` in the body
|
||||
rather than refused, because a restart is the only way to load the new half and
|
||||
refusing would take the bridge down for every tool in the meantime; pass
|
||||
`--strict` (or set `WILLTOOLS_BRIDGE_STRICT=1`) to refuse instead.
|
||||
|
||||
### The bridge can now REFUSE, and a refusal is not a fault
|
||||
|
||||
Since 2026-09-01 every route — `/status` included — refuses outright when the
|
||||
open document's title is on `bridge.refuse_titles` in the job or plant layer.
|
||||
That is the owner's *不要碰我的live* written as code. The answer comes back
|
||||
`{"ok": false, "refused": true, "doc_title": ..., "pid": ..., "error": "REFUSED: …"}`
|
||||
and it means **you reached the live session, not the sandbox**. Do not retry
|
||||
it, do not work around it, and do not go looking for another way in.
|
||||
|
||||
**The owner reversed that policy the same day, for the model it protects.**
|
||||
A title on `bridge.readonly_titles` instead is served normally through the
|
||||
bridge — including `/exec` — but only for reads: the iron rule is *铁律是在
|
||||
live里面只读不写*, and `/exec` refuses outright when the request carries a
|
||||
transaction or the snippet matches known write-capable Revit API surface,
|
||||
coming back the same `refused: true` shape as above with a one-line reason
|
||||
naming the title and the rule, never a silent no-op.
|
||||
|
||||
Why it can happen when you did nothing wrong: pyRevit walks the routes port
|
||||
**up** from the configured one past every Revit already holding one, so 48884
|
||||
is "whichever Revit started first" rather than the session you meant. With two
|
||||
sessions open the sandbox is usually on **48885** — and its `doc.Title` is
|
||||
identical to the live model's, because the snapshot deliberately keeps the
|
||||
title so the job folder does not move. `rvt.py` reads `WILLTOOLS_BRIDGE_PORT`;
|
||||
set it, or let `tools/snapshot_cycle.py` find the right session for you. The
|
||||
whole mechanism, including how a snapshot proves it is a snapshot, is in
|
||||
`docs/snapshot-cycle.md`.
|
||||
|
||||
### Never reload pyRevit. Restart Revit instead.
|
||||
|
||||
This is the single most important rule here, and it is not a style preference —
|
||||
it kills Revit outright.
|
||||
|
||||
pyRevit's routes request handler is an external event handler owned by the
|
||||
scripting engine that first loaded it. `reload_pyrevit()` tears that engine
|
||||
down but leaves the handler and the HTTP server pointing at it. **The next
|
||||
bridge request after any reload executes against a dead engine and takes Revit
|
||||
down** — an unhandled exception on a non-main thread, which Revit cannot catch.
|
||||
Measured on 2026-08-14: 11 clean requests, one reload, the 12th request fatal.
|
||||
It happened twice.
|
||||
|
||||
Nothing inside Revit can defend against this — our code never gets to run. So:
|
||||
|
||||
- There is no `/reload` endpoint, and `rvt.py reload` refuses.
|
||||
- `startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load.
|
||||
- `rvt.py` reads that log before **every** request and hard-refuses if it sees
|
||||
two loads under the same Revit process.
|
||||
|
||||
If `rvt.py` tells you the bridge is unsafe, do not work around it, do not curl
|
||||
the endpoint directly. Ask the user to **restart Revit**. A restart picks up new
|
||||
buttons exactly as well as a reload does, and a fresh process is safe.
|
||||
|
||||
If `rvt.py` warns that there is no session log while Revit is clearly running,
|
||||
the tripwire itself is broken — say so and do not reload.
|
||||
|
||||
---
|
||||
|
||||
## 2. Layout
|
||||
|
||||
```
|
||||
pyrevit-dev/
|
||||
├── CLAUDE.md ← you are here
|
||||
├── INSTALL.md deploying to another machine / Revit version
|
||||
├── tools/rvt.py client for the live bridge
|
||||
├── tools/package.py builds dist/willtools-<ver>-<date>.zip
|
||||
├── tools/sweep.py check every WP assembly in the open model
|
||||
├── tools/snapshot_cycle.py make a timestamped detached copy, cycle a
|
||||
│ Revit onto it, run a batch, close it
|
||||
├── docs/snapshot-cycle.md …and why the detach is not done in-session
|
||||
├── docs/revit-api-cheatsheet.md working Revit 2025 patterns — read when stuck
|
||||
├── docs/interference.md why the clash check is shaped the way it is
|
||||
├── docs/lift-check.md the handling check, and what it will not answer
|
||||
├── docs/handoff.md open engineering questions, the last sweep,
|
||||
│ what to pick up first
|
||||
├── knowledge/catalogue.yaml published hardware ratings (ships with the tool)
|
||||
├── panellift/ PCI Ch.8 handling checks — CPython, own suite
|
||||
│ ├── src/panellift/cli.py `python -m panellift job.json -o report.html`
|
||||
│ └── src/panellift/layout.py where the inserts should go instead
|
||||
└── WillTools.extension/
|
||||
├── startup.py registers the bridge (no UI)
|
||||
├── lib/vibe/
|
||||
│ ├── helpers.py q() p() set_p() tx() ft() mm() selected() ...
|
||||
│ ├── compat.py text_type / string_types — py2+py3 shim
|
||||
│ ├── designspec.py evidence scrape, spec schema, spec io
|
||||
│ ├── aiextract.py the AI reader — NOT the extractor any more
|
||||
│ ├── knowledge.py knowledge.yaml → defaults, family rules
|
||||
│ ├── configdump.py all five layers flattened into one file
|
||||
│ ├── configmodel.py what may be edited, its provenance, the splice
|
||||
│ ├── liftexport.py assembly → panellift job file, and runs it
|
||||
│ ├── ticketqc.py reads tickets, the comparison checks
|
||||
│ ├── ticketchecks.py the checks that need arithmetic
|
||||
│ └── ticketqc_run.py the shared engine behind TicketQC and
|
||||
│ ReinforcingQC (a `mode` parameter apart)
|
||||
└── WillTools.tab/ the ribbon — "Folder names are the UI"
|
||||
│ below is the whole of how it is built
|
||||
├── Tools.panel/ Modeling.pulldown/ — ReinforceSuggest,
|
||||
│ ReinforcingQC, TicketQC — plus
|
||||
│ SnapshotClose as the one remaining large
|
||||
│ icon (reorganised 2026-09-05; this panel
|
||||
│ used to hold all four as large icons)
|
||||
├── Setup.panel/ Config.pulldown/ — one large button with a
|
||||
│ dropdown arrow: ProjectConfig,
|
||||
│ ExtractDesign, SelfCheck, JobRecon,
|
||||
│ Takeoff
|
||||
├── Design.panel/ DesignHub.pulldown/ — PanelLiftCheck and
|
||||
│ PanelAnalysis (both finished, both grouped
|
||||
│ here on the owner's own instruction), then
|
||||
│ BeamAnalysis, ConnectionAnalysis,
|
||||
│ GravityLoadChase and LateralExtract, all
|
||||
│ four unfinished and titled "(WIP)"
|
||||
└── Lab.panel/ GodMod — the button that launched you.
|
||||
```
|
||||
|
||||
**`aiextract` no longer extracts anything.** Design Extract was rewritten to
|
||||
read the scrape in-process with `ticketqc`'s regex/geometry parser — its own
|
||||
header says "no AI call, no subprocess, nothing that leaves this process" —
|
||||
and it imports `designspec`, `ticketqc`, `knowledge` and `specsave`, not this.
|
||||
The module is kept, and its only live callers are `find_claude()`: Self Check
|
||||
probes with it to say whether the CLI is on this machine, and the GOD_MOD
|
||||
button uses it to find the CLI it launches. The prompt, the `claude -p` spawn
|
||||
and the cross-check are all still there and nothing calls them. Spec files
|
||||
written before the rewrite can still carry `source.extractor: 'opus'` (or the
|
||||
older `'claude-code'`); the mechanical reader stamps `'regex'`. Nothing
|
||||
branches on which of the three it finds — the field is displayed, in Design
|
||||
Extract's own spec table, and never acted on — so anything touching it has to
|
||||
tolerate all three. Do not describe this module as the extractor.
|
||||
|
||||
**`panellift` is not importable from Revit and that is deliberate.** It is
|
||||
CPython — dataclasses, typing syntax — and the extension is IronPython 2.7.
|
||||
The two talk through a JSON job file, so the engineering can be tested without
|
||||
a licence, a model, or Windows. `vibe.liftexport` is the whole Revit side of
|
||||
that boundary: it measures the piece, writes the job, shells out to CPython,
|
||||
and reads the summary back. Anything about *mechanics* belongs on the far
|
||||
side of that line; anything about *this model* belongs on this side.
|
||||
|
||||
Most of `vibe` does not touch Revit at module scope either — only at the top
|
||||
of individual functions that need a live `doc` (`knowledge.load()`,
|
||||
`liftexport.export()`, and anything that reaches for `vibe.designspec` /
|
||||
`vibe.ticketqc`, which import the Revit API directly). That leaves real logic
|
||||
testable with no model and no licence, the same way `panellift` is.
|
||||
`WillTools.extension/tests/` is 34 files covering about thirty of the `vibe`
|
||||
modules — `ticketpage`, `knowledge`, the host-side half of `ticketchecks`, the
|
||||
`configmodel` / `configform` / `configpage` set, the `reinforce*` and
|
||||
`takeoff*` sets, the part of `liftexport.py` below `export()`, and more — run
|
||||
straight on host CPython:
|
||||
|
||||
```bash
|
||||
cd WillTools.extension && python -m pytest
|
||||
python -m pytest # from the repo root: this suite + panellift
|
||||
```
|
||||
|
||||
**Run it; do not quote a count from here.** The count in this paragraph has
|
||||
been wrong every time anybody has checked it — the suite grows daily, and on
|
||||
2026-08-31 the root total moved twice in one afternoon while several agents
|
||||
were working. For scale only, and only that: it was roughly 1,800 under
|
||||
`WillTools.extension` and roughly 2,100 from the repo root that day. The root
|
||||
run also sweeps `_scratch/`, which is gitignored agent working space, so its
|
||||
total moves for reasons that have nothing to do with the tool.
|
||||
|
||||
**One test fails on a configured machine and it is the test that is wrong.**
|
||||
`tests/test_reinforceoffline.py::test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one`
|
||||
calls `resolve_index(None, None, None)` expecting the "no jobs root" refusal,
|
||||
but `resolve_index` falls back to the ambient `WILLTOOLS_JOBS`, so on any
|
||||
machine that actually has the tool set up it gets the "which model?" refusal
|
||||
instead. It passes under `env -u WILLTOOLS_JOBS`. Nothing in the tool is
|
||||
broken; the test needs to clear the variable. Measured 2026-08-31, not yet
|
||||
fixed — so a green suite here is everything passing **except that one**, and
|
||||
one failure is not automatically your change.
|
||||
|
||||
`WillTools.extension/tests/conftest.py` is how: it loads a module by path
|
||||
instead of `import vibe.whatever`, because `vibe/__init__.py` pulls in the
|
||||
Revit API the moment it runs. Read the docstring there before adding a test
|
||||
for a new module — it explains exactly which shape of code this can and
|
||||
cannot reach, and `ticketqc.py` / `ticketchecks.py` are the standing example
|
||||
of code that cannot: they import `Autodesk.Revit.DB` at module scope, not
|
||||
just inside a function, so freeing them needs more than moving an import.
|
||||
|
||||
Ticket-QC data lives beside the **model**, not here:
|
||||
`<model folder>/_ticketqc/{evidence,specs,log}/` + `knowledge.yaml`.
|
||||
|
||||
Knowledge is read in **five layers**, each overriding the one before:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `knowledge.DEFAULTS` | what the code assumes — in this repo |
|
||||
| `knowledge/catalogue.yaml` | published hardware ratings — in this repo, identifies nobody |
|
||||
| `%APPDATA%\WillTools\knowledge.yaml` | what this **plant** always does — naming conventions, casting-bed rails |
|
||||
| `<model folder>/_ticketqc/knowledge.yaml` | what this **job** does — parameter names, tolerances, per-job overrides |
|
||||
| the **project config file** | what the engineer set, wherever they put it — opt-in, and it wins |
|
||||
|
||||
Only the first two are in the repository, and that is the point: between them
|
||||
the other three are a full description of one office's template. Set
|
||||
`WILLTOOLS_KNOWLEDGE` to put the plant layer on a share.
|
||||
`knowledge.sources(doc)` lists the layers; Self Check prints them.
|
||||
|
||||
**Where the layers get their facts from: `knowledge/library/`.** It is a drop
|
||||
folder for source PDFs — manuals, manufacturer catalogues, datasheets — plus a
|
||||
ledger with one row per document, keyed by content hash, recording what was
|
||||
extracted from it and **which file and key path each fact landed in**. The
|
||||
folder is gitignored as a folder (not merely by `*.pdf`), because the ledger
|
||||
and the extraction notes quote their sources verbatim and `scrub.py` cannot
|
||||
read a PDF; like `knowledge/plant/` it is the other repository's territory, and
|
||||
`scrub.py --private` is what reads it. **Idle capacity drains it by
|
||||
`knowledge/library/RUNBOOK.md`** — inventory, extract with a page cite and a
|
||||
confidence mark on every fact, **land**, verify, report. The landing step is
|
||||
the point and it is the one that gets skipped: a 929-page manual was once mined
|
||||
into ten notes and a 25-fact audit of which almost nothing became YAML, and the
|
||||
owner ruled on it — *"提取出来以后没有写入。今天不要再犯这个错误"*. A document
|
||||
read but not landed is a debt, not progress, and the ledger says so in the
|
||||
status column. `python tools/library.py` lists the folder against the ledger.
|
||||
|
||||
**Extraction notes are clues, never sources — a number lands only off the
|
||||
page itself.** Measured 2026-08-31: a cheap-model extraction pass over one
|
||||
document drop carried a **42% page-error rate** on independently sampled pages
|
||||
— inverted inequalities, misidentified parts, specs blurred into vagueness —
|
||||
and the landing pass then caught three more errors the audit itself had
|
||||
missed, including a one-row table shift and a column read under the wrong
|
||||
heading. Three stages, none may collapse into another:
|
||||
|
||||
1. **Extract** with a page cite on every fact (cheap model is fine — speed is
|
||||
what it buys).
|
||||
2. **Audit independently** — a different agent samples pages against the note.
|
||||
A sub checking its own family's work reported zero errors on the same
|
||||
material where the independent auditor found the 42%; self-audit is not
|
||||
audit.
|
||||
3. **Land off the page** — any NUMBER moving from a note into a YAML layer is
|
||||
re-read against the rendered source page by the landing agent, cited
|
||||
`# SOURCE: <doc> p.N, verified <date>`. A number that cannot be confirmed
|
||||
lands as `null` with the reason and an open question — never as the note's
|
||||
value, never as a plausible reconstruction.
|
||||
|
||||
**Then run `python tools/knowledgelint.py`.** Landing is not finished until it
|
||||
passes. pyRevit's YAML reader is strict — one mapping key stated twice raises
|
||||
`Duplicate key` and it returns *nothing* — and a layer that fails to load looks
|
||||
exactly like a layer that agrees with the defaults. Measured 2026-09-01: one
|
||||
nested duplicate in a plant file discarded that whole 255KB layer inside Revit,
|
||||
every parameter role came back None, the sweep enumerated 0 assemblies, and
|
||||
every report rendered normally. The lint strict-parses every layer on this
|
||||
machine and exits 1 on any that will not load; `knowledge.config_problems()`
|
||||
and Self Check now say so too, but only after the file has already been
|
||||
written.
|
||||
|
||||
Where a table carries its own internal identity (a recommended value derived
|
||||
from two measured columns, a series that must be monotonic), assert the
|
||||
identity across the whole table at landing — it is the cheapest error
|
||||
detector there is, and it is what turned "one value off by one" into "the
|
||||
whole block is shifted a row".
|
||||
|
||||
**`knowledge.load()` reads the job file twice, and that is not a bug.** The
|
||||
job layer holds `job.plant`, which names *which plant folder* the plant layer
|
||||
is read from — but the plant layer merges **before** the job layer, so by the
|
||||
time the name is in the merged tree the plant file has already been read from
|
||||
the wrong path. The dependency runs backwards through the merge order and no
|
||||
reordering fixes it. So `plant_id()` reads the job file (and the config file
|
||||
above it, which can override the same key) once on its own, before anything
|
||||
merges, purely to answer "which plant" — then the ordinary five-layer merge
|
||||
runs. **Do not collapse this into one pass.** Deleting the pre-pass makes the
|
||||
tool read the machine-wide plant file while the job file plainly says which
|
||||
plant it is for, and every report still looks completely normal.
|
||||
`_plant_layer_stale` is the tripwire for exactly that, comparing the plant the
|
||||
file was chosen by against the plant the merged tree ends up naming. Same
|
||||
discipline as `_config_missing`: a named plant whose file is absent sets
|
||||
`_plant_missing` and never falls back to the machine-wide file.
|
||||
|
||||
The last layer is what the **Project Config** button writes. A model has one
|
||||
only once somebody has pressed that button, and the file goes wherever they
|
||||
choose — so a one-line pointer at `<model>/_ticketqc/active-config.txt` records
|
||||
where it is. It is deliberately last: it is the layer a human edited most
|
||||
recently and by hand.
|
||||
|
||||
Two rules around it, both learned the hard way in other tools:
|
||||
|
||||
- A pointer to a file that is **gone** must never fall back quietly. The run
|
||||
would use a configuration nobody chose and look completely normal.
|
||||
`load()` marks it `_config_missing` and `knowledge.config_problems(doc, know)`
|
||||
turns that into a sentence a button can refuse on.
|
||||
- The file opens with `decisions:` — every setting that is the engineer's to
|
||||
make, gathered from wherever it is really read and put above everything
|
||||
else, each under its own comment. Where the reports go, whether they open,
|
||||
the prestress loss, the transfer length, and the per-stage rigging mode.
|
||||
`knowledge._merge_decisions` maps every one of them back on load.
|
||||
**A file where editing the obvious place does nothing is a trap**, so that
|
||||
mapping has to exist in both directions or the section is decoration.
|
||||
|
||||
**Write for both Pythons.** pyRevit picks the engine and this extension ships
|
||||
to machines whose engine we do not control, so on top of the IronPython 2.7
|
||||
rules below: no `unicode` / `basestring` (use `vibe.compat`), and guard any
|
||||
Revit API name that moved between versions. `python tools/package.py` and
|
||||
`INSTALL.md` cover the rest.
|
||||
|
||||
Folder names are the UI: `Foo.tab / Bar.panel / Baz.pushbutton / script.py`,
|
||||
plus `Bar.panel / Thing.pulldown / Baz.pushbutton` for a pulldown. Copy
|
||||
`GodMod.pushbutton` as a structural template (not its contents).
|
||||
|
||||
`*.stack` also exists and this tab used two of them until 2026-08-30. Both are
|
||||
gone: a stack is up to three *small* buttons in one panel slot, and the owner
|
||||
wanted the set-up commands off the panel entirely rather than shrunk on it.
|
||||
Prefer a `.pulldown` — one large icon plus an arrow, unlimited rows, and the
|
||||
rows are free to carry readable titles because a row is text beside a 16px
|
||||
icon rather than a ribbon slot.
|
||||
|
||||
**Which name renders, and from where** — this is not uniform and guessing it
|
||||
wastes a Revit restart:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| tab | the **folder** name. `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)`; a `title:` in a tab bundle is ignored. |
|
||||
| panel | the **folder** name, same reason (`create_ribbon_panel(panel.name)`). Rename the folder to rename the panel — spaces are fine, pyRevit ships panels with them. |
|
||||
| stack | nothing renders. The folder name is only what the panel's `layout:` refers to it by. |
|
||||
| pulldown | `bundle.yaml`'s **`title:`** — unlike a stack, a pulldown container really does read one. `genericcomps.GenericUIComponent._read_bundle_metadata` sets `_ui_title` from it and `uimaker._produce_ui_pulldown` passes that to `create_pulldown_button`. With no `title:` it falls back to the folder name. |
|
||||
| button | `bundle.yaml`'s **`title:`**, and nothing else. `__title__` in the script is the fallback pyRevit reads only `if not self.meta`, i.e. only for a button with no `bundle.yaml` at all — keep it in step anyway, or the old name comes back the day a bundle file is trimmed. |
|
||||
|
||||
**Order is `layout:` in the container's own `bundle.yaml`**, a list of child
|
||||
folder names (minus their `.panel` / `.stack` / `.pushbutton` suffix), read by
|
||||
`genericcomps.GenericUIContainer.parse_layout_metadata`. The old `_layout`
|
||||
file is gone in pyRevit 5.0.1 — do not write one, nothing reads it. Two things
|
||||
about that list are worth knowing before it bites:
|
||||
|
||||
- **A child not listed is not created.** Not misordered — absent. Add a
|
||||
button, add it to the layout, or it silently never appears.
|
||||
- `"---"` inserts a separator and `">>>"` a **slideout** (everything after it
|
||||
hides behind the arrow at the panel's bottom-right). Both must be quoted —
|
||||
a bare `>` starts a folded block scalar and the panel fails to parse — and
|
||||
neither may be the last entry, because `__iter__` only honours a marker
|
||||
whose index is below the last one.
|
||||
|
||||
**A `.pulldown` needs its own `icon.png` or it renders blank.** A pulldown is a
|
||||
component like any other — `genericcomps` resolves an `icon.png` for it, and
|
||||
`_produce_ui_pulldown` hands that to `create_pulldown_button`. When there
|
||||
isn't one, `ribbon._create_button_group` catches the icon error and only
|
||||
`mlogger.debug`s it, so you get an empty ribbon slot and no message anywhere.
|
||||
`python tools/make_icons.py` draws both of the tab's pulldown icons; run it
|
||||
and **read its output**, because it prints "skipped, no such component" for a
|
||||
path that has moved rather than failing.
|
||||
|
||||
**Unfinished tools never sit on a panel with finished ones.** Standing owner
|
||||
instruction, 2026-08-30. `Design.panel` is where they go — a panel of its own
|
||||
whose single button is the `DesignHub` pulldown, so nothing half-built is on
|
||||
screen until somebody opens it; the `(WIP)` suffix on the title is what names
|
||||
them once they do. (Until 2026-08-30 this was the slideout on `Lab.panel`.
|
||||
Same rule, better door: a slideout arrow is easy to miss and shares its panel
|
||||
with a working button, which is the thing the rule is against.) `DesignHub`
|
||||
also holds two FINISHED rows today, PanelLiftCheck and PanelAnalysis — the
|
||||
owner grouped them here on 2026-09-05 because he wants the analysis tools
|
||||
together, not because either stopped working; see that pulldown's own bundle
|
||||
comment.
|
||||
|
||||
A button that is finished but not wanted on screen used to move to
|
||||
`WillTools.extension/parked/`, outside every `*.tab`. **That folder is gone**
|
||||
as of 2026-08-30 — JobRecon and Takeoff, its only residents, came back onto
|
||||
the ribbon inside `Config.pulldown`. A pulldown row costs no panel space, so
|
||||
there is now somewhere on the ribbon to put a button nobody wants underfoot,
|
||||
and parking has no job left. Do not recreate `parked/` without asking; if a
|
||||
button really must leave the ribbon, taking it out of the parent's `layout:`
|
||||
already stops it being built.
|
||||
|
||||
Titles are **short and two lines**: a large ribbon button is as wide as its
|
||||
widest line, which is how "Reinforcing Suggestion" came to dwarf everything
|
||||
next to it. `title: "Ticket\nQC"` — double quotes, because YAML only honours
|
||||
`\n` inside them. Keep each line under ~9 characters. Rows inside a pulldown
|
||||
are the opposite case — text beside a 16px icon, in a dropdown that grows to
|
||||
fit its longest row — so those stay **single-line and may say what they
|
||||
mean** (`Project Config`, `Design Extract`). A `\n` in a pulldown row has
|
||||
nowhere to go.
|
||||
|
||||
**No button carries hover text.** The owner asked for it twice, so it is now
|
||||
a test, not a convention: `bundle.yaml` gets no `tooltip:` and no `author:`,
|
||||
and `script.py` gets **no module docstring at all**. Document a button in `#`
|
||||
comments under the coding cookie instead — pyRevit cannot render those, and
|
||||
nothing is lost. `tests/test_button_tooltips.py` fails and names the button
|
||||
if any of the three comes back. The trap it exists for: pyRevit reads the
|
||||
docstring only `if not self.meta` (no `bundle.yaml`), so a docstring can sit
|
||||
harmless for months and become hover text the day a bundle file is trimmed
|
||||
or a button ships without one.
|
||||
|
||||
pyRevit still appends `Bundle Name:\n<Name> (pushbutton)` to every tooltip in
|
||||
`uimaker._make_button_tooltip`, with no setting to suppress it. Those two
|
||||
lines are the floor; do not try to beat them by editing the pyRevit install.
|
||||
|
||||
**Editing an existing `script.py` needs no reload** — pyRevit re-reads it on
|
||||
every click, so most iteration costs nothing. **Adding or renaming** a button
|
||||
needs a **Revit restart** (see the rule above — not a pyRevit reload).
|
||||
|
||||
---
|
||||
|
||||
## 3. Hard rules
|
||||
|
||||
**Dry-run and self-test before the owner touches it.** Owner, 2026-09-02:
|
||||
"以后写完代码先测试,做dry-run,能修的bug修完以后再让我来测试." Finishing a
|
||||
change is not "the code is written" -- it is "I ran it, on a real input,
|
||||
saw no garbage, and fixed every bug I could before handing it over." For a
|
||||
tool that reads the live model, that means: export a real assembly to a
|
||||
deck through the bridge (read-only), run the offline solver on it
|
||||
(`panellift`), read the output, and fix what breaks -- BEFORE asking the
|
||||
owner to press the button. His time is the last check, not the first. A
|
||||
handoff that says "try it" without a clean dry-run behind it wastes the one
|
||||
reviewer who cannot be replaced. This sits beside "report outcomes
|
||||
faithfully": a dry-run that still errors is reported as such, not smoothed
|
||||
over.
|
||||
|
||||
|
||||
**A green host suite is not a live dry-run, and a clean merge is not one
|
||||
either.** Added 2026-09-03, after Panel Analysis reached the owner broken twice
|
||||
in a day. The form passed all 127 `panelanalysis` host tests and pre-filled
|
||||
*nothing* live: it read mark and f'c only through plant `knowledge` roles, and
|
||||
on that job the plant mapped those roles to nothing, so every field came up
|
||||
blank,
|
||||
the header said "unnamed panel", and the project / client / date fields the
|
||||
owner expected did not exist at all. Host tests check the schema; they cannot
|
||||
see that a form reads no real value from a real model. So anything that READS A
|
||||
MODEL or renders a FORM is dry-run **through the live bridge, read-only**, with
|
||||
the actual values it recovered confirmed present, before it is handed over --
|
||||
the button press is the owner's last check, never the first. The same holds for
|
||||
code that arrives by MERGE from the other machine: a merge that applied cleanly
|
||||
is UNVERIFIED, not validated. The `\xe2` crash and the blank form both shipped
|
||||
in merged front-end code and both first failed when the owner ran them -- run
|
||||
every gate AND a live dry-run on merged code exactly as on your own.
|
||||
**A WPF button gets a bridge smoke-import before it reaches the owner.** The
|
||||
host cannot import `System.Windows` at all, so a wrong WPF namespace is
|
||||
invisible to every host gate -- 2026-09-03, `StretchDirection` imported from
|
||||
`System.Windows.Media` (it lives in `System.Windows.Controls`) and the button
|
||||
died at import the first time the owner clicked it, the day after the rule
|
||||
above was written. The bridge runs IronPython INSIDE Revit, so the check is
|
||||
cheap: exec the button's `from System.Windows...` import block through it
|
||||
(`python tools/wpf_smoke.py <script.py>`) and require zero import errors.
|
||||
Owner, the same day: "写的东西测试过了再给我,别老让我报错." A button handed
|
||||
over without that run is a button the owner is testing for you.
|
||||
|
||||
**A failure class that is invisible on the host earns a guard test.** The Panel
|
||||
Analysis crash on 2026-09-03 was a non-ASCII byte in an IronPython 2.7 file with
|
||||
no `# -*- coding: utf-8 -*-` cookie: a hard `SyntaxError` inside Revit, and
|
||||
completely silent on host CPython 3, which defaults to UTF-8. A bug that only
|
||||
bites in the engine we cannot run here, and passes every host check, recurs
|
||||
unless a host test reproduces its shape. `tests/test_coding_cookie.py` now walks
|
||||
every extension `.py` and fails on a non-ASCII byte with no cookie, the way
|
||||
`test_button_tooltips.py` guards hover text. When a class of failure is
|
||||
invisible on the host but real in Revit, add the guard rather than trusting the
|
||||
next person to remember.
|
||||
|
||||
**Reading a model must be GENERIC -- geometry and built-ins first, a mapped role
|
||||
only as an accelerator.** Owner, 2026-09-03: "很多的预填充应该是通用的代码,避免
|
||||
换一个活儿就得重来." A pre-fill or scrape keyed to one job's parameter names is
|
||||
rework waiting for the next job. Dimensions and weight come from GEOMETRY
|
||||
(bounding box, solid volume) -- job-independent, the floor. Names come from
|
||||
Revit BUILT-INS (`Mark`; ProjectInformation's Project Name / Client Name).
|
||||
Strengths and the like, when the plant role is unmapped, fall back to
|
||||
case-insensitive parameter-NAME PATTERN matching (f'c: "f'c" / "fc" / "28 day";
|
||||
release: "fci" / "release"; unit weight: "density" / "pcf"), never one job's
|
||||
literal name. The plant `knowledge` role is the accelerator where it exists,
|
||||
never the only path. This is "no client/job information in the repo" seen from
|
||||
the code side, and the same law as the five knowledge layers: switching to
|
||||
another job must pre-fill out of the box with no edit.
|
||||
|
||||
**Delegation is EXPLORE (me) -> WRITE (DSV4F, to a settled spec) -> REVIEW (me),
|
||||
and the tooling is what makes it safe.** DSV4F's code quality is not the risk --
|
||||
on 2026-09-03 its `tools/rvt.py` work was correct and passed review plus 16 new
|
||||
tests; every DSV4F failure that day was INFRASTRUCTURE, and each has a standing
|
||||
fix. (a) The dsh headless CLI times out and the dsh sandbox denies pytest's
|
||||
`tmp_path`, so GATES RUN OUTSIDE the sandbox: I run pytest / scrub on my side,
|
||||
DSV4F only writes. (b) A dsh sub that stalls or hits an approval prompt does not
|
||||
report back, so a dispatched dsh sub is WATCHED with `tools/dsh_watch.py
|
||||
<session-id>` in the background -- its exit is the wake signal, instead of the
|
||||
owner having to tell me it stopped. And do NOT `fork` for execution work: a fork
|
||||
inherits the conversation and on 2026-09-03 continued in reply-to-the-owner mode
|
||||
with zero tool calls instead of doing the job. For "go build / change X", spawn
|
||||
a FRESH agent with an imperative, self-contained brief -- the JUDGEMENT about
|
||||
what to build is still mine; the reading that informs it goes to a Sonnet
|
||||
sub-agent, not to me and not to DSV4F (PROJECT.md §3.9). And SPLIT the settled spec before it reaches DSV4F (owner,
|
||||
2026-09-03: "确认了边界的活儿,拆细了再让 DS 干"): one bounded piece per task
|
||||
-- a function, a section, one file's change -- each sized to finish inside a
|
||||
SINGLE reply. A whole-feature spec handed to DSV4F in one turn truncates at
|
||||
max-tokens (the PanelAnalysis write stopped after the easy schema edits,
|
||||
`stopReason: length`), which reads as "it skipped the hard parts" when it
|
||||
simply ran out of output. Exploration never goes to DSV4F -- but it does not
|
||||
stay with the expensive coordinator either: it goes to a Sonnet sub-agent
|
||||
(owner, 2026-09-05: "探索你带着sub sonnet…DSV4F别做探索。DSV4F就干点儿体力活儿").
|
||||
DSV4F gets labour only: extraction, mining, translation, bounded pre-split
|
||||
writes. PROJECT.md §3.9 carries the incident, the `cmd -p` Command Code route
|
||||
when the DeepSeek platform balance is empty, and the `systemd-run --user` rule
|
||||
for any worker that outlives a turn.
|
||||
|
||||
|
||||
**IronPython 2.7 syntax** for everything under `WillTools.extension/` and every
|
||||
`exec` snippet:
|
||||
|
||||
- No f-strings. Use `'{0}'.format(x)`.
|
||||
- `print('a:', x)` prints a **tuple** `('a:', 5)`. Build one string instead.
|
||||
- No `pathlib`, `dataclasses`, typing syntax, or walrus.
|
||||
- Only `tools/rvt.py` runs on host CPython 3.11 — modern syntax is fine there.
|
||||
|
||||
**Any change to the model needs a Transaction.** Reading never does.
|
||||
|
||||
```python
|
||||
from vibe import tx
|
||||
with tx('Name the user will see in Undo'):
|
||||
...
|
||||
```
|
||||
|
||||
No nested plain `Transaction` (use `SubTransaction` / `TransactionGroup`).
|
||||
|
||||
**Internal units are decimal FEET.** Always — areas in square feet, angles in
|
||||
radians. Convert at the boundary with `vibe.ft(mm)` / `vibe.mm(feet)`. Use
|
||||
`UnitTypeId.*`; `DisplayUnitType` was removed in Revit 2022+.
|
||||
|
||||
**`ElementId` in 2024+**: `.IntegerValue` is deprecated for `.Value`. Read it
|
||||
defensively (`try IntegerValue / except .Value`) as the existing code does.
|
||||
|
||||
**Don't block Revit's UI thread.** No sleeps or polling loops inside handlers.
|
||||
|
||||
**"Could not judge" and "judged, found nothing" are different answers, and
|
||||
nothing here may collapse them.** This is the discipline the whole tool is
|
||||
built on, and it shows up in three places that each look like a local detail:
|
||||
|
||||
- **A knowledge lookup that cannot resolve returns `None`, never `False`.**
|
||||
`knowledge.same_face()` returns `None` when either token is an abbreviation
|
||||
it does not know — because "I cannot read that" and "those are different
|
||||
faces" are opposite answers, and `False` would assert the second.
|
||||
`rebar_stock()` carries `basis = None` for "nothing knows a stock length"
|
||||
alongside `'by_size'` and `'generic'`, and `stocked` is a separate `None`
|
||||
again. `rebar_fy()` returns `basis = None` and `fy = None` when no grade is
|
||||
defensible — A615 names no strength, so its `default_fy_ksi` is deliberately
|
||||
null. Each of them also returns a `why` sentence: standing down without
|
||||
saying why is the same failure one step later. Follow this for any new
|
||||
lookup.
|
||||
- **An unrecognised state word renders red, never grey.**
|
||||
`ticketpage.state_color()` matches GREEN / YELLOW / GREY states by name and
|
||||
everything else falls through to red. That fallthrough is load-bearing, not
|
||||
a default: it is what makes it safe to add a state like `MUTED` at all,
|
||||
because a typo in a state word becomes loud instead of quietly grey. Grey is
|
||||
reachable only through a word the table knows — a *declared* mute. Do not
|
||||
"tidy" the fallthrough into grey or into an exception.
|
||||
- **A check that returns nothing owes an explanation.** See §5 below; this is
|
||||
the same rule, and it is the one a newcomer trips first.
|
||||
|
||||
**Never run an unfiltered collector, and never guess at an API enum.** This
|
||||
crashed Revit on 2026-08-18 — a single exploratory snippet that walked the
|
||||
whole document with `FilteredElementCollector(doc).WhereElementIsNotElementType()`,
|
||||
touched `.Symbol` on every result, *and* set `Options.DetailLevel` from a value
|
||||
reached by `__import__('Autodesk').Revit.DB...`. Revit died with no exception
|
||||
and no journal entry, because a bad value handed to a native API is not a
|
||||
Python error. So:
|
||||
|
||||
- Always scope a collector — `OfClass`, `OfCategory`, a view id, or just
|
||||
`asm.GetMemberIds()`. This model has ~377 assemblies and six figures of
|
||||
elements.
|
||||
- Import enums by name (`from Autodesk.Revit.DB import ViewDetailLevel`).
|
||||
If the import fails you get a traceback; if a bogus object reaches the
|
||||
native layer you get a dead Revit.
|
||||
- One narrow question per `exec`. Geometry extraction goes in its own request,
|
||||
never bundled with a model-wide scan.
|
||||
|
||||
**A contradiction hit mid-refactor is the owner's to settle, not yours.** A
|
||||
restructure on 2026-08-31 (private repo `pyrevit-knowledge`, commit
|
||||
`767281e`, "总表三层化、厂层收割落库") walked into `lifters.capacity_lb` in a
|
||||
plant's `knowledge.yaml` and found an override the owner had entered by hand —
|
||||
`P76 = 16,000 lb`, the correct ALP with-tension-bar rating — disagreeing with
|
||||
`knowledge/catalogue.yaml`'s published 7,690 lb for the same part. The
|
||||
restructure emitted the key EMPTY and wrote a new comment reading "Direct
|
||||
override, checked BEFORE the catalogue. One-offs only," phrased as though the
|
||||
key had always been blank. `lifter_capacity()` checks that override before the
|
||||
catalogue, so every export after that quietly fell back to 7,690. Five real
|
||||
panels (WP242, WP283, WP318, WP322, WP326) each carried a `FAIL / PLUMB`
|
||||
finding — 8,660 lb each of a 34,638 lb piece against a 7,690 lb tension limit,
|
||||
113% — before the owner asked on 2026-09-01 where 7,690 came from, and
|
||||
answering that took a git-history dig across two repositories. The sibling key
|
||||
in the same block, `lifters.min_edge_distance_in: 9`, survived the identical
|
||||
restructure untouched, which is what makes the dropped key a mistake and not a
|
||||
policy. Deleting the losing side of a contradiction, silently preferring the
|
||||
"published" value, and writing a comment that makes the surviving value look
|
||||
uncontested are all the same move: each retires the question instead of
|
||||
asking it. When a refactor meets a contradiction, stop and surface it to him
|
||||
instead.
|
||||
|
||||
**No client or job information in the repo. Ever.**
|
||||
|
||||
Not in code, not in comments, not in docstrings, not in a commit message, not
|
||||
in an example. When you need to refer to one, write the placeholder rather
|
||||
than deleting the sentence:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a client / owner | `sample_client` |
|
||||
| a job / project / model | `sample_job` |
|
||||
|
||||
Product codes are **not** client information and stay as they are: design
|
||||
numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers
|
||||
(`ANCHOR_FACE_4T`) are naming conventions, not identities.
|
||||
|
||||
`python tools/scrub.py` checks the working tree **and the whole git history**
|
||||
against `.scrub-terms` (gitignored, so the list of names is not itself the
|
||||
leak) plus structural patterns — model filenames, absolute user paths, email
|
||||
addresses. It runs as a pre-commit hook and inside `tools/package.py`.
|
||||
|
||||
The working-tree scan covers **untracked files too**. A scratch script nobody
|
||||
has added is exactly where a model path gets pasted, and it is one `git add -A`
|
||||
from being in the commit. `--worktree` is the audit and a hit anywhere fails;
|
||||
`--staged` is what the hook runs, where only staged files block and untracked
|
||||
hits are printed as a warning — a gate that blocks work unrelated to the commit
|
||||
is a gate that gets passed with `--no-verify`.
|
||||
|
||||
**But every scan above honours `.gitignore`, so none of them has ever opened
|
||||
`QNA/`.** Nor `knowledge/plant/`, `knowledge/job/` or `_scratch/`. That is the
|
||||
largest body of prose in the checkout and the only part of it whose job is to
|
||||
quote source material verbatim, so a name is not merely possible there, it is
|
||||
certain. `--private` is the mode that looks at it, and it is **an inventory,
|
||||
not a gate: it always exits 0** and the hook never runs it. It asks a
|
||||
different question, because "does this contain a name" is answered yes by
|
||||
design — it asks whether a name is sitting in a shape that **travels**: inside
|
||||
a fenced block, a proposed `key: value`, a quoted literal, a path, or glued
|
||||
into a longer identifier (`<name>_EMBED_P76`), which is the shape a reader
|
||||
skims past. Those are aggregated by the distinct string and listed, worst
|
||||
first; a name in a sentence, or in a value in a plant file, is the file doing
|
||||
its job and comes back as a number only. Read a row as "has this exact string
|
||||
already been pasted into a tracked file" and check it with `git grep`. It
|
||||
exits 0 because it has no verdict of its own: every string it prints carries a
|
||||
term or a structural pattern, so a copy into a tracked file is already a
|
||||
`--worktree` failure. What it finds is the raw material a leak gets made out
|
||||
of, weeks earlier — and a check that failed on that would fail every run,
|
||||
which is a check somebody wraps in `|| true`.
|
||||
|
||||
Editing a file does not remove a name from the commit that introduced it, and
|
||||
`git push` sends commits, not the checkout. Before anything is pushed the
|
||||
history can still be rewritten; afterwards it cannot.
|
||||
|
||||
**The open model is the user's real work.** Before anything that deletes,
|
||||
renames in bulk, purges, or touches a saved model: prototype read-only, show
|
||||
counts and a sample, and get an explicit go-ahead. Never use `--tx` to "just
|
||||
try" a mutation on a model you have not checked with the user.
|
||||
|
||||
---
|
||||
|
||||
## 4. Idioms
|
||||
|
||||
```python
|
||||
from vibe import q, p, set_p, tx, selected, ft, mm, out, table
|
||||
```
|
||||
|
||||
| Instead of | Write |
|
||||
| --- | --- |
|
||||
| `FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Walls).WhereElementIsNotElementType().ToElements()` | `q('OST_Walls')` |
|
||||
| `StorageType` branching | `p(el, 'Comments')` |
|
||||
| manual Transaction start/commit/rollback | `with tx('name'):` |
|
||||
| `uidoc.Selection.GetElementIds()` + `GetElement` | `selected()` |
|
||||
|
||||
Signatures: `WillTools.extension/lib/vibe/helpers.py`.
|
||||
API patterns: `docs/revit-api-cheatsheet.md`.
|
||||
|
||||
**Before touching anything that measures geometry, read
|
||||
`docs/interference.md`.** Two working-looking versions of the clash check
|
||||
reported no interference anywhere because a cylinder's edges are circles whose
|
||||
end points coincide; a third was two thirds false positives because a bent
|
||||
bar's bounding box is mostly the hole in the middle.
|
||||
|
||||
Inside a button script, use pyRevit forms rather than raw WPF: `forms.alert`,
|
||||
`forms.SelectFromList.show`, `forms.ask_for_string`, `forms.select_levels`,
|
||||
and `script.get_output()` for `print_md` / `print_table` / `linkify`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Building a new tool for the user
|
||||
|
||||
1. Prototype with `rvt.py exec` against the live model until it is correct.
|
||||
2. Write it into `WillTools.extension/WillTools.tab/<Panel>.panel/<Name>.pushbutton/`
|
||||
as `script.py` + `bundle.yaml` (+ optional 96×96 `icon.png`).
|
||||
3. **Add `<Name>` to its parent container's `bundle.yaml` `layout:` list** —
|
||||
the panel's, or the pulldown's if it is a row inside one. A child that
|
||||
is not listed is not created — the button will simply not be there, and
|
||||
nothing anywhere will say why.
|
||||
4. A tool that is not finished goes behind the `Design.panel` pulldown with
|
||||
`(WIP)` in its title, never onto a panel beside the working tools.
|
||||
5. Ask the user to **restart Revit** (never "pyRevit → Reload"), then try it.
|
||||
|
||||
Do not declare a tool finished before step 1 has actually run clean.
|
||||
|
||||
### Adding a check to Ticket QC — a new check owes a skip probe
|
||||
|
||||
This is the rule most likely to bite you, because adding a check is the first
|
||||
thing anyone does in `ticketchecks.py` and the failure is silent at write time
|
||||
and loud on the page.
|
||||
|
||||
Write the check, add it to `CHECKS`, and it runs. But a check returns a list of
|
||||
`Finding`s, and **an empty return is ambiguous** — it can mean *ran and found
|
||||
nothing wrong* or *never ran, for want of data*. `run_all` cannot tell, so it
|
||||
does not guess:
|
||||
|
||||
| what happened | what `run_all` logs | what `checklist_states` shows |
|
||||
| --- | --- | --- |
|
||||
| returned findings | `RAN` | the verdict |
|
||||
| raised | `FAILED` | NOT RUN, plus a `CHECK-ERROR` warning naming the check |
|
||||
| empty, probe returns a reason | `SKIPPED` | NOT RUN, *with that reason* |
|
||||
| empty, probe returns `None` | `RAN` | OK — ran, found nothing |
|
||||
| empty, **no probe registered** | `RAN-EMPTY` | **NOT RUN, "no probe declared"** |
|
||||
|
||||
So a check with no probe that ever legitimately finds nothing renders as a red
|
||||
NOT RUN row for a piece that is fine. That is deliberate — silence that looks
|
||||
like "fine" is the exact defect the checklist exists to surface — but it means
|
||||
**a new check with no probe is a defect you have just shipped**, not a check
|
||||
that works.
|
||||
|
||||
And **a checklist row may name more than one check.** It is answered only when
|
||||
every one of them spoke; any quiet half makes the whole row NOT RUN and the
|
||||
note carries each quiet half's own reason. Under-claiming is the safe
|
||||
direction, because a green row is a claim — a row that asks two questions and
|
||||
prints OK because the half that could still fail it was never asked is exactly
|
||||
the silent pass the table exists to prevent.
|
||||
|
||||
Register one beside the check:
|
||||
|
||||
```python
|
||||
@_register_skip_probe('check_your_thing')
|
||||
def _probe_your_thing(t, intent, know, spec):
|
||||
if not t.get('the_input_it_needs'):
|
||||
return 'no <input> on this piece -- <what> could not be judged'
|
||||
return None # None means: it ran, and found nothing. Never ''.
|
||||
```
|
||||
|
||||
The probe's return is the *reason*, and it is printed. `None` is the
|
||||
"everything was there, it just found nothing" answer — do not return an empty
|
||||
string for that, and do not write a probe that can never return a reason.
|
||||
|
||||
This is now test-enforced rather than a count somebody has to keep re-auditing:
|
||||
`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe`
|
||||
walks `CHECKS` and fails, naming names, the day a new check ships without a
|
||||
probe registered for it in `_SKIP_PROBES` — the same failure the 2026-08-31
|
||||
backlog of seven bare `CHECKS` entries used to sit as silently. Run the test
|
||||
rather than trusting a number here; a count copied into this paragraph is
|
||||
exactly the kind of fact this file has gone stale on before.
|
||||
|
||||
### Tests must not write into the working directory
|
||||
|
||||
`vibe.batchrun.make_worker` takes an `io` for exactly this reason. A test that
|
||||
actually **calls** the worker must pass `io=RecorderIO()`; the sibling tests
|
||||
all do, and the one that did not created a file literally named `s` — twice,
|
||||
once at the repo root and once under `WillTools.extension/` — which reached a
|
||||
commit (`68adc27`) before anyone noticed and had to be deleted in `9504c05`.
|
||||
Anything that only inspects the worker's code object can pass the real one,
|
||||
because nothing runs.
|
||||
|
||||
**This is a convention with nothing enforcing it.** There is no fixture and no
|
||||
guard; `pytest` will happily let a test write anywhere. Use `tmp_path` or a
|
||||
recorder, and be aware that the next stray file is one `git add -A` from the
|
||||
history — which is the same reason `tools/scrub.py --worktree` scans untracked
|
||||
files. A guard would be better than this paragraph; that is a separate job and
|
||||
nobody has asked for it yet.
|
||||
|
||||
---
|
||||
|
||||
## 6. When stuck
|
||||
|
||||
- `rvt.py params <id>` beats guessing a parameter name. It and `/collect` send
|
||||
their arguments as a JSON body, not a query string, because pyRevit 5.0.1's
|
||||
routes `Request` exposes no parsed query string under any name. **Confirmed
|
||||
live 2026-09-01 on Revit 2024.3 / pyRevit 5.0.1: both endpoints return
|
||||
`ok: true` over the JSON body.** Getting that confirmation took two tries,
|
||||
and the lesson is worth more than the fix: **`status` returning ok is not
|
||||
proof of a fresh engine.** A second Revit can hold the bridge port while
|
||||
serving code older than the file on disk, and the right model open plus a
|
||||
session-log line dated today both look exactly like a restart. Compare
|
||||
`System.Diagnostics.Process.GetCurrentProcess().StartTime` (through `/exec`)
|
||||
against `startup.py`'s mtime before believing new code is loaded.
|
||||
- **Two things `rvt.py` can now say about an answer, and neither is a fault to
|
||||
work around.** *"REFUSING this answer - it belongs to a different request"* is
|
||||
the shared-response-slot desync of §1 caught in the act; the channel is
|
||||
latched and the next call drains it, so retry rather than reaching for curl.
|
||||
*"this answer could not be attributed"* means the in-Revit half is older than
|
||||
the client and echoes no id — a **Revit restart** (never a reload) is what
|
||||
fixes it, and until then any answer taken shortly after a timeout is
|
||||
unattributed, which is exactly how a sweep's progress string once got printed
|
||||
as a document title.
|
||||
- `rvt.py exec -c "print([m for m in dir(el) if 'Type' in m])"` — reflection on
|
||||
the live object beats searching docs.
|
||||
- pyRevit's library is readable source at
|
||||
`%APPDATA%\pyRevit-Master\pyrevitlib\pyrevit\` — grep it.
|
||||
- <https://www.revitapidocs.com/2025/> · <https://pyrevitlabs.notion.site/>
|
||||
|
||||
---
|
||||
|
||||
## 7. Search the transcripts before you ask the owner
|
||||
|
||||
**`PROJECT.md` §3.8 is the rule; this is how to run it.** Before you put a
|
||||
question to him, search what he has already said. He has answered far more
|
||||
than any document records, and asking twice gets you a second ruling that does
|
||||
not match the first.
|
||||
|
||||
The transcripts are `~/.claude/projects/<project-slug>/*.jsonl` on the
|
||||
machine the conversation happened on. **The owner's machine's transcripts
|
||||
travel in the private KNOWLEDGE repo under `sessions/`** (the Discord agent's
|
||||
host has them at `~/projects/KNOWLEDGE/sessions/**/*.jsonl`), so a search on a
|
||||
different machine must include that tree or it is not a search of what he
|
||||
said (2026-09-05: two issues asked him questions answered there days before).
|
||||
Locally they are `~/.claude/projects/<project-slug>/*.jsonl` —
|
||||
newline-delimited JSON, **one record per line**, no enclosing array, so read
|
||||
them line by line and never `json.load()` the file. His messages are the
|
||||
records with `type == "user"`; `message.content` is either a plain string or a
|
||||
list of blocks, where the text lives in the blocks with `type == "text"`. Skip
|
||||
what is not his: a `type: "user"` record can also carry a tool result, and the
|
||||
long "This session is being continued…" blocks are a **summary written by an
|
||||
agent, not by him** — quote one as his words and you have invented a ruling.
|
||||
|
||||
**The encoding trap.** The files are UTF-8 and Windows stdout is cp1252, so
|
||||
printing a Chinese message raises `UnicodeEncodeError` and the search looks
|
||||
like it crashed rather than like it found something. `PYTHONIOENCODING=utf-8`
|
||||
is the fix, and it belongs on every one of these commands:
|
||||
|
||||
```bash
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
import json,glob,os,sys
|
||||
q=sys.argv[1]
|
||||
for f in glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')):
|
||||
for n,l in enumerate(open(f,encoding='utf-8'),1):
|
||||
if q not in l: continue
|
||||
r=json.loads(l)
|
||||
if r.get('type')!='user': continue
|
||||
c=r['message']['content']
|
||||
if not isinstance(c,str): c=' '.join(p.get('text','') for p in c if isinstance(p,dict))
|
||||
if q in c: print(os.path.basename(f)[:8],n,r.get('timestamp',''),c[:300],'\n')
|
||||
" "7.5sqrt"
|
||||
```
|
||||
|
||||
That exact command is what found the ruling the rule was written for. **It
|
||||
reads all 65 MB in 0.2 seconds**, so run it three or four times with different
|
||||
terms rather than once — and search **Chinese as well as English**, because he
|
||||
writes in both and often mixes them inside one sentence. Search the value
|
||||
(`7.5`), the concept (`应力极限`), the part number, the parameter name. Prefer
|
||||
a `git log -S` / `git grep` pass too: a ruling that already landed is usually
|
||||
quoted in the comment beside the value.
|
||||
|
||||
Two ways to get this wrong, and the second is the expensive one:
|
||||
|
||||
- **A hit is not automatically the answer.** Check the timestamp and read the
|
||||
surrounding lines. Two rulings months apart may be about different questions
|
||||
— one about *what the number is*, one about *where the setting lives* — and
|
||||
neither retracts the other. §3.8 has the case.
|
||||
- **No hit is not proof he never said it.** These are one machine's history,
|
||||
unindexed and bilingual, and the word you searched is very often not the word
|
||||
he used. Report ***"I searched `X`, `Y`, `Z` — nothing"***, listing the terms,
|
||||
and never *"he has never ruled on this"*. Then ask him. Asking is not the
|
||||
failure; asking **first** is.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,325 @@
|
||||
# Installing WillTools
|
||||
|
||||
Precast engineering tooling: buttons across four ribbon panels — three
|
||||
modelling/ticketing tools plus Snapshot behind **Tools**' `Modeling`
|
||||
pulldown, five set-up commands behind the **Config** pulldown, two finished
|
||||
and four unfinished analysis tools behind **DesignHub**, and GOD_MOD — plus a
|
||||
live bridge that lets Claude Code drive a running Revit session. `README.md`
|
||||
has the table of what each one does.
|
||||
|
||||
Developed and exercised on **Revit 2025.4 · pyRevit 6.5.3 · IronPython 2.7.12**.
|
||||
Everything else in this file is about what to check when one of those differs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| | Needed for | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Revit** 2021 or newer | everything | see [§6](#6-a-different-revit-version) before assuming a version works |
|
||||
| **pyRevit** 4.8 or newer | everything | <https://github.com/pyrevitlabs/pyRevit/releases> |
|
||||
| **Python 3.9+** on the host | `tools/*.py`, `panellift` | not needed to use the ribbon buttons themselves |
|
||||
| **Claude Code CLI** | GOD_MOD button only | optional — every engineering button runs without it |
|
||||
|
||||
**No button that does engineering work needs the CLI, the network or the
|
||||
bridge.** Design Extract reads a design packet in-process with a
|
||||
regex/geometry parser and never leaves Revit. GOD_MOD is the only button
|
||||
that launches the CLI, and it is the developer button.
|
||||
|
||||
### The Windows Store Python trap
|
||||
|
||||
If `python` at a terminal prints something like *"Python was not found; run
|
||||
without arguments to install from the Microsoft Store"* and then exits
|
||||
non-zero, that is not really Python — it is a stub Windows ships in
|
||||
`WindowsApps` that only exists to point you at the Store. Every command in
|
||||
this file, and `tools/setup_machine.py` itself, will fail in confusing ways
|
||||
if that stub is what `python` resolves to.
|
||||
|
||||
Fix: install Python from <https://www.python.org/downloads/> (check "Add
|
||||
python.exe to PATH" during setup), then open a **new** terminal and confirm
|
||||
with `python --version` — it should print a real version number, not a
|
||||
Store prompt. If both a real install and the Store stub are on `PATH`, the
|
||||
Store one usually wins unless you reorder `PATH` or use the `py` launcher
|
||||
instead (`py -3 tools/setup_machine.py`).
|
||||
|
||||
## 2. Install
|
||||
|
||||
1. **Clone or unzip anywhere you can write to.** A checkout under your user
|
||||
folder is easiest. The path is not hardcoded anywhere; the buttons find
|
||||
the folder relative to themselves.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/will123song/pyrevit-dev.git
|
||||
cd pyrevit-dev
|
||||
```
|
||||
|
||||
2. **Run the setup script.** It installs the host-side Python dependencies
|
||||
(`requirements.txt`: PyYAML, pytest, pymupdf) and then *proves* the
|
||||
install rather than just reporting exit codes — it runs `panellift`
|
||||
against a published worked example and checks the computed stress
|
||||
against the number the article prints.
|
||||
|
||||
```bash
|
||||
python tools/setup_machine.py
|
||||
```
|
||||
|
||||
Read the output top to bottom. `PASS` and `SKIP` lines need nothing from
|
||||
you; a `SKIP` is a deliberate non-answer (this distribution ships no
|
||||
knowledge layers, so the checks that need one stand down cleanly — see
|
||||
`knowledge/README.md`), not a problem. A `FAIL` line names what broke and
|
||||
what to try — fix the first one and re-run; failures often cascade from
|
||||
one root cause. Re-run any time with `--check` to just re-verify without
|
||||
changing anything:
|
||||
|
||||
```bash
|
||||
python tools/setup_machine.py --check
|
||||
```
|
||||
|
||||
3. **Register the folder with pyRevit.** pyRevit tab → *Settings* →
|
||||
*Custom Extension Directories* → add the folder that **contains**
|
||||
`WillTools.extension` (the cloned folder itself, not the `.extension`
|
||||
folder inside it) → *Save Settings*.
|
||||
|
||||
Equivalent to this in `%APPDATA%\pyRevit\pyRevit_config.ini`:
|
||||
|
||||
```ini
|
||||
[core]
|
||||
userextensions = ["C:\\path\\to\\pyrevit-dev"]
|
||||
```
|
||||
|
||||
4. **Turn on the routes server** — only if you want the Claude Code bridge
|
||||
(GOD_MOD). pyRevit → *Settings* → *Routes* → enable, port `48884`. Or:
|
||||
|
||||
```ini
|
||||
[routes]
|
||||
enabled = true
|
||||
port = 48884
|
||||
host = "127.0.0.1"
|
||||
```
|
||||
|
||||
5. **Restart Revit.** Not "pyRevit → Reload" — see [§7](#7-the-one-rule).
|
||||
|
||||
A **WillTools** tab appears with four panels, left to right:
|
||||
|
||||
| Panel | |
|
||||
| --- | --- |
|
||||
| **Tools** | one button, **Modeling**, with a dropdown arrow (Reinforce Suggestion, Reinforcing QC, Ticket QC), plus **Snapshot** as a large button beside it. |
|
||||
| **Setup** | one button, **Config**, with a dropdown arrow: Project Config, Design Extract, Self Check, Job Recon, Takeoff. |
|
||||
| **Design** | one button, **DesignHub**, with a dropdown arrow: Panel Lift Check and Panel Analysis (finished), then Beam Analysis, Connection Analysis, Gravity and Lateral, marked `(WIP)`. |
|
||||
| **Lab** | **GOD_MOD**, the CLI button. |
|
||||
|
||||
## 3. Check the install
|
||||
|
||||
Open a model and press **Self Check**. It probes every assumption these tools
|
||||
make — Revit version, pyRevit version, Python engine, routes, library imports,
|
||||
unit conversion, whether the CLI is findable, where the data folder resolved
|
||||
to, whether a published checklist is available, and whether this model
|
||||
actually follows the naming conventions the checks depend on — and prints
|
||||
each as OK / WARN / FAIL.
|
||||
|
||||
Read the FAILs top-down; they cascade. A WARN never blocks a run, and on a
|
||||
knowledge-less install several rows are *expected* to read WARN — that is
|
||||
the "checks stand down" state, not a broken install.
|
||||
|
||||
## 4. Try it with no Revit at all
|
||||
|
||||
```bash
|
||||
python -m panellift examples/example-panel.job.json -o report.html
|
||||
```
|
||||
|
||||
That runs the PCI Chapter 8 handling check against a published worked
|
||||
example (the ASPIRE Winter 2019 article) and writes an HTML report. It needs
|
||||
no Revit, no model, and no knowledge layer — a good first thing to run right
|
||||
after `tools/setup_machine.py` to confirm the arithmetic reaches you intact.
|
||||
|
||||
## 5. Using it on a real job
|
||||
|
||||
Per model, in order:
|
||||
|
||||
1. **Extract Design** — pick the design summaries, one extraction terminal
|
||||
opens per design, each writes `specs/<DESIGN>.json`.
|
||||
2. **Read the spec.** It is JSON on purpose. Fix anything wrong and fill in
|
||||
`source.reviewed_by`. Nothing downstream questions the spec again.
|
||||
3. **Ticket QC** — checks every piece of those designs against the spec.
|
||||
**Needs a knowledge layer with the published checklist** — see below;
|
||||
without one it refuses to open with a one-line reason.
|
||||
4. Edit the `knowledge.yaml` files as you learn the job — see the layers
|
||||
below for which one. They are re-read on every run — **no restart needed
|
||||
for knowledge or spec edits.**
|
||||
|
||||
Everything lands in `_ticketqc/` **beside the model file**:
|
||||
|
||||
```
|
||||
<model folder>/_ticketqc/
|
||||
evidence/<DESIGN>.json raw scrape, no interpretation
|
||||
specs/<DESIGN>.json the reviewable spec <-- read this
|
||||
knowledge.yaml your hand-written rules
|
||||
log/<DESIGN>.txt what the extractor printed
|
||||
```
|
||||
|
||||
If the model is not saved, or its folder is read-only, it falls back to
|
||||
`%APPDATA%\pyrevit-ticketqc\<model title>\`. Self Check tells you which.
|
||||
|
||||
### The knowledge layers, and why this distribution has none of them
|
||||
|
||||
Knowledge is not one file. It is read in layers, each overriding the one
|
||||
before it key by key, and the split is by **how long the fact stays true**:
|
||||
|
||||
| layer | where | what belongs there |
|
||||
| --- | --- | --- |
|
||||
| defaults | `lib/vibe/knowledge.py` — in this repo | what the code assumes when nobody has said |
|
||||
| catalogue | `knowledge/catalogue.yaml` — **not shipped** | published hardware ratings, plus the master shop-ticket checklist |
|
||||
| **plant** | `%APPDATA%\WillTools\knowledge.yaml` — **not shipped** | naming conventions, casting-bed rails, **parameter names**, which catalogue item each part number is, rigging mode and dunnage position |
|
||||
| **job** | `<model folder>/_ticketqc/knowledge.yaml` — **not shipped** | tolerances, and anything where this job departs from the plant |
|
||||
| **project config** | wherever you put it — see below | the whole configuration, pinned to one job |
|
||||
|
||||
This distribution ships **none of the last four**, deliberately — see
|
||||
[`knowledge/README.md`](knowledge/README.md) and
|
||||
[`knowledge/catalogue.yaml.template`](knowledge/catalogue.yaml.template) for
|
||||
the shape of what a real layer looks like, with every value blanked out.
|
||||
Everything on **Tools** and **Setup** except Ticket QC runs without one,
|
||||
reading only `knowledge.DEFAULTS`; Self Check reports every plant-specific
|
||||
parameter role as unmapped until real layers are wired in. **Ask the owner**
|
||||
for real layers when you have a job to run for real — see the README's
|
||||
"What to ask the owner for."
|
||||
|
||||
Copying a job file forward to the next job should be nearly empty work — if
|
||||
you find yourself copying a lot, it belongs in the plant layer. Set
|
||||
`WILLTOOLS_KNOWLEDGE` to a full path to put the plant layer on a share so a
|
||||
whole office reads one file. (`tools/setup_machine.py` deliberately leaves
|
||||
this variable unset on a knowledge-less install — see its own comments if
|
||||
you are wiring one in by hand rather than through the script.)
|
||||
|
||||
Handling decisions are plant knowledge too: `handling.stages[].rig_mode`
|
||||
(`rolling` or `static`, blank inherits the global mode) and
|
||||
`handling.dunnage.mode` (`lifter_rows` or `fraction`, with `fraction` such as
|
||||
`0.2` for supports at 0.2L from each end). A plant that has not set dunnage
|
||||
still gets transportation as a lift, and the report labels it as approximate.
|
||||
|
||||
Self Check prints which layers it found, and lists any parameter role still
|
||||
unmapped.
|
||||
|
||||
### Pinning a configuration to a job
|
||||
|
||||
The **Project Config** button writes every layer above, merged, into one file
|
||||
wherever you choose — the job folder on the share, usually, next to the rest
|
||||
of the job's paperwork. From then on the model runs on that file, and it is
|
||||
the last word: edit it and the next run uses what you wrote.
|
||||
|
||||
A one-line pointer at `<model folder>/_ticketqc/active-config.txt` records
|
||||
where the file went. Press the button again to refresh the file from the
|
||||
layers underneath, or to drop back to them.
|
||||
|
||||
Two things to know before you use it:
|
||||
|
||||
- **It freezes the plant layer.** The merged copy in the file contains the
|
||||
plant's settings as values of its own, so while the file is live, fixing
|
||||
something in the plant file no longer reaches this job. That is the point of
|
||||
pinning, and it is also how a plant-wide correction quietly fails to arrive.
|
||||
- **Do not move or delete the file without clearing the pointer.** Every
|
||||
button refuses to run rather than falling back, because a run on the layered
|
||||
files when you meant the pinned ones looks entirely normal and is not the
|
||||
configuration you set.
|
||||
|
||||
A new model has no pointer, so it starts from the layered files and gets its
|
||||
own configuration the first time the button is pressed there.
|
||||
|
||||
### Nothing is hard-coded, including public facts
|
||||
|
||||
The obvious things stay out of the code: client names, job names, parameter
|
||||
names. Less obviously, **so do facts that are perfectly public.** A lifting
|
||||
insert's rated working load is printed in the manufacturer's catalogue and is
|
||||
no secret — but written into a `.py` file it becomes a number nobody can
|
||||
correct without a release, and it will be wrong eventually.
|
||||
|
||||
So a rating is a lookup, and it takes two hops, because a plant models its own
|
||||
part number rather than the manufacturer's designation:
|
||||
|
||||
```
|
||||
family name in the model
|
||||
-> lifters.catalogue_alias plant layer: P000 -> SAMPLE_ANCHOR_4T
|
||||
-> catalogue.inserts product data: SAMPLE_ANCHOR_4T -> 8000 lb
|
||||
```
|
||||
|
||||
Each hop fails differently and is fixed in a different place, so the check
|
||||
says which one is missing rather than reporting a bare "unknown". The same
|
||||
shape applies to anything else with a published number behind a local name.
|
||||
|
||||
## 6. A different Revit version
|
||||
|
||||
The code is written against the stable parts of the API and guards the two
|
||||
places Autodesk moved the floor:
|
||||
|
||||
- **`UnitTypeId`** (Revit 2021+) with a `DisplayUnitType` fallback for 2020 and
|
||||
older, resolved once at import so an old host cannot break the whole library.
|
||||
- **`ElementId.IntegerValue`** → **`.Value`** (Revit 2024+), read defensively
|
||||
at every site.
|
||||
|
||||
Also version-proofed: the `/exec` snippet namespace skips API types that a
|
||||
given release does not have, rather than failing to register.
|
||||
|
||||
**Not verified anywhere but 2025.** If you move to 2023 or 2026, press Self
|
||||
Check first — it reports the host version as a WARN and exercises the imports
|
||||
and a unit round-trip, which is where a version problem shows up first.
|
||||
|
||||
### A different Python engine
|
||||
|
||||
pyRevit chooses the interpreter. This extension is written in the intersection
|
||||
of Python 2 and 3 — no f-strings, no `print` statement, no implicit integer
|
||||
division, no `pathlib`, and `unicode`/`basestring` resolved in
|
||||
`lib/vibe/compat.py` — so IronPython 2.7, IronPython 3 and CPython 3 should all
|
||||
load it. Only IronPython 2.7 has actually been run in anger; Self Check warns
|
||||
on anything else.
|
||||
|
||||
## 7. The one rule
|
||||
|
||||
**Never use pyRevit → Reload. Restart Revit.**
|
||||
|
||||
pyRevit's routes handler is owned by the engine that first loaded it. A reload
|
||||
tears that engine down and leaves the handler pointing at the corpse; the next
|
||||
bridge request runs against a dead engine and takes Revit down with it —
|
||||
an unhandled exception off the main thread, which Revit cannot catch.
|
||||
|
||||
There is no reload endpoint, `rvt.py` refuses to run after it sees two loads in
|
||||
one Revit process, and a restart picks up new buttons just as well.
|
||||
|
||||
Restart Revit after: installing, adding or renaming a button, or adding an icon.
|
||||
No restart needed after: editing a `script.py`, a spec, or `knowledge.yaml`.
|
||||
|
||||
## 8. What is in this distribution
|
||||
|
||||
```
|
||||
pyrevit-dev/
|
||||
├── INSTALL.md this file
|
||||
├── README.md what the tool is, what needs knowledge
|
||||
├── CONTRIBUTING.md tests, the no-client-data rule, PRs
|
||||
├── examples/example-panel.job.json a published worked example, try this first
|
||||
├── docs/ reference material and design docs
|
||||
├── knowledge/README.md why this folder is otherwise empty
|
||||
├── knowledge/catalogue.yaml.template the shape of the layer that isn't here
|
||||
├── tools/setup_machine.py run this first
|
||||
├── tools/rvt.py bridge client (host CPython 3)
|
||||
├── panellift/ the handling-check engine, its own test suite
|
||||
└── WillTools.extension/
|
||||
├── startup.py registers the bridge routes
|
||||
├── lib/vibe/ helpers, compat, designspec, knowledge,
|
||||
│ ticketqc, ticketchecks, aiextract
|
||||
└── WillTools.tab/ the ribbon, four panels left to right:
|
||||
├── Tools.panel/ Modeling.pulldown/ — Reinforce
|
||||
│ Suggestion · Reinforcing QC · Ticket QC —
|
||||
│ plus Snapshot as the one remaining large
|
||||
│ button
|
||||
├── Setup.panel/ Config.pulldown/ — Project Config ·
|
||||
│ Design Extract · Self Check · Job Recon ·
|
||||
│ Takeoff
|
||||
├── Design.panel/ DesignHub.pulldown/ — Panel Lift Check ·
|
||||
│ Panel Analysis (finished, grouped here on
|
||||
│ the owner's own instruction), then Beam
|
||||
│ Analysis (WIP) · Connection Analysis (WIP)
|
||||
│ · Gravity (WIP) · Lateral (WIP)
|
||||
└── Lab.panel/ GOD_MOD
|
||||
```
|
||||
|
||||
No model data or knowledge ships with it. `_ticketqc/` folders stay with
|
||||
their models; knowledge layers come from the owner, on request — see the
|
||||
README's "What to ask the owner for."
|
||||
@@ -0,0 +1,709 @@
|
||||
# WillTools
|
||||
|
||||
This is the document to read before opening a file here. It assumes you know precast and
|
||||
Python and nothing about this codebase. Four parts, in the order they matter: **what the
|
||||
tool is**, **how it is built**, **the discipline**, **where everything stands**.
|
||||
|
||||
Read part 3 properly. Parts 1 and 2 orient you; part 3 is what stops you doing damage.
|
||||
Every rule in it was bought with a failure and carries the incident that bought it,
|
||||
because a rule whose reason has been lost gets "simplified" away by the next reader.
|
||||
|
||||
One standing convention, and it is the project's premise: **this repository does not state
|
||||
things it has not checked.** Anything unverified is marked unverified; anything measured
|
||||
says when. Where a number rots — line counts, test counts, timings — you get the command
|
||||
instead, because those have each been wrong more than once in a single week. Where a
|
||||
document and the code disagree, **the code wins**; §4.6 lists the disagreements known on
|
||||
2026-08-30.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it is
|
||||
|
||||
### 1.1 The product
|
||||
|
||||
WillTools is one engineer's QC instrument, not a plant production system. The owner is
|
||||
an **independent precast specialty consultant** — not the plant's employee — and on any
|
||||
job his responsibility is **cladding and wall panels only**. Other engineers hold the
|
||||
beams, slabs and columns, and the plant knowledge every check runs on was validated
|
||||
against panels and nothing else.
|
||||
|
||||
`lib/vibe/scope.py` exists because of that, and insists that **out of scope is a third
|
||||
state** — not a pass, not a failure, not an absence. Every report names how much work it
|
||||
left alone and of what kind. The scope is *declared* in the job knowledge layer
|
||||
(`scope.mark_prefixes`), never inferred. The project already paid for the alternative:
|
||||
one unauthorised exclusion hid forty tickets and every check that ran came back clean.
|
||||
|
||||
That is the first face of the rule the whole tree is organised around — **silence is
|
||||
never a pass**. Its full statement is §3.1, and it is the single most important idea
|
||||
here.
|
||||
|
||||
### 1.2 The ribbon
|
||||
|
||||
One tab, four panels: **Tools**, **Setup**, **Design**, **Lab**. Folder names *are* the
|
||||
UI, and `layout:` in a container's `bundle.yaml` decides what gets built at all — a child
|
||||
not listed is absent, not misordered. No button carries hover text (no `tooltip:`, no
|
||||
`author:`, no module docstring); `tests/test_button_tooltips.py` fails by name if one
|
||||
returns.
|
||||
|
||||
**Reinforcing Suggestion** asks which already-reinforced panels, *between them*, account
|
||||
for every feature of this one — a set-cover jigsaw, not a similarity ranking — capped at
|
||||
four donors, with the shortfall named rather than relaxed to a fifth. Search in
|
||||
`reinforcematch`, index in `reinforceindex`/`reinforcecore`, join in `reinforcebind`,
|
||||
wording in `reinforcereport`. **It opens no Transaction, by hard rule**: a wall panel's
|
||||
middle vertical band is per-design engineering, and copying a donor's cage in
|
||||
automatically would be the most dangerous thing this repo could do. An ordinary press
|
||||
rebuilds only pieces whose cheap signature moved; shift-click forces a full rebuild,
|
||||
because the signature cannot see an opening moved or resized in place.
|
||||
|
||||
**Panel Lift Check** is a PCI Chapter 8 handling check over stripping, transportation and
|
||||
erection. The piece is measured from the model, then **you click the inserts carrying each
|
||||
stage, on the piece itself** — the rules supply highlighted defaults and no longer get the
|
||||
last word. It refuses rather than guesses: fewer than two support lines, a stage naming an
|
||||
insert the panel has not got, a stage with no multiplier and no `pci_case`, short/long
|
||||
above 0.75 (two-way spanning, which a beam idealisation cannot represent). Multi-select
|
||||
runs a background batch whose worker lives in `vibe/batchrun.py` and never in the button —
|
||||
a thread reading the command module's scope after pyRevit tears it down raises on a
|
||||
non-main thread and kills Revit. Batches skip the position search — 93% of a run, 12.6 s
|
||||
of a 13.6 s median piece on the reference sweep.
|
||||
|
||||
**Ticket QC** is the flagship. It checks every shop ticket of a design against the
|
||||
extracted spec plus the knowledge layers: concrete strengths, jacking force, strand
|
||||
count/grade/diameter/position, W.W.F., unit weight, embeds in both directions, bar marks,
|
||||
note views, size envelope, interference. **27 check functions** in `ticketchecks.CHECKS`
|
||||
feed **34 numbered checklist rows** in `ticketpage.CHECKLIST_ITEMS` — both counted
|
||||
2026-08-30, both countable with `len()`. Every check is arithmetic; the one exception is
|
||||
quarantined (§3.1). A row's status comes from `run_all`'s disposition log, never from an
|
||||
absence of findings. And a ticket is not one sheet: it is the union of every sheet named
|
||||
for the mark.
|
||||
|
||||
**Snapshot & Close** is the daily close-out. The model is 300+ MB on disk and 10+ GB open,
|
||||
so there is never a second copy in memory: phase A indexes the live document, phase B
|
||||
closes Revit (the press *is* the authorisation), phase C optionally reopens it detached in
|
||||
a deferred host process once the memory is free. The request is journalled before the close
|
||||
and the deferred process journals its outcome, so a day it silently did not run is reported
|
||||
in capitals on the next press.
|
||||
|
||||
Behind the **Config** pulldown on the Setup panel: *Project Config*, *Design Extract*
|
||||
(scrapes a design packet into a reviewable `spec.json`, with whatever the parser could not
|
||||
place landing verbatim in `unresolved` rather than dropped), *Self Check*, *Job Recon*
|
||||
(reconciles the issued ticket set against the whole job — the question no per-ticket check
|
||||
can answer, because a ticket that is not there cannot be checked) and *Takeoff*.
|
||||
|
||||
The **Design** panel is a quarantine: everything on it sits behind the `DesignHub`
|
||||
pulldown, on a panel of its own, so nothing half-built ever sits beside a finished tool.
|
||||
On the **Lab** panel, **GOD_MOD** opens a terminal running `claude` at the repo root with
|
||||
default permissions.
|
||||
|
||||
### 1.3 What a run produces
|
||||
|
||||
**Ticket QC.** The checklist table *is* the page — first, unfolded, one per ticket that
|
||||
has anything to say, in loudness order. `ticketpage.state_color`: OK green; WARN and FOR
|
||||
ENGINEER yellow; **NOT RUN and FAIL both red**. An all-green ticket gets one line, not a
|
||||
table.
|
||||
|
||||
Everything else is a `foldreport.Section`, and the folding is a contract: *the answer is
|
||||
the headline and is never folded; everything else starts closed; a section carrying a
|
||||
problem opens itself, and its `problem` is a **sentence** naming what is wrong, not a
|
||||
boolean; folded is not gone — a closed header still says what is inside and how much.*
|
||||
The reader's side of that contract is **if nothing below the tables is open, nothing needs
|
||||
me.** A problem section renders open as plain HTML needing no script, because pyRevit's
|
||||
output panel is a WinForms `WebBrowser` in IE7 document mode where `<details>` does not
|
||||
exist and fails *silently*.
|
||||
|
||||
Three files land in `_ticketqc/` on every press: `last-ticketqc.txt` (the whole report,
|
||||
nothing folded, long notes wrapped rather than cut, so a dead fold costs a file open and
|
||||
never a sentence); `qc-run.json` (`findings` and `model_read` as two keys, never one list,
|
||||
each entry carrying `source` and a boolean `computed`; muting never touches it, because a
|
||||
muted row is a rendering decision and a muted check that failed still fails the ticket —
|
||||
but it does not yet carry the run log, §4.4); and the `ticketqc/2` evidence file of §2.2
|
||||
(`qc-run.json` itself carries `ticketvision.RUN_SCHEMA = 'qc-run/3'` — read the constants,
|
||||
both have moved since this paragraph was written).
|
||||
|
||||
**Panel Lift Check** produces a self-contained HTML file with the `.job.json` beside it as
|
||||
the complete input, re-runnable where there is no Revit: a stage table, an "Inserts picked"
|
||||
table recording the one part of the run that came from a human, recommended moves in dx/dy
|
||||
inches, and inline SVG free-body, shear, moment and stress plots that load **nothing
|
||||
external**. Two habits in it are worth copying. A stage that was not analysed prints
|
||||
*words*, never a dash, because a dash among numbers reads as a small number. And the
|
||||
station tables under each plot thin ~2000 stations by stride but never thin what matters —
|
||||
both ends, every opening edge, every pick or dunnage line, the peak and trough of V and M,
|
||||
the worst compression, and the governing tension station, tinted and bold and saying so.
|
||||
|
||||
Read Δx as "about here", not as a dimension, until §4.4's solver work is done.
|
||||
|
||||
---
|
||||
|
||||
## 2. How it is built
|
||||
|
||||
### 2.1 Two Pythons, and the line between them
|
||||
|
||||
`WillTools.extension/` is **IronPython 2.7.12** on Revit's UI thread: no f-strings, no
|
||||
`pathlib`, no `dataclasses`, no typing syntax. `panellift/` and `tools/` are **host
|
||||
CPython** (`panellift` declares `>=3.9`).
|
||||
|
||||
**`panellift` is deliberately unimportable from Revit** — it uses dataclasses and typing
|
||||
syntax on purpose. A calculation whose output reaches a plant must be verifiable, and
|
||||
verifiable means a suite that needs no licence, no model and no Windows. That is the
|
||||
whole reason the line is where it is.
|
||||
|
||||
`vibe/liftexport.py` is the entire Revit side of it: it measures the piece, writes the
|
||||
job file, shells out with a timeout and tick callback (`find_python`, `run`), and reads
|
||||
the summary back. **Mechanics live on the far side of that line; facts about *this
|
||||
model* live on the near side.**
|
||||
|
||||
### 2.2 The contract is the file, not the function
|
||||
|
||||
Modules hand each other versioned JSON carrying `schema`, `written_at`, `written_by`,
|
||||
`doc_key` and `knowledge_sources` — not objects. Four instances today:
|
||||
`liftexport.SCHEMA = 'panellift/1'`, `reinforcecore.SCHEMA = 6`,
|
||||
`specformat.SCHEMA_VERSION = 1`, and `ticketqc.SCHEMA = 'ticketqc/2'` (bumped from `/1` on
|
||||
2026-08-31; read the constant, it will move again), written to
|
||||
`<model>/_ticketqc/evidence/run-<stamp>.json` on every Ticket QC press. **Nothing reads
|
||||
the last one yet** — that is step 2 of the migration (§4.5), and precisely why the writer
|
||||
could ship alone. The four rules, from `docs/architecture-migration.md` §2:
|
||||
|
||||
1. **Discard whole on mismatch.** `reinforcecore.load()` and `ticketqc.load_evidence()`
|
||||
throw a stale file away entirely — never partially, never migrated.
|
||||
2. **No live objects.** The plan named two holdouts; the build found four (`t['doc']`,
|
||||
`t['legends']`, `t['sheets']`/`t['sheet']`, and live `XYZ` in `t['strand_points']` on
|
||||
the no-framing early return) — so the serialiser gates on **type**, not on a list of
|
||||
known offenders.
|
||||
3. **Absence is not a value.** A gather that could not read something writes an explicit
|
||||
`null` *and* a reason, through one `put()`. A merely missing key reads downstream as
|
||||
"nothing to check" — the same failure mode `ticketchecks._SKIP_PROBES` exists to
|
||||
prevent (§3.1).
|
||||
4. **Provenance travels** — which layers were in force, which spec source (`spec` or
|
||||
`regex`), which build. `intent` and `spec_source` ride with each ticket. Findings do
|
||||
not: `qc-run.json` owns verdicts and the evidence file owns inputs, and step 2 needs
|
||||
them apart so it can compare one against the other.
|
||||
|
||||
### 2.3 The five knowledge layers
|
||||
|
||||
Each layer overrides the one before it. `knowledge.load(doc)` merges them;
|
||||
`knowledge.sources(doc)` lists them, and Self Check prints them.
|
||||
|
||||
| layer | where it lives |
|
||||
| --- | --- |
|
||||
| `knowledge.DEFAULTS` | this repo — what the code assumes |
|
||||
| `knowledge/catalogue.yaml` | this repo — published hardware ratings, identifies nobody |
|
||||
| plant `knowledge.yaml` | `%APPDATA%\WillTools\` or `WILLTOOLS_KNOWLEDGE`, per-plant folder |
|
||||
| job `knowledge.yaml` | `<model folder>/_ticketqc/` |
|
||||
| the project config file | wherever the engineer put it; `_ticketqc/active-config.txt` points at it |
|
||||
|
||||
**Maps deep-merge, lists replace.** Two consequences bite: a list in the job file
|
||||
*removes* the plant's entries rather than adding to them, and `_deep_update` simply
|
||||
assigns, so **an override and a typo are indistinguishable today**.
|
||||
|
||||
Two refusals guard the edges. A plant named in `job.plant` whose file is missing sets
|
||||
`_plant_missing` rather than silently falling back to the machine-wide file; a pointer to
|
||||
a vanished config sets `_config_missing`. `knowledge.config_problems()` turns either into
|
||||
a sentence the buttons refuse on.
|
||||
|
||||
Note the deliberate two-pass in `load()`: `job.plant` chooses the plant file but lives in
|
||||
a layer that merges *after* it, so `plant_id()` reads the job and config files once,
|
||||
alone, first. **Delete that pre-pass and the tool reads the wrong plant while every report
|
||||
looks normal.**
|
||||
|
||||
Which layer a fact belongs in is not a preference; it is an argument, and it is part of
|
||||
the discipline — §3.2.
|
||||
|
||||
### 2.4 The bridge
|
||||
|
||||
`tools/rvt.py` is a host client for a pyRevit routes endpoint registered by `startup.py`.
|
||||
`rvt.py exec` runs a snippet *inside* the live session and returns
|
||||
`{ok, stdout, result, error, traceback}`. It is how anything is prototyped against a real
|
||||
model, and it is the most dangerous thing in the repo. Its one absolute rule is §3.5.
|
||||
|
||||
### 2.5 Two repos
|
||||
|
||||
This repository can be shown to anybody. The other one cannot. `knowledge/plant/`,
|
||||
`knowledge/job/` and `knowledge/_archive/` sit in this working tree for convenience and
|
||||
are tracked by the **other** repository, never by this one; `.gitignore` says so at the
|
||||
point of exclusion. `knowledge/catalogue.yaml` is the one deliberate exception, being
|
||||
published manufacturer data, and it says so in its own header. The gate that keeps the
|
||||
split honest is §3.4.
|
||||
|
||||
---
|
||||
|
||||
## 3. The discipline
|
||||
|
||||
### 3.1 The honesty invariants
|
||||
|
||||
**NOT RUN is never a pass. Silence that looks like success is the cardinal sin.** NOT RUN
|
||||
renders **red**, with FAIL, and `ticketpage.py` carries a comment forbidding the next
|
||||
reader from greying it out: a check that did not run tells you nothing about the piece, so
|
||||
it must never look calmer than one that failed. One unauthorised exclusion hid forty
|
||||
tickets and every check that ran came back clean (`scope.py`, §1.1); seven `die()` calls
|
||||
let refusals leave through a dialog, so a batch where every panel refused produced a blank
|
||||
report. Every refusal now goes through `not_run()`.
|
||||
|
||||
**`None` ("could not judge") is not `[]` ("judged, found nothing").** `ticketchecks.run_all`
|
||||
disambiguates an empty return with a **skip probe**: `_SKIP_PROBES[check_name]` gives
|
||||
either a reason or `None` (ran, found nothing). A check with *no* probe logs `RAN-EMPTY`
|
||||
and renders NOT RUN reading "no probe declared" — unexplained silence is the defect, not
|
||||
agreement. A probe's sentence must name a verifiable place: row 5.4 now separates *no
|
||||
spec*, *a spec predating the field*, and *a design recording no mesh*. Every registered
|
||||
check has a probe now, and it is test-enforced (§4.4) rather than a count to keep
|
||||
re-auditing by hand.
|
||||
|
||||
**A declared out-of-scope row is a third state, not a silence.** The scope is declared in
|
||||
the job layer, never inferred. No declaration means judge everything, and a mark whose
|
||||
prefix cannot be read is never set aside — "I could not tell" may only resolve towards
|
||||
judging.
|
||||
|
||||
**A model-read (`AI-*`) finding is always WARN**, never OK and never FAIL. With
|
||||
`vision_escalation` on, grade-D items no comparison can settle go to a small model reading
|
||||
one rendered view; the answer lands in `model_read`, never in `findings`, and **can never
|
||||
set a checklist row**. `ticketvision` carries that provenance four independent ways (the
|
||||
`source` field, an `AI-` code owning no row, its own list, its own report section), on the
|
||||
assumption that an edit will drop one and the other three still have to hold. A model that
|
||||
saw nothing wrong in one view is a reason to look, not to stop looking.
|
||||
|
||||
**Muting never silences a FAIL**, and a muted check still *runs*: `apply_muting` repaints
|
||||
it grey rather than removing it, and MUTED gets its own tally bucket. "31 OK" when four of
|
||||
them were nobody's judgement is the quiet lie the table exists to stop.
|
||||
|
||||
**A check may not claim what the tool is in no position to claim.** Rows 5.6 and 5.7 both
|
||||
printed *"no reinforcing found on this piece"*; this plant models bars outside any rebar
|
||||
category, so with `drawings.reinforcing_families` empty a **301-member** piece produced the
|
||||
same words as a piece with no steel in it. They now say the tool could not **recognise**
|
||||
any, name the key that would turn it on, and **stay red** — *a better sentence is not
|
||||
permission to soften the state*. Two siblings landed the same day: 5.1 printed OK off one
|
||||
of its two questions, so a check slot now takes a tuple and **any quiet half makes the row
|
||||
NOT RUN**; and 5.3, a person's job by design, printed red because a hand-kept
|
||||
`FOR ENGINEER` tuple omitted it — that list is now derived from the table.
|
||||
|
||||
### 3.2 The knowledge layer law
|
||||
|
||||
The mechanism is §2.3. The law is this: **job beats plant beats what everybody in the
|
||||
trade knows** — not a tie-break, but a statement about where truth lives. The trade knows
|
||||
a bar mark is a recipe; the plant knows its own hardware; the job knows what was drawn.
|
||||
So **placement is an argument, not a preference**. Three examples from a single day:
|
||||
|
||||
- **Rebar stock lengths → the plant.** One number for every size let a 30 ft `#4` pass
|
||||
where the plant stocks `#4` in 20 ft — a false OK on a real defect class. It became a
|
||||
**map keyed by printed size, in integer inches** (`catalogue.rebar.stock_by_size`, values
|
||||
supplied by the plant layer). As a *list*, a job adding one size would silently delete
|
||||
the plant's whole table.
|
||||
- **A job's odd-grade bar → the job.** The manual files every stock length under a
|
||||
designation the owner says is retired; if it reappears it is an exception to this job,
|
||||
so it is job knowledge.
|
||||
- **Which end is up → public.** The plant layer carried `top_is_higher_global_z`, and both
|
||||
obvious repairs were wrong. An assembly origin is placed when the assembly is made, and
|
||||
**placing it is the statement**: that is how precast is built, not a house habit, so it
|
||||
belongs in the repo catalogue. A key asking a plant to declare it is not an unread key,
|
||||
it is the wrong question — and it named a *mechanism* rather than an answer, which a
|
||||
layer must never be able to swap.
|
||||
|
||||
**Dormant and loud.** A key not yet wired ships **null**, with `*_verified: false` and a
|
||||
written reason — never a guess. The reinforcing rule shipped that way and proved the right
|
||||
*shape* around the wrong *field*; the flag did its job, because the rule never ran and the
|
||||
report named the reason, where a guess would have left every bar in the job silently
|
||||
un-recognised. Turning it on was two YAML fields and no code change. §4.3 lists what is
|
||||
dormant now.
|
||||
|
||||
### 3.3 Never guess a plant fact
|
||||
|
||||
Parameter names, categories, family tokens and casting conventions are not code's to know.
|
||||
They are reached through **roles** declared in the knowledge layers (`PARAMETER_ROLES`,
|
||||
`knowledge.param_names`). Two incidents from one day:
|
||||
|
||||
- **Three guesses, three wrong.** An agent needed one plant parameter name and guessed it
|
||||
three ways; none of the three existed in the model. `rvt.py params <id>` answers it in
|
||||
one request. The precedent: the mark parameter name left the code entirely and became
|
||||
the role `member.mark`.
|
||||
- **Types read where the values were on instances.** A census of the reinforcing parameter
|
||||
read family *types* only, but this plant writes that value on the **instance** — so the
|
||||
census saw nothing and the agent told the owner the wall panels carried no reinforcing
|
||||
at all. Measured properly, one piece had 112 bars and another 84. The rule now carries
|
||||
`read_from: [instance, type]`: the value sits on one or the other, mutually exclusively
|
||||
per element, and a reader taking only one finds nothing on half the model **and returns
|
||||
a result that looks perfectly clean**.
|
||||
|
||||
**Measure it, ask the owner, or stand down with a written reason — never assume.** When a
|
||||
bar-size parameter could not be measured, nobody guessed; it is still an open question
|
||||
(§4.3). *"Ask the owner" is the third resort, not the second:* **search the transcripts
|
||||
first** — §3.8, one-liner in `CLAUDE.md` §7.
|
||||
|
||||
### 3.4 Secrecy and the scrub gate
|
||||
|
||||
**No client, job or plant information anywhere** — code, comments, docstrings, commit
|
||||
messages, examples. Where you need to refer to one, write the `CLAUDE.md` §3 placeholder
|
||||
rather than deleting the sentence. Product codes are not identities and stay as they are.
|
||||
|
||||
`tools/scrub.py` is the gate, in two modes on purpose. `--worktree` is the audit: every
|
||||
file git can see, tracked *and* untracked, and a hit anywhere is a hit. `--staged` is the
|
||||
pre-commit hook: only staged files can block a commit, while untracked hits print loudly
|
||||
without stopping unrelated work — because a gate that blocks unrelated work is one people
|
||||
learn to pass with `--no-verify`. It also runs inside `tools/package.py`. The term list
|
||||
lives in gitignored `.scrub-terms`, so the list of names is never itself the leak;
|
||||
structural patterns (model filenames, absolute user paths) are checked whether or not
|
||||
anyone remembered to add a term.
|
||||
|
||||
**The cautionary tale (2026-08-30).** The gate built each term as `\b<term>\b`. In regex
|
||||
`_` and digits are word characters, so `\bTerm\b` never matched `PREFIX_TERM` — and
|
||||
`PREFIX_ROLE` was the dominant naming convention here before the roles refactor. **The
|
||||
terms most likely to be a real leak were written in the one shape the gate was blind to.**
|
||||
The fix is letter-only lookarounds, and the sanctioned placeholders are now read out of
|
||||
`CLAUDE.md`'s own table so the gate cannot drift from the rule. The rescan: working tree 0
|
||||
hits → **4**; history 19 hits in 3 commits → **22 in 6**, all six already pushed.
|
||||
**Assume every scan before that fix under-reported.** Editing a file does not remove a
|
||||
name from the commit that introduced it — §4.4 has the decision still outstanding.
|
||||
|
||||
### 3.5 Revit safety
|
||||
|
||||
- **Never reload pyRevit. Restart Revit.** `reload_pyrevit()` tears down the engine that
|
||||
owns the routes handler while the HTTP server still points at it, so the next bridge
|
||||
request runs against a dead engine and takes Revit down — on a non-main thread, where
|
||||
Revit cannot catch it. Measured 2026-08-14: 11 clean requests, one reload, the 12th
|
||||
fatal. It happened twice. Our code never gets to run, so the guard sits outside Revit:
|
||||
`startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load, and `rvt.py`
|
||||
reads that log before *every* request and hard-refuses on two loads under one process id.
|
||||
There is no `/reload` endpoint. If `rvt.py` says the bridge is unsafe, do not work around
|
||||
it and do not curl the endpoint — ask for a restart, which picks up new buttons just as
|
||||
well.
|
||||
- **Never run an unfiltered collector; never guess an API enum.** 2026-08-18: one snippet
|
||||
walked the whole document with `WhereElementIsNotElementType()`, touched `.Symbol` on
|
||||
every result, and set `Options.DetailLevel` from a value reached by `__import__`. Revit
|
||||
died with no exception and no journal entry, because **a bad value handed to a native
|
||||
API is not a Python error**. Scope every collector; import enums by name, so a bad name
|
||||
is a traceback rather than a dead process.
|
||||
- **Transactions for writes; reads never need one** (`with tx('name')`). The production
|
||||
model is read-only to agents: prototype read-only, show counts and a sample, and get an
|
||||
explicit go-ahead before any mutation. **Nothing in the extension writes document data or
|
||||
geometry, on purpose** — a wrong report can be disbelieved, but wrong geometry gets built.
|
||||
The one exception is view state: Panel Lift Check opens a `tx('Show assembly')` to
|
||||
isolate the piece temporarily and put an 85% transparency override on its non-insert
|
||||
members, and writes both back off when it is done.
|
||||
|
||||
### 3.6 How the work is organised
|
||||
|
||||
The owner works through sub-agents. **One dedicated long-lived agent holds the bridge**,
|
||||
and all live-Revit work routes through it. Everything else is split into **non-overlapping
|
||||
file sets** and run in parallel, each landing committed **by explicit path** and pushed.
|
||||
|
||||
**`git add -A` is prohibited**, and it has cost twice. `cb1587d` swept in a
|
||||
**deliberately broken** button that the tooltip agent had planted to prove its guard
|
||||
fires, so HEAD briefly shipped a fake tooltip (repair and rule in `14de0d3`). And
|
||||
`109d7df` — titled *"Today is the 30th, not the 31st"* — swept in the first half of
|
||||
another agent's unfinished up-end work; the follow-up, `cf9e164`, opens by saying so.
|
||||
|
||||
A glob is the same mistake in a different coat: `git add .`, `git commit -a`,
|
||||
`git add lib/` while another agent is mid-edit. **Name the files.**
|
||||
|
||||
### 3.7 Testing
|
||||
|
||||
**Host-testable by design.** The whole suite runs on plain CPython with no Revit and no
|
||||
licence:
|
||||
|
||||
```bash
|
||||
python -m pytest WillTools.extension/tests -q # the extension
|
||||
cd panellift && python -m pytest -q # the solver
|
||||
```
|
||||
|
||||
**Run them; do not quote them.** Two readings on 2026-08-30, hours apart on the same
|
||||
machine: `panellift` 272 passed both times, while the extension went from 1807 passed / 1
|
||||
failed to 1860 passed / 1 failed, because another agent was adding tests in between. That
|
||||
is the normal condition here, and it is why every count in this document is date-stamped
|
||||
or replaced by a command. The one thing that held across both readings is the single
|
||||
failure — a real one, environment-coupled, described in §4.4. Several documents in this
|
||||
tree still quote counts that were true when written and are not now (§4.6).
|
||||
|
||||
`tests/conftest.py` is what makes it possible: it registers a bare module object as
|
||||
`sys.modules['vibe']` with `__path__` pointed at the real package, so `vibe/__init__.py` —
|
||||
which imports the Revit API the moment it runs — never executes. That reaches any module
|
||||
touching Revit only *inside* functions needing a live `doc`, and cannot reach one
|
||||
importing the Revit API at **module scope**. `ticketqc.py` / `ticketchecks.py` are the
|
||||
standing example: `ticketqc` imports eight `Autodesk.Revit.DB` names at the top and
|
||||
`ticketchecks` imports `Finding` from it, so freeing them takes more than moving an
|
||||
import. `test_ticketchecks_host.py` reaches a few pure decisions through a deliberately
|
||||
**inert** stub of those names: if an edit makes one really call the API, the test raises
|
||||
instead of quietly passing.
|
||||
|
||||
**Pin against a written-down golden, not a recomputation.** The takeoff count is pinned to
|
||||
numbers typed into the test, because *a pin that recomputes both sides pins nothing* — it
|
||||
agrees with any refactor, including a wrong one.
|
||||
|
||||
**Tombstone tests** keep a deleted thing deleted. `test_no_cap_survives_anywhere_in_the_module`
|
||||
asserts that `ticketpage` has no `capped_note` and no `NOTE_CAP`: that 180-character cap
|
||||
had truncated a debond run mid-number, in both the panel and the text file.
|
||||
|
||||
### 3.8 Search the transcripts before you ask him
|
||||
|
||||
**A question he has already answered is worse than a question nobody asked.** It costs him
|
||||
time, it tells him nobody looked, and — the real damage — it invites him to answer a second
|
||||
time. Two rulings then sit on the record with nothing to say which governs, and the next
|
||||
agent gets to pick. The rule, in his words, 2026-08-31: *"开发纪律里面再加一条,有问题先搜索
|
||||
聊天记录。搜不到再来问我。"*
|
||||
|
||||
**The incident that bought it (2026-08-31).** An overnight audit put
|
||||
`handling.tension_limit_sqrt_fc` at the top of the owner's list as **A0**, under a heading
|
||||
saying it was *"the only open item that changes a live engineering answer"* — reasoning
|
||||
that he had made the stress limit a per-job choice, that the job layer held nothing, and
|
||||
that the job was therefore running on 7.5 inherited from a layer he never said should
|
||||
decide it. Every one of those facts was true. The conclusion was still wrong: he had ruled
|
||||
the value twelve days earlier, **2026-08-19: *"就用7.5sqrt。"*** The plant layer holds
|
||||
exactly 7.5, the tool had been doing the right thing the whole time, and A0 existed only
|
||||
because nobody searched. Escalating it would have spent his morning re-deciding a decided
|
||||
thing.
|
||||
|
||||
Note what the audit actually did wrong, because it is the subtle half: it found a **later**
|
||||
quote (2026-08-30, *"应力极限写入 projectconfig … 让用户自己填写。I can see it both ways"*)
|
||||
and read a ruling about **where the knob lives** as if it vacated the ruling about **what
|
||||
the number is**. Those are different questions and both answers stand. A mechanism ruling
|
||||
does not retract a value ruling — and a search that returns two hits is not finished until
|
||||
you have worked out whether they even disagree.
|
||||
|
||||
**Searching is cheap. `CLAUDE.md` §7 has the one-liner; the measured cost over the whole
|
||||
65 MB of transcripts is 0.2 seconds.** "It is expensive to look" is not available as a
|
||||
reason, and neither is "I did not know where they were".
|
||||
|
||||
**But a search that finds nothing is not proof he never said it.** The transcripts are one
|
||||
machine's history, they are not indexed, they hold his messages in two languages, and the
|
||||
term he used is very often not the term you searched. So the honest report is
|
||||
***"I searched X, Y and Z and found nothing"* — never *"he has never ruled on this"***.
|
||||
State the terms; the next reader can then extend the search instead of repeating it. This
|
||||
is §3.1's invariant wearing different clothes: not-found is a NOT RUN, not a pass.
|
||||
|
||||
When you do find it, **write it down where the value lives** — beside the number, with the
|
||||
date and his words verbatim — so the next agent finds it by reading rather than by
|
||||
searching. A ruling recorded only in a transcript will be asked again.
|
||||
|
||||
### 3.9 Three tiers: the coordinator decides, Sonnet explores, DSV4F labours
|
||||
|
||||
**The coordinating agent (Fable / Opus class) is the most expensive process on this
|
||||
machine, and it spends its budget on four things only: planning and splitting work, judging
|
||||
results, deciding anything irreversible or anything that touches live Revit, and talking to
|
||||
the owner.** Below it sit two cheaper tiers, and they are not interchangeable:
|
||||
|
||||
- **Exploration — reading a codebase to answer "how does this work / what would it take" —
|
||||
goes to a Sonnet sub-agent** (the harness's `Agent` tool with `model: sonnet`; or Opus
|
||||
driving Sonnet), never to DSV4F. Exploration needs judgement about what matters, and the
|
||||
owner's ruling is that DSV4F does not get to make it. Sonnet returns a note with
|
||||
`file:line` citations; the coordinator reads the note, not the files, and goes to the
|
||||
source only for the fact a decision hinges on.
|
||||
- **Labour — extracting tables from documents, mining PDFs into notes and fixtures,
|
||||
translating, classifying, reformatting, and writing code to a settled, pre-split spec —
|
||||
goes to DSV4F (DeepSeek V4 Flash)**, with V4 Pro only where Flash has demonstrably failed
|
||||
on the same material. The owner, 2026-09-05: *"探索你带着sub sonnet或者让opus带sonnet去做。
|
||||
DSV4F别做探索。DSV4F就干点儿体力活儿"*.
|
||||
|
||||
**The incident that bought it (2026-09-05).** `CLAUDE.md` carried *EXPLORE (me) → WRITE
|
||||
(DSV4F) → REVIEW (me)* with the sentence *"exploration never goes to DSV4F"*, and no rule
|
||||
said the exploring had to be cheap. Under that, the coordinator spent an afternoon on
|
||||
`docs/pa-web-spec.md`, `tools/pa_web/static/app.js`, `draw.js` and every key
|
||||
`beamcalc/model.py` reads from a job file — twelve consecutive file reads on the most
|
||||
expensive model, to produce a note a Sonnet sub-agent writes as well. The owner's words:
|
||||
*"用DSV4F。这些东西开发纪律里面没有?"* — and until that day this section did not exist. The
|
||||
first draft of the fix then over-corrected and dispatched that same reconnaissance to DSV4F;
|
||||
he stopped it within minutes with the ruling quoted above. Both halves are the rule: the
|
||||
coordinator does not do the reading, **and** the reading is Sonnet's, not Flash's.
|
||||
|
||||
**Which DSV4F route.** Two exist and they do not share a balance. `cheap` (in `PATH`) is
|
||||
the DeepSeek platform direct; on 2026-09-05 that balance was **negative** (`402 Insufficient
|
||||
Balance`) after the `sample_job` document mining ran V4 Pro at 65k output tokens with parallel retries, and
|
||||
every `cheap` call and mining script died with it. **Command Code (`cmd -p "<brief>" --model
|
||||
deepseek/deepseek-v4-flash --yolo --no-session`) is a separate account with its own quota**,
|
||||
and the owner's instruction that day was to use it. Check the balance before dispatching a
|
||||
batch, not after it has failed; a 402 on the first call means every call.
|
||||
|
||||
**Detach anything that outlives a turn.** The coordinator runs as a systemd service under a
|
||||
watchdog that restarts it — three times on 2026-09-05: 13:11 and 14:11 for a mis-detected
|
||||
gateway, 15:09 for a stall. A service restart kills the whole cgroup, and each time it took
|
||||
the background workers with it: seven `cmd` mining jobs launched from the coordinator's
|
||||
shell, four dead mid-write, output files 0 bytes or absent. A worker launched with
|
||||
**`systemd-run --user --unit <name> …`** is its own transient unit and survives; one
|
||||
launched with `&`, `nohup` or `setsid` from the coordinator's shell does not. Write the
|
||||
worker's result to a file and its status to a log (§3.1 applies: an empty log is a NOT RUN,
|
||||
not a pass), so a coordinator that has lost its memory can read what finished instead of
|
||||
asking.
|
||||
|
||||
**Split before you send, and size to one reply.** A whole-feature brief truncates at
|
||||
max-tokens and reads as "it skipped the hard parts"; one function, one section, one file's
|
||||
change per task (`CLAUDE.md` § delegation, owner 2026-09-03: *"确认了边界的活儿,拆细了再让
|
||||
DS 干"*). The gates — pytest, scrub, review — run on the coordinator's side, never inside the
|
||||
worker.
|
||||
|
||||
---
|
||||
|
||||
## 4. Where it stands
|
||||
|
||||
Written 2026-08-30 against `ff10870`. The working tree was clean when this survey was
|
||||
taken; it is edited concurrently by several agents, so check `git status` yourself rather
|
||||
than trusting that. `docs/checklist-code-map.md` was rebuilt 08-30 17:19 and commits have
|
||||
landed since.
|
||||
|
||||
### 4.1 The checklist, row by row
|
||||
|
||||
Authority is `CHECKLIST_ITEMS` in `lib/vibe/ticketpage.py`.
|
||||
|
||||
| # | grade | state | note |
|
||||
| --- | --- | --- | --- |
|
||||
| 1.1 sheet name = mark | A | running | no skip probe (§4.4) |
|
||||
| 1.2 sheet design reduces to piece design | A | running | judged in `ticketqc.check_ticket`; no named check fn |
|
||||
| 1.3 one ticket set per piece | A | running | no skip probe |
|
||||
| 1.4 view names refer to this design | B | running; **dormant out of the box** | needs `nomenclature.design_number_re` — plant layer, not `DEFAULTS`. Re-graded A→B 08-30 |
|
||||
| 1.5 call-outs do not overlap | C | running, built 08-30 | rotated annotations set aside under `CALLOUT-OVERLAP-UNREAD`; no sample, untested |
|
||||
| 2.1 printed size vs modelled | B | running | |
|
||||
| 2.2 weight three ways | A | running | the *measured* leg needs run option `expensive_geometry` (default ON); off, the row is NOT RUN naming it |
|
||||
| 2.3 unit weight | A | running | |
|
||||
| 2.4 cast extras in the weight | A | running, built 08-30 | verified end-to-end on a real ticket |
|
||||
| 3.1 f'c · 3.2 f'ci | A | running | |
|
||||
| 3.3 release ratio | B | **muted on this job**, still runs | owner's own dated sentence: no panel here has the thing it asks about |
|
||||
| 4.1 strand count · 4.2 diameter & grade | B/A | running | |
|
||||
| 4.3 jacking force | A | running | `tolerances.it_lb` wired 08-30 to **100 lb**; the job layer's `1.0` deleted, not corrected |
|
||||
| 4.4 strand positions vs chain from rail | B | running | stands down with a reason where there is no strand geometry |
|
||||
| 4.5 debond call-out | C | running, built 08-30 | consumes 4.7; where 4.7 could not judge, 4.5 is NOT RUN with 4.7's reason |
|
||||
| 4.6 rows & spacing | A | running | same check as 4.4 |
|
||||
| 4.7 strand debonded where concrete runs short | B | running | |
|
||||
| 5.1 BED PLAN call-out embed exists | A | **half running by construction** | `check_bom_members` runs; `check_detail_callouts` reports NOT RUN *naming the sheets nobody read* wherever the plan cites a bubble. Only a plan citing no detail can be OK |
|
||||
| 5.2 embed in model appears on ticket | A | running | |
|
||||
| 5.3 face designations vs model side | C | **FOR ENGINEER by design** | printed a red NOT RUN on every ticket until 08-30 — missing from a hand-kept tuple (§3.1) |
|
||||
| 5.4 mesh / WWF designation | A | **muted on this job**, still runs | no mesh on this job |
|
||||
| 5.5 bar marks legible | D | **FOR ENGINEER by design** | a property of the picture |
|
||||
| 5.6 bar marks unique | B | running, built 08-30 | split out of the old 5.5; runs where the reinforcing rule resolves (§4.3) |
|
||||
| 5.7 no bar longer than its stock | B | running, **against one generic number** | the per-size stock table is built, populated and unreachable (§4.3) |
|
||||
| 6.1 insert capacity · 6.2 edge distance | A | running | no skip probe |
|
||||
| 6.3 handling result | A | running | no skip probe; three silent `return []` paths |
|
||||
| 6.4 dunnage not under an opening | A | running, probe added 08-30 | open ruling on the all-blind-embed path: NOT RUN (today) or OK |
|
||||
| 6.5 note views say something | B | running; **dormant out of the box** | needs `notes.instruction_view_sets`; `DEFAULTS` ships it empty |
|
||||
| 7.1 interference | B | running | WARN-only by ruling, over 157 findings on 57 archived tickets. Do not re-infer that the rest are noise — that was overruled once |
|
||||
| 8.1 unresolved design-summary lines | C | running | the one true FAIL of the 08-30 live run ("no probe declared"); probe added |
|
||||
| 8.2 no invisible characters | A | running | no skip probe |
|
||||
|
||||
Nothing on the list is unbuilt. Two rows are a person's job on purpose, two are muted with
|
||||
a written dated reason, the rest compute. What varies is how much of its question each row
|
||||
actually reaches — **5.1 and 5.7 claim least**.
|
||||
|
||||
**The table above is the 34-row census this section was written against and is now stale
|
||||
by count** — `checklist.master` in `knowledge/catalogue.yaml` carries **46** entries as of
|
||||
2026-08-31 (verified by reading `len(ticketpage.CHECKLIST_ITEMS)` on a fresh load; run it
|
||||
rather than trusting this number later). The rows above through 8.2 are still real; twelve
|
||||
more (`4.8`, `5.8`–`5.15`, `6.6`–`6.8`) have landed in the master since and are not
|
||||
individually described here. Extend the table from the catalogue, not from memory of it.
|
||||
|
||||
### 4.2 The other tools
|
||||
|
||||
| tool | state |
|
||||
| --- | --- |
|
||||
| **Panel Lift Check** | Finished, in daily use, no licence needed to test. Numerics open (§4.4). The per-stage input control aligned to the reference tool's `HandlingData` is a **mapping document only** (`docs/panellift-ewf-alignment.md`); nothing is built from it |
|
||||
| **Reinforcing Suggestion** | Finished. End-to-end 08-30: cold 10 s, warm ~1 s, offline byte-identical to the bridge. Position recommendation defaults **off**; one invariant test still owed |
|
||||
| **Ticket QC** | Finished, the flagship. Six in-process `vibe/ticket*.py` modules — `wc -l WillTools.extension/lib/vibe/ticket*.py`, and expect the number to have moved since you last looked; it is what §4.5 is about |
|
||||
| **Snapshot & Close** | Finished. Phases A and B measured green; phase C (the deferred detached copy) is off unless the job layer enables it, and journals its own silence |
|
||||
| **Takeoff** | Finished. `nomenclature.ticket_unit` wired 08-30, so the tool stops deciding for itself what one ticket covers |
|
||||
| **Job Recon** | Finished. Self-loading, scope-aware, 9.3 s on the real job |
|
||||
| **Design Extract** | Finished, with its caveat printed on it: every value it writes is machine-read and unverified |
|
||||
| **Self Check** | Finished |
|
||||
| **Gravity (WIP)** | Neither this nor Lateral writes to the Revit model — both are extractors: read the model, write a file elsewhere. Gravity is meant to extract information and write **Excel**. Today it does none of that: **a button with nothing behind it, and it says so.** ~200 lines of placeholder printing the open questions and assuming nothing. Blocked on the owner supplying old jobs and a control spreadsheet |
|
||||
| **Lateral (WIP)** | Meant to extract lateral-resisting members and write **RISA** — a structural analysis package — its input format. Half-built, and it does real work: it extracts marked members and writes a JSON intermediate model, two CSVs, and a `.r3d.DRAFT.txt` (`.r3d` is RISA-3D's own file extension) — the `.DRAFT` suffix deliberate, so it cannot be loaded into RISA by accident. What the RISA format needs beyond that is not established |
|
||||
|
||||
### 4.3 Dormant, and what would wake it
|
||||
|
||||
| dormant | what wakes it | who |
|
||||
| --- | --- | --- |
|
||||
| `drawings.reinforcing_rule.scopes` — empty, so 5.6 and 5.7 see one population | a ruling on three edge cases: bar-formed hardware carrying the token (one is a strand loop, with neither mark nor stock length), and reinforcement carrying none | owner, A1 |
|
||||
| `catalogue.rebar.stock_by_size` — populated, read by `knowledge.rebar_stock()`, **never called by `check_bar_stock_length`** | which parameter carries a bar's size | owner, A3 |
|
||||
| 5.3 as a computed check | what makes an elevation line dashed — line style, subcategory, or nested detail items. The view's 17 filters were read; none carries it | owner, A5 |
|
||||
| the up-end rule | woken 08-30 on evidence over 282 assemblies. Still open: the vertical-panel case, and the colour→axis mapping, which is inference not measurement | owner, A4 |
|
||||
| `tolerances.psi` (inert, muted not deleted) and `details`, the 70-entry job catalogue nothing reads | one word each: wire or delete | owner, D1 / D7 |
|
||||
| `drawings.rebar_must_appear_on` / `section_a` / `section_b` | a dated promise queued behind the current phase — not drift | queued |
|
||||
| `check_detail_callouts` as a verdict | something that reads a detail sheet's *contents*. Not built; needs the model | build |
|
||||
| vision escalation (`AI-` codes) | off by default; owns no checklist row and cannot flip one (§3.1) | config |
|
||||
|
||||
### 4.4 Known defects and unfinished business
|
||||
|
||||
- **`qc-run.json` carries findings, not the run log.** `ticketvision.qc_json` takes
|
||||
`findings` + `model_read` and nothing else — checklist rows, mutes and `run_all`'s log
|
||||
stay behind. A NOT RUN row's *reason* reaches the HTML page and `last-ticketqc.txt` but
|
||||
never the machine-readable file, so anything reading the JSON cannot tell a row that
|
||||
passed from a row nobody asked.
|
||||
- **A failing test, environment-coupled.** `test_reinforceoffline.py`'s no-index case fails
|
||||
here because `cli.resolve_index` finds the real, gitignored `knowledge/job/` and answers
|
||||
with a job list instead of the three-ways message. Green on a clean checkout, red on the
|
||||
owner's machine.
|
||||
- **Closed, 2026-08-31: every registered check now has a skip probe, and it is
|
||||
test-enforced rather than a hand count that can silently rot again.**
|
||||
`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` walks
|
||||
`ticketchecks.CHECKS` against `_SKIP_PROBES` and fails, naming the orphan, the day a new
|
||||
check ships without one. A silent `return []` from an unprobed check still logs
|
||||
RAN-EMPTY and prints a red NOT RUN reading "no probe declared" — the mechanism is
|
||||
unchanged — but the gap this bullet used to track (eight bare entries, then seven) is
|
||||
gone; run the test rather than trusting a count copied in here.
|
||||
- **Client and plant names are in pushed history** (§3.4). **Untaken decision:** redact the
|
||||
working tree only, or rewrite history and force-push.
|
||||
- **The lifting solver's numerics.** `docs/lift-check.md`, written up, unstarted. Not a
|
||||
speed question: today's recommendations are not determined to better than several inches
|
||||
and on some pieces are not the best layout available. It gates any further
|
||||
insert-placement work.
|
||||
- **`families.wwf_prefix` has two defaults** — `takeoffgather` falls back to empty (no mesh
|
||||
detection), `ticketqc` to `'WWF'`. Reachable only when a layer sets the key null, which is
|
||||
a plant saying it has no welded wire fabric. If that is what null means, `ticketqc` is
|
||||
wrong.
|
||||
- **Smaller, recorded:** `read_ticket` costs 37.6 s on a large piece, entirely outside the
|
||||
checking wall and with no code-level cure; and `catalogue.rebar.stock_exempt_marks` is
|
||||
empty pending the 60-ft continuous-bar ruling.
|
||||
|
||||
### 4.5 The roadmap, as it actually exists
|
||||
|
||||
**`docs/architecture-migration.md` — nine steps, approved 08-30.** In-process IronPython
|
||||
keeps only the gatherers; judgement, matching and rendering move to host CPython, fed by
|
||||
the versioned JSON of §2.2.
|
||||
|
||||
*Step 1 landed 08-31*: Ticket QC writes `evidence/run-<stamp>.json` on every press and
|
||||
**nothing reads it**. As built it corrected its own plan — four live-object holdouts, not
|
||||
two, so the serialiser gates on type. **§5.1, parity on real data, is not met and could
|
||||
not be**: it needs a press, and the bridge was held.
|
||||
|
||||
*Step 2* buys a parity proof and nothing else: a host runner judging the 13 Revit-free
|
||||
checks offline against the twelve-piece audit set. Cost M, no visible change. *Step 3* is
|
||||
where it pays — the button calls that runner (reusing `liftexport.find_python`/`run`),
|
||||
making it the first press where a `ticketchecks` judgement edit is visible **without a
|
||||
Revit restart**. Cost S.
|
||||
|
||||
**§8.2, honestly:** `engine: clean` is landed on three buttons and buys nothing today. A
|
||||
clean engine is already the default here, because `extension.json` declares no `url`, so
|
||||
pyRevit's package lookup drops the extension and rocket mode never takes the clean engine
|
||||
away — verified two ways offline. It is a **pin, not a lever**, against the day somebody
|
||||
adds a `url`. **The one-minute behavioural check — press, edit a rendered string, press
|
||||
again without restarting — has still never been run**, and no bridge call answers it.
|
||||
|
||||
**`QNA/eriksson-gap-inventory.md` — the commercial-parity list.** 46 quantities: 10 at
|
||||
parity, 2 needing no build, **34 to build** (17 S · 5 S-after-dependency · 5 M · the rest
|
||||
larger). **Mcr is the cheapest first win** — S, P, A and e are held at every station and
|
||||
`modulus_of_rupture_psi` is written. The load-bearing finding: the reference tool is a **1D
|
||||
single-member analysis, not 2D FEA** — every plot is a function of x, with no mesh
|
||||
reference and no Mxy/Wood-Armer column in 18 pages. That reframes tier B entirely. The list
|
||||
needs the owner's tick before anything on it is built.
|
||||
|
||||
Beyond those two, the only genuine plans are the reinforcing-suggestion brief's open
|
||||
granularity questions and §4.3's queued coverage rule. The rest of `QNA/` is record, not
|
||||
plan.
|
||||
|
||||
### 4.6 Where the docs and the code disagree
|
||||
|
||||
Several documents were accurate when written. Where they are not, the code wins.
|
||||
|
||||
- **Every test count quoted in this tree is stale.** `CLAUDE.md` says `panellift` has 76
|
||||
tests and the extension suite is `49 passed`; `README.md` says `34 passed`. Run the
|
||||
commands in §3.7.
|
||||
- **Every line count quoted in this tree is stale, recent ones included.**
|
||||
`architecture-migration.md` §3 warns it will drift and has drifted again; on 2026-08-30
|
||||
`ticketqc.py` moved between two measurements minutes apart, because other agents were
|
||||
editing. Use `wc -l`, and read any line count as a date-stamp rather than a fact. §3.3's
|
||||
"26 registered checks" is now 27. **Verdicts hold; numbers do not.**
|
||||
- **Design Extract calls no AI**, despite `README.md` ("hands the scrape to Claude Code")
|
||||
and `CLAUDE.md`. The script says "no AI call, no subprocess", and no button routes
|
||||
through `vibe/aiextract.py` any more.
|
||||
- **`configdump.py` and `CLAUDE.md` say four knowledge layers**; `layer_values()` handles
|
||||
five, with config behind `include_config=False`.
|
||||
- **`knowledge.load()`'s docstring drops a folder** — it gives the job layer as
|
||||
`<model folder>/knowledge.yaml`, where `designspec.knowledge_path()` returns
|
||||
`<model folder>/_ticketqc/knowledge.yaml`.
|
||||
- **`docs/handoff.md` calls Panel Lift Check "the one under development"**; it is one of
|
||||
the four finished large buttons, and the same file's "Never migrate" section calls it
|
||||
*already migrated*.
|
||||
- **Batch speeds disagree across three files** — `panellift/cli.py` 41 min / 40 s,
|
||||
`docs/lift-check.md` ~40 min / 2 min, `PanelLiftCheck/script.py` 38 min / 2.5 min. The
|
||||
last is the most specific and names its path.
|
||||
- **`rails.left_rail_is_fixed` is a dead flag** (`docs/knowledge-override.md` says so
|
||||
correctly): one reader, `aiextract`, which puts it in a prompt. No check consumes it.
|
||||
@@ -0,0 +1,52 @@
|
||||
# QNA — 有啥不明白的,写在这儿
|
||||
|
||||
这个文件夹只有一个用途:**你随手记下没搞懂的问题,我看到了就回答。**
|
||||
|
||||
不用等到我在旁边。想到了就写,反正记下来的问题总比忘掉的强。
|
||||
|
||||
---
|
||||
|
||||
## 怎么问
|
||||
|
||||
打开 `questions.md`,在最下面加一段:
|
||||
|
||||
```markdown
|
||||
## Q: transfer length 那个 33.2 in 是怎么来的?
|
||||
```
|
||||
|
||||
`## Q:` 开头,一行说清楚就行。要贴代码、贴报错、贴一段 job JSON 都可以,
|
||||
跟在下面就是了。**不用管格式,不用管排版。**
|
||||
|
||||
写完存盘。就这样,没有别的步骤。
|
||||
|
||||
## 怎么得到回答
|
||||
|
||||
我会在问题下面直接补一段:
|
||||
|
||||
```markdown
|
||||
**A**(2026-08-22):ACI 318 的 ℓt = f_se·db/3 ……
|
||||
```
|
||||
|
||||
答案就跟在问题下面,一个文件从上往下读就是一份记录 —— 不用在两个文件之间来回翻。
|
||||
|
||||
---
|
||||
|
||||
## 说清楚一件事:我不是 24 小时在线
|
||||
|
||||
盯这个文件夹的是 `tools/qna_watch.py`:它是当前 agent 会话里的一个后台任务,
|
||||
一有改动就退出并叫醒我去读、去做,做完再重新布防。**只有这个会话开着的时候才有效**。
|
||||
终端关了、电脑重启了,就没人看了 —— 那之后写进来的问题会一直躺在
|
||||
`questions.md` 里,等下次会话开始时 watcher 一启动就会把积压的改动先报出来,
|
||||
我读一遍,照样会答,只是没那么快。
|
||||
|
||||
所以:**积压是正常的,丢掉是不会的。**
|
||||
下次开工的时候直接跟我说一句「看下 QNA」,我就把没答的都补上。
|
||||
|
||||
## 别在这儿写客户名和项目名
|
||||
|
||||
跟仓库里别的地方一个规矩(见 `CLAUDE.md` 第 3 节):不写业主名、不写工程名、
|
||||
不写模型文件名。产品编号不算 —— design number、piece mark、五金件料号都可以照写。
|
||||
|
||||
保险起见,`questions.md` 本身是 gitignore 掉的,不会被 push 上去。
|
||||
只有这份 README 进仓库,因为它是约定,不是内容。
|
||||
哪天你想把问答记录也存进版本库,把 `.gitignore` 里那两行删掉就行。
|
||||
@@ -0,0 +1,173 @@
|
||||
# WillTools
|
||||
|
||||
Precast engineering tooling for Revit: shop-ticket QC against the design
|
||||
summary, and PCI Chapter 8 handling calculations. Built for one precast
|
||||
plant's workflow and shared here so a colleague can try it against their own.
|
||||
|
||||
Two halves, deliberately kept apart. `WillTools.extension` runs inside
|
||||
Revit, on the model. `panellift` is plain CPython outside Revit — the same
|
||||
handling arithmetic, testable in seconds with no Revit licence at all.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/will123song/pyrevit-dev.git
|
||||
cd pyrevit-dev
|
||||
python tools/setup_machine.py
|
||||
```
|
||||
|
||||
That installs the host-side Python dependencies and proves the install with
|
||||
real answers — not just exit codes: it runs `panellift` against a published
|
||||
worked example and checks the arithmetic against the number the article
|
||||
prints. Read [INSTALL.md](INSTALL.md) for what to do with the output, and
|
||||
for pointing pyRevit at the extension so the ribbon shows up in Revit.
|
||||
|
||||
Try the handling calculator immediately, no Revit needed:
|
||||
|
||||
```bash
|
||||
python -m panellift examples/example-panel.job.json -o report.html
|
||||
```
|
||||
|
||||
## What works without knowledge layers, and what stands down
|
||||
|
||||
This distribution ships **no knowledge** — not a plant's parameter names,
|
||||
not its casting-bed conventions, not even the published hardware catalogue.
|
||||
See [`knowledge/README.md`](knowledge/README.md) for why and what the shape
|
||||
of a real layer looks like.
|
||||
|
||||
Runs fully, with no knowledge at all:
|
||||
|
||||
- `panellift`'s handling checks against a job file — the example above, or
|
||||
one you build by hand.
|
||||
- The ribbon itself, and **Self Check**, which tells you exactly what is and
|
||||
is not configured on this machine.
|
||||
- `python tools/setup_machine.py --check` — reports the no-knowledge state
|
||||
as a **pass**, not a failure. A knowledge-less trial install is an
|
||||
expected, first-class state here, not a broken one.
|
||||
|
||||
Stands down cleanly, with a stated reason rather than a wrong or silent
|
||||
answer: anything that needs a plant's parameter mapping, the published
|
||||
hardware catalogue, or the master shop-ticket checklist — most visibly,
|
||||
**Ticket QC**, which refuses to open with a one-line reason rather than
|
||||
rendering a report that looks like every ticket passed. Ask the owner for
|
||||
real knowledge layers once you are ready to point this at an actual job.
|
||||
|
||||
## `WillTools.extension` — inside Revit
|
||||
|
||||
A pyRevit extension. Runs on IronPython 2.7 on Revit's UI thread, so it does
|
||||
the things that need the model and nothing that needs testing.
|
||||
|
||||
One tab, **WillTools**, four panels. Reorganised 2026-09-05: the modelling
|
||||
and ticketing tools now sit behind **Tools**' `Modeling` pulldown, and the
|
||||
analysis tools — finished and unfinished alike — sit behind **Design**'s
|
||||
`DesignHub` pulldown.
|
||||
|
||||
| Panel | Button | |
|
||||
| --- | --- | --- |
|
||||
| Tools | **Modeling** ▾ | A pulldown. The order is the order of the work: suggest the reinforcing, check it once it is modelled, check it once it is ticketed. |
|
||||
| | ↳ Reinforce Suggestion | Suggests reinforcing for a design from what comparable designs already carry. |
|
||||
| | ↳ Reinforcing QC | A cut-down Ticket QC for a panel whose reinforcing is finished but whose ticketing has not started — only the checks whose evidence is the panel's modelled content run. |
|
||||
| | ↳ Ticket QC | Checks every shop ticket of a design against its spec — f'c, f'ci, I.T., strand count/grade/diameter, W.W.F., unit weight, pattern legend, size envelope. Needs a knowledge layer; see above. |
|
||||
| | **Snapshot** | Takes a timestamped detached copy of the model at close-out. |
|
||||
| Setup | **Config** ▾ | A pulldown. The commands you run to get a job ready or to look at it sideways, rather than the ones you press all day. |
|
||||
| | ↳ Project Config | Writes the project config layer: every setting that is the engineer's to make, in one file, wherever they want it. |
|
||||
| | ↳ Design Extract | Scrapes a design packet — every sheet whose number reduces to the design, and every view placed on them — reads it with the regex/geometry parser in-process, writes a reviewable `spec.json`. No AI call, no subprocess, nothing that leaves Revit. Run once per design. |
|
||||
| | ↳ Self Check | Probes everything the tools assume about this machine, this Revit and this model. Run it first on a new computer. |
|
||||
| | ↳ Job Recon | Reconciles the issued ticket set against the whole job — the question no per-ticket check can answer, because a ticket that is not there cannot be checked. |
|
||||
| | ↳ Takeoff | What the job contains, per piece and in total: hardware by part number, reinforcing by bar mark. |
|
||||
| Design | **DesignHub** ▾ | A pulldown. PanelLiftCheck and PanelAnalysis are finished and grouped here with the other analysis tools; the four rows after them are buttons-and-docs only and carry `(WIP)`. |
|
||||
| | ↳ Panel Lift Check | PCI Chapter 8 handling check on the piece, from the insert positions the model already holds. |
|
||||
| | ↳ Panel Analysis (WIP) | Opens an input-confirmation form, pre-filled from the model, feeding a Stage-0 self-weight solve; the full in-place/wind/service engine is not built yet. |
|
||||
| | ↳ Beam Analysis (WIP) | Placeholder; nothing is implemented. |
|
||||
| | ↳ Connection Analysis (WIP) | Placeholder; nothing is implemented. |
|
||||
| | ↳ Gravity (WIP) | Gravity load chasing. Unfinished; today the button writes nothing at all. |
|
||||
| | ↳ Lateral (WIP) | Lateral extraction. Unfinished; today it writes a JSON model, two CSVs, and a RISA draft that is deliberately not a real RISA input yet. |
|
||||
| Lab | **GOD_MOD** | Opens Claude Code in a terminal on this repository, wired to the running Revit through a local bridge. Optional — every engineering button runs without it. |
|
||||
|
||||
Anything half-built lives behind the **Design** panel's pulldown and carries
|
||||
`(WIP)` in its name, so nothing unfinished ever sits beside a tool that
|
||||
works on **Tools** or **Setup** — `DesignHub` itself is the one pulldown
|
||||
that mixes finished and unfinished rows, because the owner wants the
|
||||
analysis tools grouped together rather than split across panels.
|
||||
|
||||
The design intent lands on disk as JSON that a human reads and corrects before
|
||||
anything trusts it. Repeated checking is then pure arithmetic — no model, no
|
||||
judgement, same answer every time.
|
||||
|
||||
See [INSTALL.md](INSTALL.md) to deploy it, including on a different Revit
|
||||
version.
|
||||
|
||||
## `panellift` — outside Revit
|
||||
|
||||
PCI Design Handbook Chapter 8 handling checks: stripping and yard handling of
|
||||
flat panels, from the insert positions the model already holds.
|
||||
|
||||
Plain CPython, **no dependencies**, and no Revit import anywhere. That is the
|
||||
point — a calculation whose output reaches a plant has to be verifiable, and
|
||||
verifiable means a test suite that runs in seconds without a licence.
|
||||
|
||||
It reproduces every published number in the ASPIRE Winter 2019 worked examples
|
||||
(which reprint PCI Table 8.3.1 and Figure 8.3.2), and it *derives* PCI's
|
||||
0.0214 moment coefficient from the beam solution rather than quoting it:
|
||||
|
||||
```bash
|
||||
cd panellift && python -m pytest
|
||||
```
|
||||
|
||||
**Run it rather than trusting a number printed here** — a suite grows every
|
||||
day and a README does not.
|
||||
|
||||
See [panellift/PLAN.md](panellift/PLAN.md) for what is built, what is next,
|
||||
and the idealisations that are known to be idealisations.
|
||||
|
||||
## Filing feedback
|
||||
|
||||
Please do — that is what this trial is for. Two templates when you open an
|
||||
issue: **Bug report** for something that crashed or gave a wrong answer,
|
||||
**Question** for something that was unclear.
|
||||
|
||||
One thing worth knowing before you file: **this tool refuses rather than
|
||||
guesses, by design.** A row that reads "NOT JUDGED" with a stated reason —
|
||||
an unmapped parameter, a missing knowledge layer, a check that legitimately
|
||||
has nothing to say about this piece — is very often the tool working
|
||||
*correctly*, not a bug. The bug report template asks you to tell these two
|
||||
apart:
|
||||
|
||||
- **Wrong answer** — it computed something, and the something is wrong, or
|
||||
it crashed. That is a code bug; file the Bug report template, and paste
|
||||
`python tools/setup_machine.py --check`'s full output — it is the single
|
||||
most diagnostic thing you can attach.
|
||||
- **Refused, and I think it shouldn't have** — it declined to answer and you
|
||||
believe it had enough information to. That is usually knowledge-layer
|
||||
work (a parameter role that needs mapping, a rating that needs
|
||||
publishing) rather than a code fix — file it as a Question, and say what
|
||||
you expected it to know.
|
||||
|
||||
**No client or job data in an issue, a PR, or a screenshot — ever.** See
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) for the placeholder convention this
|
||||
project uses everywhere instead.
|
||||
|
||||
### What to ask the owner for
|
||||
|
||||
This distribution ships no knowledge on purpose. To point it at a real job
|
||||
you will want, from the owner directly (not filed as an issue — these are
|
||||
not code, and they identify a real plant):
|
||||
|
||||
- a **plant knowledge layer** — your office's parameter names, casting-bed
|
||||
conventions, and rigging defaults;
|
||||
- the **published hardware catalogue** — manufacturer ratings for lifting
|
||||
inserts and connectors, plus the master shop-ticket checklist Ticket QC
|
||||
reads;
|
||||
- sample decks or a walkthrough, if you want a guided first run rather than
|
||||
starting from the example in `examples/`.
|
||||
|
||||
## Working on this
|
||||
|
||||
- **Never use pyRevit → Reload. Restart Revit.** A reload leaves the routes
|
||||
handler pointing at a dead engine and the next bridge request kills Revit.
|
||||
- IronPython 2.7 syntax under `WillTools.extension/`; modern Python is fine in
|
||||
`tools/` and `panellift/`.
|
||||
- Any change to a model needs a Transaction. Internal units are decimal feet.
|
||||
- Scope every `FilteredElementCollector`. An unfiltered one crashed Revit.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Beam Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
|
||||
# owner's own instruction, alongside ConnectionAnalysis; nothing behind this
|
||||
# button is built yet, see script.py.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Beam Analysis -- PLACEHOLDER. Nothing is implemented.
|
||||
#
|
||||
# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own
|
||||
# instruction to put a button here for it. Same posture as GravityLoadChase
|
||||
# and LateralExtract: a row that opens is still a row somebody clicks, so it
|
||||
# says plainly that nothing is built rather than staying silent about it.
|
||||
#
|
||||
# No scope has been settled -- not what a "beam" is in this model's terms,
|
||||
# not which code governs, not read-only or not. None of that is guessed
|
||||
# here; this button exists so there is somewhere to build it once it is.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Beam Analysis (WIP)'
|
||||
|
||||
from pyrevit import forms
|
||||
|
||||
forms.alert(
|
||||
'Beam Analysis is not built yet.\n\n'
|
||||
'This row exists so there is somewhere on the ribbon for it. Nothing '
|
||||
'has been read from the model and nothing has been calculated.',
|
||||
title='Beam Analysis', exitscript=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Connection Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
|
||||
# owner's own instruction, alongside BeamAnalysis; nothing behind this
|
||||
# button is built yet, see script.py.
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Connection Analysis -- PLACEHOLDER. Nothing is implemented.
|
||||
#
|
||||
# Added 2026-09-05 alongside BeamAnalysis, on the owner's own instruction to
|
||||
# put a button here for it. Same posture as GravityLoadChase and
|
||||
# LateralExtract: a row that opens is still a row somebody clicks, so it
|
||||
# says plainly that nothing is built rather than staying silent about it.
|
||||
#
|
||||
# No scope has been settled -- not which connections, not which code
|
||||
# governs, not read-only or not. None of that is guessed here; this button
|
||||
# exists so there is somewhere to build it once it is.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Connection Analysis (WIP)'
|
||||
|
||||
from pyrevit import forms
|
||||
|
||||
forms.alert(
|
||||
'Connection Analysis is not built yet.\n\n'
|
||||
'This row exists so there is somewhere on the ribbon for it. Nothing '
|
||||
'has been read from the model and nothing has been calculated.',
|
||||
title='Connection Analysis', exitscript=True)
|
||||
@@ -0,0 +1,14 @@
|
||||
title: Gravity (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration. This tool is buttons-and-docs only until the owner
|
||||
# provides worked examples; the suffix is what stops it being mistaken for a
|
||||
# finished tool by anyone who opens the DesignHub dropdown. It sits on the
|
||||
# title rather than the second line now because a pulldown row has no second
|
||||
# line.
|
||||
|
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} — 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}** — 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 — 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 — 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** — its own '
|
||||
'engineering core, its own report, its own tests — 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 — '
|
||||
'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 — 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 — 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 — 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 '
|
||||
'— reproducing your number to within what tolerance, on which '
|
||||
'quantities?\n'
|
||||
'- **Whether the spreadsheets are machine-readable** — 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** — 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 — 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 — 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.
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,351 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Extract the lateral-resisting members and write an analysis input file.
|
||||
#
|
||||
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
|
||||
# when somebody has typed the marker into its identity description -- by
|
||||
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
|
||||
# Nothing here infers it from geometry, category or name.
|
||||
#
|
||||
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
|
||||
# the output files go beside the model, never into the repository.
|
||||
#
|
||||
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
|
||||
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
|
||||
# loaded by accident. See vibe.latrisa for what is established about the RISA
|
||||
# format and what is not.
|
||||
#
|
||||
# The selection idiom follows Ticket QC and Panel Lift Check: what is
|
||||
# preselected is what is checked; nothing preselected scans the configured
|
||||
# categories across the model. The difference from both is that this tool
|
||||
# never asks the user to pick pieces from a list -- the marker IS the pick,
|
||||
# and offering a second way to choose would let the two disagree.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Lateral (WIP)'
|
||||
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, knowledge, latextract, latmodel, latrisa
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Lateral Extract'
|
||||
|
||||
|
||||
def die(message):
|
||||
"""The user has not given us something yet. Not a refusal to extract."""
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
def not_run(message):
|
||||
"""'Could not judge' -- a RESULT, and it must survive on the report.
|
||||
|
||||
The distinction this whole tool turns on: NOT RUN is not the same as
|
||||
'ran and found nothing marked'. NOT RUN means the question could not be
|
||||
asked at all, so the absence of marked members says nothing. A run that
|
||||
asked and found nothing gets the ordinary report, with a nothing-found
|
||||
banner and no files written.
|
||||
"""
|
||||
output.print_md('# Lateral Extract — NOT RUN')
|
||||
output.print_md('**NOT RUN** — the extraction did not run.')
|
||||
output.print_md('> {0}'.format(message))
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run',
|
||||
report_text=message)
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# -------------------------------------------------------------- context
|
||||
know = knowledge.load(doc)
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused', report_text=problem)
|
||||
forms.alert(problem, title=TITLE, exitscript=True)
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
scope = ('the {0} element(s) you selected'.format(len(selection))
|
||||
if selection else 'the whole model, in the configured categories')
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
|
||||
pb.update_progress(0, 2)
|
||||
model, found, why = latextract.gather(doc, selection=selection or None,
|
||||
know=know)
|
||||
pb.update_progress(2, 2)
|
||||
|
||||
cats = knowledge.get(know, 'lateral.categories', None) \
|
||||
or latextract.DEFAULT_CATEGORIES
|
||||
|
||||
if why == latextract.NOT_RUN_UNMAPPED:
|
||||
not_run(
|
||||
'The `parameters.{0}` role is not mapped, so this does not know '
|
||||
'which parameter holds the identity description the marker is '
|
||||
'typed into.\n\n'
|
||||
'It will not guess a name. A guessed name would work on the '
|
||||
'template it was guessed from and silently find nothing on every '
|
||||
'other one, and "found nothing" is indistinguishable from "nothing '
|
||||
'is marked" once it is on the screen.\n\n'
|
||||
'Map it in the plant knowledge file and run again. Self Check lists '
|
||||
'which layers are in play.'.format(latextract.DESC_ROLE))
|
||||
|
||||
if why == latextract.NOT_RUN_ABSENT:
|
||||
# A literal role path, not a variable, so knowledge_drift can see it.
|
||||
names = knowledge.param_names(know, 'member.identity_description')
|
||||
not_run(
|
||||
'The `parameters.{0}` role is mapped to **{1}**, but not one '
|
||||
'candidate element actually carries that parameter.\n\n'
|
||||
'That is not the same as "nothing is marked". The field is not '
|
||||
'there, so nothing could have been marked, and this tool has no '
|
||||
'opinion of its own about which members resist lateral load.\n\n'
|
||||
'Scope searched: {2}. Categories: `{3}`.\n\n'
|
||||
'Either add the parameter to these families, or point the role at '
|
||||
'whatever field this office really uses.'
|
||||
.format(latextract.DESC_ROLE, ', '.join(names), scope,
|
||||
', '.join(cats)))
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
output.print_md('# Lateral extraction')
|
||||
output.print_md(
|
||||
'Model **{0}** · scope: {1} · {2} candidate element(s) read'
|
||||
.format(doc.Title, scope, found['candidates']))
|
||||
output.print_md(
|
||||
'Marker tokens accepted: {0}. Read from: {1}.'
|
||||
.format(', '.join(['`' + t + '`' for t in found['tokens']]),
|
||||
', '.join(['`' + p + '`' for p in found['params_tried']])))
|
||||
|
||||
# ---- the declared scope, and why it is NOT applied here ------------------
|
||||
#
|
||||
# Every other button on this panel narrows what it judges to the job's
|
||||
# declared scope (`vibe.scope`). This one deliberately does not, and says so
|
||||
# rather than leaving the reader to assume either way.
|
||||
#
|
||||
# The reason is that the marker IS the pick. A member is lateral-resisting
|
||||
# because somebody typed it into the identity description, one element at a
|
||||
# time -- that is a person choosing, not the tool choosing, and this whole
|
||||
# tool's rule is that a filter never overrides what the user picked. Beyond
|
||||
# that, a lateral system is columns, beams and shear walls by nature; scoping
|
||||
# it to one discipline's prefix would delete most of the frame and produce an
|
||||
# analysis model that looks complete and is not. The line below is printed
|
||||
# every run so the difference is visible on the page.
|
||||
_declared = scopelib.of(know)
|
||||
output.print_md(
|
||||
'> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is '
|
||||
'deliberately NOT applied here: a member is in this extraction because '
|
||||
'somebody marked it, and a lateral system crosses every discipline on '
|
||||
'the job. Every marked member is exported whatever its mark, and the '
|
||||
'members another engineer owns are as much a part of the frame as '
|
||||
'yours.'.format(_declared.label()))
|
||||
|
||||
n_marked = len(found['marked'])
|
||||
n_amb = len(found['ambiguous'])
|
||||
|
||||
if not n_marked and not n_amb:
|
||||
# Asked, and the answer was nothing. Distinct from NOT RUN above, and
|
||||
# said in words rather than left as an empty table -- and critically,
|
||||
# NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer.
|
||||
output.print_md('## Nothing is marked')
|
||||
output.print_md(
|
||||
'> {0} element(s) were read and **not one** carries the marker. '
|
||||
'The identity-description field exists, so the question was asked '
|
||||
'properly and the answer is that no member has been marked as '
|
||||
'lateral-resisting yet.\n\n'
|
||||
'> **No files have been written.** An empty analysis file on disk '
|
||||
'would look like a result.\n\n'
|
||||
'> To mark a member, type `{1}` into its identity description '
|
||||
'(`{2}`) and run this again.'
|
||||
.format(found['candidates'], found['tokens'][0],
|
||||
found['params_tried'][0]))
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Nothing is marked - no files written.')
|
||||
script.exit()
|
||||
|
||||
if n_amb:
|
||||
output.print_md('## Ambiguous marks — not counted either way')
|
||||
output.print_md(
|
||||
'The marker appears in these fields but is preceded by a negating '
|
||||
'word, so it may mean the opposite. They are **excluded** from the '
|
||||
'export and named here for you to settle.')
|
||||
output.print_table(
|
||||
[[str(r['element_id']),
|
||||
r['mark'] or '', r['category'] or '', r['desc'] or '']
|
||||
for r in found['ambiguous']],
|
||||
columns=['element id', 'mark', 'category', 'identity description'])
|
||||
|
||||
# ---- what was selected, element by element -------------------------------
|
||||
output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked))
|
||||
output.print_table(
|
||||
[[str(r['element_id']), r['mark'] or '', r['category'] or '',
|
||||
r['desc_param'] or '', r['token'] or '', r['desc'] or '']
|
||||
for r in found['marked']],
|
||||
columns=['element id', 'mark', 'category', 'read from', 'matched',
|
||||
'identity description'])
|
||||
|
||||
# ---- what was skipped, element by element, with the reason ---------------
|
||||
output.print_md('## Skipped ({0})'.format(len(model['skipped'])))
|
||||
if model['skipped']:
|
||||
output.print_table(
|
||||
[[str(s.get('element_id')), s.get('mark') or '',
|
||||
s.get('category') or '', s.get('reason') or '']
|
||||
for s in model['skipped']],
|
||||
columns=['element id', 'mark', 'category', 'why it is not a member'])
|
||||
else:
|
||||
output.print_md('> Nothing was skipped.')
|
||||
|
||||
# ---- what became members -------------------------------------------------
|
||||
output.print_md('## Members written ({0})'.format(len(model['members'])))
|
||||
if model['members']:
|
||||
output.print_table(
|
||||
[[m['label'], m['i_node'], m['j_node'],
|
||||
'{0:.3f}'.format(m['length_ft']),
|
||||
(m.get('section') or {}).get('name') or '(none)',
|
||||
m.get('material') or '(none)',
|
||||
m.get('axis_source') or '',
|
||||
'; '.join(m['unresolved'])]
|
||||
for m in model['members']],
|
||||
columns=['member', 'i', 'j', 'length ft', 'section label',
|
||||
'material', 'axis from', 'NOT determined'])
|
||||
else:
|
||||
output.print_md(
|
||||
'> **No member survived extraction.** Every marked element is in the '
|
||||
'skipped table above with its reason. Nothing analysable came out of '
|
||||
'this run.')
|
||||
|
||||
# ---- what could not be determined ----------------------------------------
|
||||
rows = latmodel.unresolved_rows(model)
|
||||
output.print_md('## Not determined ({0} item(s))'.format(len(rows)))
|
||||
output.print_md(
|
||||
'Every one of these is **left empty** in the output. None of them is '
|
||||
'given a default value. A member with no section is not quietly assigned '
|
||||
'one; the draft file writes `{0}` for it, which no analysis program can '
|
||||
'resolve, so it fails to analyse rather than analysing wrongly.'
|
||||
.format(latrisa.UNRESOLVED_SHAPE))
|
||||
if rows:
|
||||
output.print_table(
|
||||
[[r['label'], str(r['element_id']), r['mark'] or '', r['missing']]
|
||||
for r in rows],
|
||||
columns=['member', 'element id', 'mark', 'what is missing'])
|
||||
|
||||
# ---- connectivity --------------------------------------------------------
|
||||
conn = model['connectivity']
|
||||
output.print_md('## Connectivity')
|
||||
output.print_md(
|
||||
'{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only '
|
||||
'one member reaching them. This tool does not extract supports, so it '
|
||||
'cannot tell a support from a disconnected member -- both look like this.'
|
||||
.format(conn['node_count'], conn['member_count'], model['node_tol_ft'],
|
||||
len(conn['free_ends'])))
|
||||
if not conn.get('near_miss_checked'):
|
||||
output.print_md(
|
||||
'> The near-miss search was **not run** ({0} nodes is too many for an '
|
||||
'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no '
|
||||
'near misses" -- nobody looked.'.format(conn['node_count']))
|
||||
elif conn['near_misses']:
|
||||
output.print_md(
|
||||
'**{0} pair(s) of nodes sit within 6 inches of each other but further '
|
||||
'apart than the weld tolerance.** These are almost certainly meant to '
|
||||
'be the same point. They are **not** merged here.'
|
||||
.format(conn['near_miss_total']))
|
||||
output.print_table(
|
||||
[[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])]
|
||||
for r in conn['near_misses'][:40]],
|
||||
columns=['node', 'node', 'gap ft'])
|
||||
output.print_md(
|
||||
'> A member ending part-way along another member is **not** connected to '
|
||||
'it and no node is inserted to make it so.')
|
||||
|
||||
# ---- the standing caveats ------------------------------------------------
|
||||
output.print_md('## What is deliberately not in the output')
|
||||
for note in model['notes']:
|
||||
output.print_md('- ' + note)
|
||||
|
||||
# ------------------------------------------------------------ where it goes
|
||||
#
|
||||
# Beside the MODEL, never in the repository.
|
||||
start_dir = knowledge.get(know, 'lateral.output_dir', None) or \
|
||||
designspec.sub_dir(doc, 'lateral')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
|
||||
if not doc.PathName:
|
||||
output.print_md(
|
||||
'## Not written\n> This model has never been saved, so there is no '
|
||||
'folder beside it to write to. Save the model and run again.')
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Model has never been saved - nothing written.')
|
||||
script.exit()
|
||||
|
||||
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
|
||||
written = []
|
||||
for name, text in latrisa.file_set(model, stem):
|
||||
path = os.path.join(start_dir, name)
|
||||
try:
|
||||
f = io.open(path, 'w', encoding='utf-8')
|
||||
try:
|
||||
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
|
||||
finally:
|
||||
f.close()
|
||||
written.append(path)
|
||||
except Exception as ex:
|
||||
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
|
||||
|
||||
output.print_md('## Files written')
|
||||
for path in written:
|
||||
output.print_md('- `{0}`'.format(path))
|
||||
output.print_md(
|
||||
'> The `.json` and the two `.csv` files are complete statements of what '
|
||||
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
|
||||
'file -- its section keywords follow RISA\'s published Appendix D but '
|
||||
'the field order inside each record is unverified, so it is named so '
|
||||
'that it cannot be opened into RISA by accident. Read its header before '
|
||||
'renaming it.')
|
||||
if not latmodel.is_analysable(model):
|
||||
output.print_md(
|
||||
'> **This model would not analyse correctly as written**, and that '
|
||||
'is on purpose: it has unresolved sections, free ends, no supports '
|
||||
'and no releases. Fix those in the analysis package, not here.')
|
||||
|
||||
# The extraction ran and its files are on disk -- the record is 'ok' with
|
||||
# the report text kept short, since record() caps at 20000 characters.
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok',
|
||||
report_path=written[0] if written else None,
|
||||
report_text=('{0} marked member(s), {1} skipped, '
|
||||
'{2} node(s), {3} member(s) written; files: {4}'
|
||||
.format(n_marked, len(model['skipped']),
|
||||
conn['node_count'],
|
||||
conn['member_count'],
|
||||
'; '.join(written))))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,17 @@
|
||||
title: Panel Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration, same as its siblings in this pulldown. This button
|
||||
# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section
|
||||
# per selected panel, pre-filled from the model where readable and blank
|
||||
# otherwise, gated so a panel cannot run until its required fields (incl. >=2
|
||||
# supports) are filled -- then feeds the confirmed inputs to the Stage-0
|
||||
# self-weight solve. The in-place / wind / service engine behind the form is a
|
||||
# later stage. See script.py's own comments for exactly what it does and does
|
||||
# not do.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,27 @@
|
||||
title: Panel Lift Check
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to `liftexport` -- the Revit half of the
|
||||
# panellift boundary -- is visible without a Revit restart. It trades press
|
||||
# latency (engine construction instead of engine reuse) for restart-freedom.
|
||||
# The CPython half already needs no restart; this is for the half that does.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons
|
||||
# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row
|
||||
# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a
|
||||
# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the
|
||||
# longest row, so the title is free to say what it means. No (WIP): moving
|
||||
# panels did not change what this tool does, and it is not half-built.
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,36 @@
|
||||
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
|
||||
# why a container may carry a `title:`.
|
||||
title: DesignHub
|
||||
|
||||
# REORGANISED 2026-09-05, on the owner's own instruction: "把 Panel Lift
|
||||
# 移到 Design Hub 里面去,再把 Beam Analysis 跟 Connection Analysis 也加
|
||||
# 进去" -- Panel Lift Check moved onto this pulldown from being one of
|
||||
# Tools.panel's four large icons (see that panel's own bundle comment), and
|
||||
# two new placeholder rows, BeamAnalysis and ConnectionAnalysis, joined
|
||||
# GravityLoadChase and LateralExtract as more (WIP) rows.
|
||||
#
|
||||
# PanelLiftCheck is NOT half-built -- it is the one row here that runs, end
|
||||
# to end, on a real piece, exactly like PanelAnalysis. It moved here because
|
||||
# the owner wants it grouped with the other analysis tools rather than
|
||||
# because it stopped working; nothing about its own behaviour changed.
|
||||
#
|
||||
# The other four rows are half-built and all four say so in their own
|
||||
# titles. Keep the (WIP) suffixes until the tool behind one actually runs:
|
||||
# the arrow hides them from the ribbon, but a row that opens is still a row
|
||||
# somebody clicks. PanelAnalysis differs from BeamAnalysis, ConnectionAnalysis,
|
||||
# GravityLoadChase and LateralExtract in one way worth naming: it DOES run,
|
||||
# end to end, and opens a real page -- what is unfinished is the analysis
|
||||
# pipeline behind the page it opens, not the button itself. See that
|
||||
# button's own script.py.
|
||||
#
|
||||
# Order: the two tools that run today, then the four that do not, in the
|
||||
# order the owner named them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- PanelLiftCheck
|
||||
- PanelAnalysis
|
||||
- BeamAnalysis
|
||||
- ConnectionAnalysis
|
||||
- GravityLoadChase
|
||||
- LateralExtract
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,16 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Design").
|
||||
#
|
||||
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
|
||||
# standing instruction is that unfinished tools must never sit on the same
|
||||
# panel as the polished ones; gravity-load chasing, lateral extraction, beam
|
||||
# analysis and connection analysis are buttons-and-docs only until he
|
||||
# provides worked examples, so they live one click down, behind DesignHub,
|
||||
# on a panel of their own. Panel Analysis and Panel Lift Check share that
|
||||
# same door for a different reason (2026-09-05 for Panel Lift Check) --
|
||||
# both actually run, but the owner wants them grouped with the tools that
|
||||
# do not rather than back out on Tools.panel; see DesignHub's own bundle
|
||||
# comment.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- DesignHub
|
||||
@@ -0,0 +1,13 @@
|
||||
title: GOD_MOD
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# The name is the owner's, verbatim and with the underscore, from
|
||||
# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod
|
||||
# because a pyRevit component name is a C# class name in the generated
|
||||
# assembly; the title is what anybody actually reads.
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Starts a detached terminal running the `claude` CLI with its working
|
||||
# directory set to the repository root, so the session comes up on CLAUDE.md
|
||||
# and tools/rvt.py -- which is what wires it to THIS running Revit.
|
||||
#
|
||||
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
|
||||
# long agent session cannot freeze or crash Revit, and this button touches the
|
||||
# model in no way at all: it opens no Transaction, reads no element, and does
|
||||
# not even import `vibe.helpers`' document handle.
|
||||
#
|
||||
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
|
||||
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
|
||||
# any other auto-approve flag. The agent is being pointed at a live Revit
|
||||
# session and the owner's real model; every tool call it makes is worth a
|
||||
# human seeing first. If a future edit "just needs" the prompts gone, that is
|
||||
# a conversation with the owner, not a flag.
|
||||
#
|
||||
# Interactive, so this deliberately does NOT use the redirect-both-streams
|
||||
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
|
||||
# run without deadlocking on a pipe, and redirecting stdout here would leave
|
||||
# the CLI with no terminal to draw on. What is reused is the other half of the
|
||||
# idiom: a generated .cmd does the cd and holds the window open, so nothing
|
||||
# has to nest quotes inside a wt.exe command line.
|
||||
#
|
||||
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
|
||||
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
|
||||
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
|
||||
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
|
||||
# unreferenced, for the owner to keep or bin.
|
||||
#
|
||||
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
|
||||
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
|
||||
# word, underscore and all. Same button, same folder contents throughout; the
|
||||
# folder is `GodMod.pushbutton` because a component name becomes a C# class
|
||||
# name in the generated assembly.
|
||||
#
|
||||
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
|
||||
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
|
||||
# bundle file cannot resurrect a name nobody uses.
|
||||
__title__ = 'GOD_MOD'
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
|
||||
from pyrevit import forms, script
|
||||
|
||||
TITLE = 'Claude'
|
||||
MARKER = 'CLAUDE.md' # the project instructions the session reads
|
||||
|
||||
|
||||
def repo_root():
|
||||
"""The checkout to start the session in, and whether it looks like one.
|
||||
|
||||
This script lives at
|
||||
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
|
||||
so the repo is four levels above the button. Deriving it means the
|
||||
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
|
||||
case where pyRevit does not hand us __file__.
|
||||
|
||||
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
|
||||
session -- it is only missing the project instructions -- but the report
|
||||
says so rather than letting the agent start somewhere surprising in
|
||||
silence.
|
||||
"""
|
||||
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
|
||||
try:
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
except NameError:
|
||||
return home, os.path.isfile(os.path.join(home, MARKER))
|
||||
for _ in range(4):
|
||||
root = os.path.dirname(root)
|
||||
if os.path.isfile(os.path.join(root, MARKER)):
|
||||
return root, True
|
||||
if os.path.isfile(os.path.join(home, MARKER)):
|
||||
return home, True
|
||||
return root, False
|
||||
|
||||
|
||||
def find_cli():
|
||||
"""Absolute path to the Claude Code CLI, or None.
|
||||
|
||||
`vibe.aiextract.find_claude` already knows the four places a per-user
|
||||
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
|
||||
drift. Imported here rather than at module scope so a broken library
|
||||
still leaves this button able to say what went wrong.
|
||||
"""
|
||||
from vibe.aiextract import find_claude
|
||||
return find_claude()
|
||||
|
||||
|
||||
def write_runner(claude_exe, workdir):
|
||||
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
|
||||
|
||||
It goes in %TEMP%, never in the repo and never beside the model: it holds
|
||||
an absolute path to somebody's install, which is exactly what the scrub
|
||||
gate exists to keep out of the tree. Named per Revit process, because
|
||||
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
|
||||
file under each other is a class of bug not worth owning.
|
||||
"""
|
||||
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
|
||||
if not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
runner = os.path.join(folder,
|
||||
'god-mod-claude-{0}.cmd'.format(os.getpid()))
|
||||
# newline='' or the CRLFs below get translated again and every line ends
|
||||
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
|
||||
# text editor is a bad place to be debugging a launch from.
|
||||
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
|
||||
fh.write(u'@echo off\r\n')
|
||||
fh.write(u'chcp 65001 >nul\r\n')
|
||||
fh.write(u'title Claude Code - Revit\r\n')
|
||||
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
|
||||
fh.write(u'echo Revit dev folder: %CD%\r\n')
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'"{0}"\r\n'.format(claude_exe))
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'echo [claude exited - this window stays open]\r\n')
|
||||
fh.write(u'pause >nul\r\n')
|
||||
return runner
|
||||
|
||||
|
||||
def launch(runner, workdir):
|
||||
"""Start the runner detached. Windows Terminal when it is there."""
|
||||
psi = ProcessStartInfo()
|
||||
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
|
||||
'Microsoft', 'WindowsApps', 'wt.exe')
|
||||
if os.path.isfile(wt):
|
||||
psi.FileName = wt
|
||||
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
|
||||
else:
|
||||
psi.FileName = 'cmd.exe'
|
||||
psi.Arguments = '/c "{0}"'.format(runner)
|
||||
psi.WorkingDirectory = workdir
|
||||
psi.UseShellExecute = True
|
||||
return Process.Start(psi)
|
||||
|
||||
|
||||
WORKDIR, HAS_MARKER = repo_root()
|
||||
|
||||
try:
|
||||
CLAUDE = find_cli()
|
||||
except Exception as ex:
|
||||
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
|
||||
type(ex).__name__, ex), title=TITLE, exitscript=True)
|
||||
|
||||
if not CLAUDE:
|
||||
forms.alert(
|
||||
'Claude Code CLI not found.\n\n'
|
||||
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
|
||||
'full path of claude.cmd / claude.exe.\n\n'
|
||||
'Looked in the usual per-user install folders and on PATH.',
|
||||
title=TITLE, exitscript=True)
|
||||
|
||||
try:
|
||||
RUNNER = write_runner(CLAUDE, WORKDIR)
|
||||
launch(RUNNER, WORKDIR)
|
||||
except Exception as ex:
|
||||
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
|
||||
type(ex).__name__, ex), title=TITLE, exitscript=True)
|
||||
|
||||
output = script.get_output()
|
||||
output.print_md('### Claude Code is starting')
|
||||
output.print_md('Working folder: `{0}`'.format(WORKDIR))
|
||||
output.print_md('CLI: `{0}`'.format(CLAUDE))
|
||||
if not HAS_MARKER:
|
||||
output.print_md(
|
||||
'**No `{0}` in that folder** - the session will start without the '
|
||||
'project instructions, and without them it does not know about the '
|
||||
'bridge to this Revit.'.format(MARKER))
|
||||
output.print_md(
|
||||
'It runs with **default permissions**, so it asks before it acts. '
|
||||
'It can read and drive **this** Revit session through `tools/rvt.py` - '
|
||||
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
|
||||
'button that renumbers the selected doors"_.')
|
||||
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Lab").
|
||||
#
|
||||
# One button, and it is not a work in progress -- GOD_MOD works. It sits
|
||||
# alone at the right-hand end because it is the escape hatch rather than one
|
||||
# of the four tools, and an escape hatch you can find without looking is
|
||||
# worth a panel divider.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- GodMod
|
||||
@@ -0,0 +1,8 @@
|
||||
title: Design Extract
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,714 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Turn a design summary into a reviewable spec file, in two chained passes.
|
||||
#
|
||||
# PASS 1, mechanical, in-process: scrapes the summary drafting view and its
|
||||
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
|
||||
# no subprocess, nothing that leaves this process. What the parser could not
|
||||
# place lands verbatim in the spec's 'unresolved' list rather than being
|
||||
# dropped, which is what makes a narrow rule set honest instead of lossy.
|
||||
# Read-only as far as Revit is concerned, except for exporting the summary
|
||||
# view to a PNG for pass 2 (also read-only -- ExportImage writes no model
|
||||
# data).
|
||||
#
|
||||
# PASS 2, AI, out of process (owner, 2026-09-01, Discord): chained
|
||||
# automatically after pass 1 succeeds, `vibe.aiextract.launch` starts a
|
||||
# DETACHED process (`tools/aiextract_run.py`, DSV4F with vision) that fills
|
||||
# whatever pass 1 left null, empty or unresolved, plus the strand matrix
|
||||
# pass 1 never attempts. It can only ADD to the spec pass 1 wrote -- see
|
||||
# that module's docstring for the never-overwrite rule -- and this button
|
||||
# does not wait for it: the spec file is updated in place whenever that
|
||||
# process finishes, or left exactly as pass 1 wrote it if the AI pass fails.
|
||||
#
|
||||
# Every value this button writes is machine-read and UNVERIFIED. Check it
|
||||
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
|
||||
# is trusted to have used the right numbers.
|
||||
#
|
||||
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
|
||||
# -----------------------------------------------------------
|
||||
# The specs written here are what Ticket QC's design-intent comparison
|
||||
# enforces, so a design extracted here enters the scoped path. Until
|
||||
# 2026-08-31 this button offered EVERY design in the model with no mention of
|
||||
# the scope at all -- a side door: an out-of-scope design could be extracted,
|
||||
# reach Ticket QC, and be compared against, without ever being labelled.
|
||||
#
|
||||
# It is not a door that should be shut. The engineer may have a reason to
|
||||
# extract somebody else's design -- to read it, to compare against it, to
|
||||
# check a shared edge -- and a picker that hid designs would be exactly the
|
||||
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
|
||||
# nothing is blocked. Instead:
|
||||
#
|
||||
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
|
||||
# choosing, before anything is written;
|
||||
# * the report carries the scope line every other report carries, and,
|
||||
# when one was actually extracted, the annotation saying the values in
|
||||
# it rest on knowledge nobody validated for that kind of piece;
|
||||
# * the fold opens ITSELF when that happened, by the standing display rule.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Design Extract'
|
||||
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
|
||||
ViewSheet)
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, ticketqc, knowledge, specsave
|
||||
from vibe import aiextract, ticketvision
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
if not doc.PathName:
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Save the model first - the spec files live '
|
||||
'beside it.')
|
||||
forms.alert('Save the model first - the spec files live beside it.',
|
||||
exitscript=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
|
||||
# --------------------------------------------------------------- pick work
|
||||
know = knowledge.load(doc)
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=know)
|
||||
existing = designspec.list_specs(doc)
|
||||
|
||||
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
|
||||
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
|
||||
|
||||
# The declared scope, off the job knowledge, read the same way Job Recon,
|
||||
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
|
||||
# note at the top of this file.
|
||||
the_scope = scopelib.of(know)
|
||||
|
||||
|
||||
def out_of_scope(design):
|
||||
"""True when this design number is declared to be somebody else's.
|
||||
|
||||
Through `as_mark`, so a design key that does not read as a mark at all
|
||||
is 'could not tell' and is never labelled as another engineer's.
|
||||
"""
|
||||
return not the_scope.includes(scopelib.as_mark(design))
|
||||
|
||||
|
||||
def sheets_by_folder():
|
||||
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
|
||||
organization, so this picker is grouped the way the browser tree is.
|
||||
|
||||
A flat list of every drafting view is unusable on a real job -- there are
|
||||
hundreds, and their names are not the design numbers anyway.
|
||||
"""
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
out = {}
|
||||
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if sheet.IsTemplate:
|
||||
continue
|
||||
number = (sheet.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
|
||||
continue
|
||||
folder = ''
|
||||
if org is not None:
|
||||
try:
|
||||
folder = ' / '.join(
|
||||
[item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
folder = ''
|
||||
out.setdefault(folder or '(no folder)', []).append(sheet)
|
||||
return out
|
||||
|
||||
|
||||
def packets(sheets):
|
||||
"""{design key: [ViewSheet, ...]} in page order.
|
||||
|
||||
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
|
||||
pages of design PNL1, and extracting any of them means extracting the
|
||||
packet.
|
||||
"""
|
||||
out = {}
|
||||
for sheet in sheets:
|
||||
key = designspec.design_key(sheet.SheetNumber, know)
|
||||
if key:
|
||||
out.setdefault(key, []).append(sheet)
|
||||
for key in out:
|
||||
out[key].sort(key=lambda s: (s.SheetNumber or ''))
|
||||
return out
|
||||
|
||||
|
||||
DESIGNS = {} # row label -> design key, so nothing has to be split
|
||||
|
||||
|
||||
def row(design, sheets=None):
|
||||
bits = []
|
||||
if sheets:
|
||||
cover = sheets[0]
|
||||
bits.append(cover.Name)
|
||||
if len(sheets) > 1:
|
||||
bits.append('{0} sheets ({1})'.format(
|
||||
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
|
||||
else:
|
||||
bits.append('sheet {0}'.format(cover.SheetNumber))
|
||||
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
|
||||
else 'NO pieces')
|
||||
if design in existing:
|
||||
bits.append('spec exists - will be overwritten')
|
||||
# At the point of choosing, and in capitals: this is the one moment the
|
||||
# engineer can decide he did not mean to extract somebody else's design.
|
||||
# It never removes the row.
|
||||
if out_of_scope(design):
|
||||
bits.append('OUTSIDE THE DECLARED SCOPE')
|
||||
label = '{0} - {1}'.format(design, ', '.join(bits))
|
||||
DESIGNS[label] = design
|
||||
return label
|
||||
|
||||
|
||||
groups = OrderedDict()
|
||||
|
||||
by_folder = sheets_by_folder()
|
||||
for folder in sorted(by_folder):
|
||||
found = packets(by_folder[folder])
|
||||
if found:
|
||||
groups['Sheets / ' + folder] = [row(key, found[key])
|
||||
for key in sorted(found)]
|
||||
|
||||
# Designs the pieces claim that no sheet in the design folder accounts for.
|
||||
# These would silently go unchecked, so they get their own group rather than
|
||||
# being left off the list.
|
||||
accounted = set()
|
||||
for folder in by_folder:
|
||||
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
|
||||
accounted.update(packets(by_folder[folder]).keys())
|
||||
orphans = [d for d in sorted(grouped) if d not in accounted]
|
||||
if orphans:
|
||||
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
|
||||
|
||||
if existing:
|
||||
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
|
||||
if leftover:
|
||||
groups['Specs already on disk'] = leftover
|
||||
|
||||
if not groups:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='No design numbers found in this model.')
|
||||
forms.alert('No design numbers found in this model.', exitscript=True)
|
||||
|
||||
kwargs = {
|
||||
'title': 'Extract which design summaries?',
|
||||
'group_selector_title': 'Browser folder',
|
||||
'multiselect': True,
|
||||
'button_name': 'Extract',
|
||||
'width': 900,
|
||||
'height': 650,
|
||||
}
|
||||
preferred = 'Sheets / ' + DESIGN_FOLDER
|
||||
if DESIGN_FOLDER and preferred in groups:
|
||||
kwargs['default_group'] = preferred
|
||||
|
||||
picked = forms.SelectFromList.show(groups, **kwargs)
|
||||
if not picked:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='Design picker closed -- no design chosen.')
|
||||
script.exit()
|
||||
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
|
||||
|
||||
# What was OFFERED and left unpicked is not the scope's business -- the
|
||||
# engineer chose. What matters is what he DID pick that the declaration says
|
||||
# is somebody else's, because those specs are about to feed Ticket QC.
|
||||
picked_out = [d for d in designs if out_of_scope(d)]
|
||||
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
|
||||
scope_annotation = the_scope.annotation(picked_out, unit='design')
|
||||
|
||||
# ------------------------------------------------------------- gather + go
|
||||
output.print_md('# Extract design summaries')
|
||||
output.print_md('Model **{0}**'.format(doc.Title))
|
||||
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
|
||||
output.print_md(
|
||||
'**Every value below is machine-read and unverified.** A regex either '
|
||||
'matched a rule or it did not -- there is no judgement in between. What '
|
||||
"matched is filled in; every line of the summary that didn't is listed "
|
||||
'verbatim in the spec\'s `unresolved` section instead of being silently '
|
||||
'dropped -- and, from this press on, on this page as well. Read those, '
|
||||
'then check the spec in `{0}` against the drawing before Ticket QC or '
|
||||
'Panel Lift Check are trusted to have used the right numbers.'
|
||||
.format(designspec.sub_dir(doc, 'specs')))
|
||||
|
||||
# ---- the declared scope. Not folded, and above the results rather than
|
||||
# below them, because a spec is written to disk by the time this page exists
|
||||
# -- the reader is being told what he already has, and Ticket QC will pick it
|
||||
# up whether or not he scrolls.
|
||||
output.print_md('> ' + scope_line)
|
||||
if scope_annotation:
|
||||
output.print_md('> **' + scope_annotation + '**')
|
||||
for _note in the_scope.declaration_lines():
|
||||
output.print_md('_{0}_'.format(_note))
|
||||
|
||||
def _per_plant_layer_exists():
|
||||
"""True when this machine already has at least one per-plant folder.
|
||||
|
||||
Answers a different question from `knowledge.plant_id(doc)`: that asks
|
||||
which plant THIS job names; this asks whether the per-plant migration
|
||||
has already happened on THIS MACHINE at all, which is what decides
|
||||
whether the legacy machine-wide path is still a safe place to drop a
|
||||
starter.
|
||||
|
||||
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
|
||||
-- writing the machine-wide starter there is exactly what this button has
|
||||
always done, and it is still right: a fresh install needs something to
|
||||
edit. But once a machine has even one real per-plant folder, the
|
||||
machine-wide file at `<root>/knowledge.yaml` is what
|
||||
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
|
||||
(`plantpath.py`), so writing a blank template there would give a job
|
||||
that simply forgot to set `job.plant` a silent, empty plant layer
|
||||
instead of a loud "no plant declared." That is the exact trap this fix
|
||||
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
|
||||
migrated machine the safe move is to write nothing and say so.
|
||||
"""
|
||||
root = designspec.plant_root()
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return False
|
||||
for name in names:
|
||||
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `write_plant_starter()` with no plant argument always targets the
|
||||
# machine-wide `<root>/knowledge.yaml` -- the exact file
|
||||
# `plant_knowledge_path(None)` returns as the fallback for any job that
|
||||
# omits `job.plant`. Calling it unconditionally, as this button used to,
|
||||
# recreated a blank template there on every press, which any no-plant job
|
||||
# would then silently read as its entire plant layer. `knowledge.plant_id`
|
||||
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
|
||||
# before the ordinary merge runs (see its docstring), so it is read the
|
||||
# same way here rather than re-derived.
|
||||
plant = knowledge.plant_id(doc)
|
||||
|
||||
if plant:
|
||||
# A named plant gets its own folder. `write_plant_starter` already
|
||||
# refuses to overwrite a file that exists (knowledge.py:6601 --
|
||||
# `if os.path.isfile(path) and not overwrite: return path, False`), so
|
||||
# calling it on every press is safe: it only ever fills a gap, never
|
||||
# clobbers a real plant layer.
|
||||
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
elif _per_plant_layer_exists():
|
||||
# No plant named, but this machine has already been migrated to
|
||||
# per-plant folders -- writing at the legacy path would recreate the
|
||||
# exact decoy this fix removes. Report the gap instead of filling it
|
||||
# with a blank file that looks like an answer.
|
||||
output.print_md(
|
||||
'Plant standards: **no `job.plant` set**, and this machine already '
|
||||
'has per-plant folders under `{0}` -- nothing written at the '
|
||||
'legacy machine-wide path, because that path is what a no-plant '
|
||||
'job reads as its plant layer. Set `job.plant` in this job\'s '
|
||||
'knowledge file.'.format(designspec.plant_root()))
|
||||
else:
|
||||
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
|
||||
# so the machine-wide file is still the only plant layer this install
|
||||
# could possibly mean, exactly as it was before per-plant folders
|
||||
# existed. Safe to seed, and `write_plant_starter` still refuses to
|
||||
# overwrite if one is somehow already there.
|
||||
ppath, pcreated = knowledge.write_plant_starter()
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
|
||||
kpath, created = knowledge.write_starter(doc, plant=plant)
|
||||
if created:
|
||||
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
|
||||
|
||||
written, skipped = [], []
|
||||
for design in designs:
|
||||
try:
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
if not evidence.get('summary_views'):
|
||||
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
|
||||
'sheets carry no views'.format(
|
||||
DESIGN_FOLDER or 'any folder', design)))
|
||||
continue
|
||||
designspec.write_json(designspec.evidence_path(doc, design), evidence)
|
||||
notes = sum([len(v.get('text_notes') or [])
|
||||
for v in evidence.get('summary_views') or []])
|
||||
|
||||
try:
|
||||
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
|
||||
if spec is None:
|
||||
skipped.append((design, 'no summary view to read'))
|
||||
continue
|
||||
path = designspec.save_spec(doc, design, spec)
|
||||
# The spec itself is kept, not just its counts: what is unresolved is
|
||||
# listed by name below, and the copy the engineer asks for is written
|
||||
# from the file this returned.
|
||||
written.append({'design': design, 'notes': notes, 'path': path,
|
||||
'spec': spec})
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
# ----------------------------------------------- the AI pass, chained on
|
||||
#
|
||||
# Owner, 2026-09-01 (Discord), approving the two-stage-extraction
|
||||
# proposal: the mechanical reader above always runs first and never
|
||||
# guesses; whatever it left null, empty or unresolved is this second
|
||||
# pass's job, on DSV4F WITH VISION, never opus ("可以,不过别用opus,用
|
||||
# dsv4f带vision吧"). The strand matrix is one such field, not a
|
||||
# standing exception any more -- `build_spec` now reads it from
|
||||
# geometry (the drawn strand symbols against the dimension chain) and
|
||||
# takes it only when every identity check on that reading passes; the
|
||||
# AI pass picks it up here exactly when the geometry read refused, the
|
||||
# same as any other blank left by the mechanical reader. It runs
|
||||
# detached (`vibe.aiextract.launch`), the same
|
||||
# way the AI reader always has, so this button returns immediately --
|
||||
# the spec file above is updated IN PLACE whenever that process
|
||||
# finishes, or is left exactly as written above (plus one `unresolved`
|
||||
# line saying so) if the AI pass fails or times out. Nothing here waits
|
||||
# for it and nothing here re-reads the spec after kicking it off.
|
||||
#
|
||||
# Rendering the summary view to an image is Revit API work, done here
|
||||
# rather than inside `aiextract` (which otherwise has no reason to touch
|
||||
# a live View) -- OWNER MUST VERIFY: exercised only against a live Revit
|
||||
# session, not tonight.
|
||||
ai_image, ai_error = None, None
|
||||
try:
|
||||
ai_views, _ai_sheets = designspec.find_summary_views(doc, design,
|
||||
know=know)
|
||||
if ai_views:
|
||||
ai_image = ticketvision.render_view_png(
|
||||
doc, ai_views[0].Id, designspec.sub_dir(doc, 'log'))
|
||||
except Exception as ex:
|
||||
ai_error = ('could not render the summary view to an image ({0}: '
|
||||
'{1}) -- the AI pass will run text-only, and the strand '
|
||||
'matrix will very likely stay blank'
|
||||
.format(type(ex).__name__, ex))
|
||||
|
||||
try:
|
||||
aiextract.launch(doc, design, know=know,
|
||||
image_paths=[ai_image] if ai_image else [])
|
||||
written[-1]['ai_launched'] = True
|
||||
except Exception as ex:
|
||||
written[-1]['ai_launched'] = False
|
||||
started = ('the AI pass itself could not be started ({0}: {1})'
|
||||
.format(type(ex).__name__, ex))
|
||||
ai_error = (ai_error + '; ' + started) if ai_error else started
|
||||
written[-1]['ai_image'] = ai_image
|
||||
written[-1]['ai_error'] = ai_error
|
||||
|
||||
# ------------------------------------------------------- where the copy goes
|
||||
#
|
||||
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
|
||||
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
|
||||
# 路径。用户也可以打开看到底是什么东西unresolved。"
|
||||
#
|
||||
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
|
||||
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
|
||||
# else, so a spec saved only into a folder the engineer picked would look
|
||||
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
|
||||
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
|
||||
# read.
|
||||
#
|
||||
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
|
||||
# on cancel, which is right there: nothing has been calculated yet, so a
|
||||
# cancel costs nothing. Here the extraction has already run and the files are
|
||||
# already on disk, so exiting would throw away work that only another press
|
||||
# could get back. Cancel therefore means "no second copy" and nothing else --
|
||||
# the results still print, pointing at the canonical file.
|
||||
copies = {}
|
||||
copy_problems = []
|
||||
in_place = []
|
||||
cancelled_save = False
|
||||
|
||||
if written:
|
||||
designs_written = [w['design'] for w in written]
|
||||
# Seeded with wherever the last extraction was saved; the job's data
|
||||
# folder when there is no such memory. Both are real places on this
|
||||
# machine -- a dialog opening on a path that does not exist is how a file
|
||||
# lands somewhere nobody meant. NOT the `specs` folder itself: the
|
||||
# default name is the name the canonical file already has, so opening
|
||||
# there would greet a single-design run with "PNL1.yaml already exists,
|
||||
# replace it?" -- a question about the file it just wrote.
|
||||
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
|
||||
or designspec.root_dir(doc))
|
||||
chosen = forms.save_file(
|
||||
file_ext='yaml', init_dir=start_dir or '',
|
||||
default_name=specsave.default_name(designs_written),
|
||||
title='Save the design extraction as')
|
||||
if not chosen:
|
||||
cancelled_save = True
|
||||
else:
|
||||
by_design = dict([(w['design'], w['path']) for w in written])
|
||||
for design, target in specsave.targets(chosen, designs_written):
|
||||
# Saving onto the file itself. The dialog opens on the folder the
|
||||
# specs already live in, so this is an ordinary thing to do by
|
||||
# accident -- and rewriting a file with its own contents is at
|
||||
# best pointless. Say so instead of claiming a copy that is the
|
||||
# same path twice.
|
||||
if specsave.same_file(by_design[design], target):
|
||||
in_place.append(design)
|
||||
continue
|
||||
try:
|
||||
# Copied as text rather than re-rendered from the spec, so the
|
||||
# copy is character-for-character the file Ticket QC reads. A
|
||||
# second rendering could drift from the first and the two
|
||||
# would look like the same extraction.
|
||||
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
|
||||
body = fh.read()
|
||||
with io.open(target, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
copies[design] = target
|
||||
except Exception as ex:
|
||||
copy_problems.append('{0}: {1}'.format(design, ex))
|
||||
specsave.remember_dir(designspec.root_dir(doc),
|
||||
os.path.dirname(chosen))
|
||||
|
||||
if written:
|
||||
output.print_md('## Written')
|
||||
output.print_table(
|
||||
[[w['design'], str(w['notes']),
|
||||
str(len(specsave.unresolved_entries(w['spec']))),
|
||||
os.path.basename(w['path'])] for w in written],
|
||||
columns=['design', 'text notes scraped', 'unresolved notes',
|
||||
'spec file'])
|
||||
|
||||
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
|
||||
# is a dead link in this panel -- backslashes and no scheme, so it renders
|
||||
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
|
||||
# stays the real path, because that is what gets copied into Explorer,
|
||||
# and only the href gets the URL form. `specsave.link` is that pattern.
|
||||
for w in written:
|
||||
line = '- **{0}** {1}'.format(w['design'],
|
||||
specsave.link(w['path']))
|
||||
if w['design'] in copies:
|
||||
line += ' — 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 — {0}'
|
||||
.format(problem))
|
||||
|
||||
output.print_md(
|
||||
'Then check the fields the reader did fill against the drawing. '
|
||||
'Ticket QC picks specs up automatically, from the paths above.')
|
||||
|
||||
# --------------------------------------------------------- the AI pass
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'## AI pass (DSV4F, vision) -- chained automatically\n\n'
|
||||
'For each design above, a second, detached pass just launched: it '
|
||||
'reads whatever the mechanical reader left null, empty or '
|
||||
'unresolved, plus the strand matrix the mechanical reader never '
|
||||
'even attempts. It runs on DSV4F with vision, never opus (owner\'s '
|
||||
'ruling, 2026-09-01) -- and it can only ADD to the spec: a field '
|
||||
'the mechanical reader above already filled is never touched. It '
|
||||
'writes back to the SAME spec file above, in place, whenever it '
|
||||
'finishes; if it fails or times out the file is left exactly as '
|
||||
'the mechanical reader wrote it, with one line in `unresolved` '
|
||||
'saying so.')
|
||||
for w in written:
|
||||
bits = ['launched' if w.get('ai_launched') else 'NOT launched']
|
||||
if w.get('ai_image'):
|
||||
bits.append('drawing image rendered')
|
||||
else:
|
||||
bits.append('no drawing image -- text-only pass')
|
||||
output.print_md('- **{0}** — {1}'.format(w['design'],
|
||||
', '.join(bits)))
|
||||
if w.get('ai_error'):
|
||||
output.print_md(' > {0}'.format(w['ai_error']))
|
||||
output.print_md(
|
||||
'**Every value in these spec files remains machine-read and '
|
||||
'unverified** -- the AI pass does not change that, it only changes '
|
||||
'who read which line. Re-open the spec after the AI pass\'s window '
|
||||
'closes and check it against the drawing the same as above, '
|
||||
'especially the strand matrix and anything now stamped '
|
||||
'`dsv4f-vision` in `source.extractor`.')
|
||||
|
||||
# ------------------------------------------- what is unresolved, by name
|
||||
#
|
||||
# "用户也可以打开看到底是什么东西unresolved" -- so the notes themselves are
|
||||
# on the page, verbatim, not a number in a column. A count tells a reader
|
||||
# there is something to look at and nothing about whether to look now.
|
||||
#
|
||||
# Folded by the standing display rule (`vibe.foldreport`, set 2026-08-30):
|
||||
# everything below the answer starts closed, and a section carrying a
|
||||
# problem opens ITSELF. Unresolved notes are exactly that case, so a
|
||||
# design with any opens on its own; a design with none stays a one-line
|
||||
# header. The contract that buys is: if nothing here is open, nothing on
|
||||
# this page needs you.
|
||||
sections = []
|
||||
for w in written:
|
||||
entries = specsave.unresolved_entries(w['spec'])
|
||||
blanks = specsave.blank_fields(w['spec'])
|
||||
problems = designspec.validate_spec(w['spec'])
|
||||
section = fold.Section(
|
||||
key='unresolved-' + w['design'],
|
||||
# A plain dash. `foldreport._section_html` now restores the
|
||||
# reports' entities in a title the way it always did for a table
|
||||
# cell or a markdown block (fixed 2026-08-30, after this note was
|
||||
# written), so `—` 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 += ' — {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** — 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** — '
|
||||
'they have notes the reader could not place. The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every note on every summary matched '
|
||||
'a rule. The sections are folded; click a header to open one.')
|
||||
output.print_html(fold.html(sections))
|
||||
|
||||
if skipped:
|
||||
output.print_md('## Skipped')
|
||||
for design, why in skipped:
|
||||
output.print_md('- **{0}** - {1}'.format(design, why))
|
||||
|
||||
# ---------------------------------------------- review specs already on disk
|
||||
if existing:
|
||||
output.print_md('## Specs already on disk')
|
||||
rows = []
|
||||
for design in sorted(existing):
|
||||
spec = existing[design]
|
||||
problems = designspec.validate_spec(spec)
|
||||
row_cells = [design,
|
||||
spec.get('source', {}).get('extractor') or '?',
|
||||
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
|
||||
'ok' if not problems else '; '.join(problems[:2]),
|
||||
str(len(spec.get('unresolved') or []))]
|
||||
# A spec on disk feeds Ticket QC whether this press wrote it or an
|
||||
# earlier one did, so the label belongs on the whole folder, not
|
||||
# only on what was extracted just now.
|
||||
if the_scope.is_declared:
|
||||
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
|
||||
rows.append(row_cells)
|
||||
columns = ['design', 'extractor', 'reviewed by', 'validation',
|
||||
'unresolved notes']
|
||||
if the_scope.is_declared:
|
||||
columns.append('scope')
|
||||
output.print_table(rows, columns=columns)
|
||||
if the_scope.is_declared:
|
||||
output.print_md(
|
||||
'`scope` marks a spec whose design number falls outside {0}. '
|
||||
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
|
||||
'these specs if it is pointed at those pieces, and the label is '
|
||||
'so that it is a decision rather than an accident.'
|
||||
.format(the_scope.label()))
|
||||
output.print_md(
|
||||
'`reviewed by` stays NOT reviewed until a person sets '
|
||||
'`source.reviewed_by` in the spec file by hand, which is the only '
|
||||
'thing that ever marks a spec as checked.')
|
||||
output.print_md(
|
||||
'`extractor` says which reader wrote the file: `regex` is the '
|
||||
'mechanical reader this button runs, `dsv4f-vision` is the second, '
|
||||
'AI pass this button now chains after it (`tools/aiextract_run.py`, '
|
||||
'DSV4F with vision -- owner\'s ruling, 2026-09-01), and `opus` / '
|
||||
'`claude-code` are what an earlier AI reader stamped before that '
|
||||
'ruling -- older files, read the same way. Nothing branches on the '
|
||||
'value; it is shown so a spec written by one reader is not mistaken '
|
||||
'for a spec written by another.')
|
||||
|
||||
# The extraction ran and everything is on the page. The record is 'ok'
|
||||
# with a short summary; record() never raises, so a logging failure cannot
|
||||
# turn a finished run into an error.
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='ok',
|
||||
report_text=('{0} design(s) written, {1} skipped; saved '
|
||||
'copy: {2}'
|
||||
.format(len(written), len(skipped),
|
||||
chosen if not cancelled_save else 'none')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,8 @@
|
||||
title: Hot Reload
|
||||
# No tooltip and no author on purpose -- the owner wants nothing on hover,
|
||||
# and test_button_tooltips.py enforces it. Document the button in comments
|
||||
# in script.py instead.
|
||||
#
|
||||
# One line: this is a row in a `.pulldown` (text beside a 16px icon), so a
|
||||
# `\n` has nowhere to go. The dropdown grows to fit, so the title may read
|
||||
# in full.
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Hot Reload -- stop the WillTools routes bridge cleanly, THEN reload pyRevit.
|
||||
#
|
||||
# Why the stop must come first: the routes HTTP dispatch is wired to a module-
|
||||
# global external event handler created once, on the host main thread, when
|
||||
# `pyrevit/routes/server/server.py` is first imported (server.py:35-36), and it
|
||||
# is NOT recreated across a reload -- pyRevit re-imports extension scripts, not
|
||||
# its own lib. A request in flight when the engine is torn down runs its handler
|
||||
# against a dead engine -> unhandled exception on a non-main thread -> Revit dies
|
||||
# uncatchably (measured 2026-08-14; see startup.py and CLAUDE.md "Never reload").
|
||||
# Stopping the HTTP server first closes that crash window. On reload, startup.py
|
||||
# re-registers the routes and a fresh server comes back up automatically.
|
||||
#
|
||||
# If the server will NOT stop cleanly, this ABORTS rather than reloads -- a
|
||||
# reload with a live bridge is the exact crash, so a bad stop must never fall
|
||||
# through to it.
|
||||
#
|
||||
# IronPython 2.7: no f-strings. No module docstring / tooltip / author
|
||||
# (test_button_tooltips.py).
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
from pyrevit import EXEC_PARAMS
|
||||
from pyrevit import routes
|
||||
from pyrevit import forms
|
||||
from pyrevit import script
|
||||
from pyrevit.loader import sessionmgr
|
||||
from pyrevit.loader import sessioninfo
|
||||
|
||||
logger = script.get_logger()
|
||||
|
||||
go = True
|
||||
if EXEC_PARAMS.executed_from_ui:
|
||||
go = forms.alert(
|
||||
'Hot Reload stops the WillTools bridge, then reloads pyRevit.\n\n'
|
||||
'Run this only when no bridge request is in flight -- no sweep, no '
|
||||
'agent mid-exec. A request raised the instant before this click cannot '
|
||||
'be cancelled and can still take Revit down.\n\nReload now?',
|
||||
ok=False, yes=True, no=True)
|
||||
|
||||
if go:
|
||||
stopped_clean = True
|
||||
try:
|
||||
routes.deactivate_server()
|
||||
except Exception as ex:
|
||||
stopped_clean = False
|
||||
logger.error('deactivate_server raised: {0}'.format(ex))
|
||||
|
||||
# Verify the socket is actually gone before tearing the engine down. A
|
||||
# deactivate that "succeeded" but left the server up is still unsafe.
|
||||
try:
|
||||
if routes.get_active_server() is not None:
|
||||
stopped_clean = False
|
||||
except Exception as ex:
|
||||
stopped_clean = False
|
||||
logger.error('get_active_server raised: {0}'.format(ex))
|
||||
|
||||
if not stopped_clean:
|
||||
forms.alert(
|
||||
'Could not stop the WillTools bridge cleanly.\n\n'
|
||||
'Do NOT reload with a live bridge -- restart Revit instead of '
|
||||
'reloading. (A reload while the routes handler is live is what '
|
||||
'crashes Revit.)', ok=True)
|
||||
else:
|
||||
# Vouch to tools/rvt.py that THIS reload is a clean stop-first one, so
|
||||
# the reloaded bridge is not refused as a raw-reload landmine. startup.py
|
||||
# consumes this marker on the very next load and stamps that session
|
||||
# 'hotreload'. Keyed by pid: a reload is in-process, so the same Revit
|
||||
# picks it up and no other session can. Best-effort -- if it cannot be
|
||||
# written the only cost is rvt.py refusing the reloaded bridge (the safe
|
||||
# default), never a failed reload.
|
||||
try:
|
||||
import os as _os
|
||||
from System.Diagnostics import Process as _Process
|
||||
_here = _os.path.abspath(__file__).replace('\\', '/')
|
||||
_mark = _here.find('/WillTools.extension/')
|
||||
if _mark != -1:
|
||||
_root = _here[:_mark]
|
||||
else:
|
||||
_root = _os.path.join(_os.path.expanduser('~'), 'pyrevit-dev')
|
||||
_pid = _Process.GetCurrentProcess().Id
|
||||
_flagpath = _os.path.join(
|
||||
_root, '.bridge-hotreload-{0}.flag'.format(_pid))
|
||||
_fh = open(_flagpath, 'w')
|
||||
_fh.write('hotreload\n')
|
||||
_fh.close()
|
||||
except Exception as ex:
|
||||
logger.warning('could not write hot-reload marker: {0}'.format(ex))
|
||||
|
||||
logger.info('WillTools bridge stopped. Reloading pyRevit...')
|
||||
sessionmgr.reload_pyrevit()
|
||||
# Mirrors the shipped Reload button: record the new session id.
|
||||
script.get_results().newsession = sessioninfo.get_session_uuid()
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Job Recon
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
|
||||
# the tool changed while it was out: only its ribbon entry was ever parked,
|
||||
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reconcile the issued ticket set against the whole job.
|
||||
#
|
||||
# Every other check on this panel judges one ticket at a time, and no amount of
|
||||
# per-ticket checking can find a ticket that is not there. This button asks the
|
||||
# question about the SET: every piece printed on something, everything printed
|
||||
# about a piece that exists, and the counts reconciling mark by mark.
|
||||
#
|
||||
# It reads. It never writes, and it opens no transaction.
|
||||
#
|
||||
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
|
||||
# across this tab: the answer is the headline and is never folded; everything
|
||||
# else follows it, collapsed; a section that carries a problem opens itself.
|
||||
# The reader's contract is that if nothing below the headline is open, nothing
|
||||
# needs him.
|
||||
#
|
||||
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
|
||||
# for, and a count per finding -- not the findings themselves. On a job with
|
||||
# four hundred uncovered pieces the lists are thousands of lines and the
|
||||
# verdict is invisible inside them. So:
|
||||
#
|
||||
# * the headline is the totals and the finding counts, printed through
|
||||
# pyRevit's own markdown and tables;
|
||||
# * every non-empty finding is a section below it, OPEN, because there is no
|
||||
# finding in this reconciliation that is merely interesting;
|
||||
# * coverage by design, the page-set inventory, the categories swept, the
|
||||
# checks that found nothing and the timings are folded;
|
||||
# * and the two sections that say what this run could NOT see -- out of
|
||||
# scope, and blind spots -- are separate, always, and carry their counts
|
||||
# on their headers with the fold shut. A blind spot that is a REFUSAL
|
||||
# (nothing reconciled) opens itself and is repeated in the headline,
|
||||
# because a refusal reported quietly reads as a clean job.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
|
||||
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
|
||||
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
|
||||
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
|
||||
# problem in it renders open as plain HTML and needs no script at all; and the
|
||||
# whole report, nothing folded and nothing capped, is written to
|
||||
# `last-report.txt` beside the model's other job data on every press, with the
|
||||
# page saying where. A fold that will not open therefore costs a file open,
|
||||
# never a lost sentence. All of that lives in `vibe.foldreport`.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Job Recon'
|
||||
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script
|
||||
|
||||
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
REQUIRED = ('member.control_number', 'sheet.control_numbers')
|
||||
|
||||
# The cap on each list ON THE PAGE. The file written beside the model has no
|
||||
# cap at all -- a page is read, a file is searched.
|
||||
PAGE_LIMIT = 60
|
||||
|
||||
HOW_TO_MAP = """
|
||||
This reconciliation needs roles the older checks never used. Add them to the
|
||||
plant knowledge file (the layer that describes the office TEMPLATE), naming
|
||||
whatever your title block and your pieces actually call these things:
|
||||
|
||||
parameters:
|
||||
member:
|
||||
control_number: <the piece's control number>
|
||||
ticket_created: <optional -- the model's own 'a ticket exists' flag>
|
||||
ticket_detailed: <optional>
|
||||
sheet:
|
||||
total_required: <the quantity the title block states>
|
||||
sheet_count: <how many pages the ticket says it has>
|
||||
# either list the control-number slots explicitly ...
|
||||
control_numbers: [<slot 1>, <slot 2>, ...]
|
||||
|
||||
nomenclature:
|
||||
# ... or, for numbered slots, match them with one expression:
|
||||
control_slot_re: '^<PREFIX>_\\d+$'
|
||||
|
||||
Until then this button refuses rather than reporting a job it cannot see.
|
||||
"""
|
||||
|
||||
|
||||
def write_full(recon, full_path):
|
||||
"""The whole report, unfolded, beside the model. Returns True if written.
|
||||
|
||||
Written BEFORE anything is printed, so the file exists even if the
|
||||
printing throws. `as_text` first and encode second: under IronPython 2.7
|
||||
the report is a byte string and a mark carrying a non-ASCII character
|
||||
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
"""
|
||||
payload = compat.as_text(
|
||||
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
for line in problems:
|
||||
output.print_md('**' + line + '**')
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
|
||||
report_text=('\n'.join(problems) +
|
||||
'\n\nJob Recon cannot run until the '
|
||||
'configuration is fixed.'))
|
||||
return
|
||||
|
||||
names = knowledge.param_names(know, 'member.control_number')
|
||||
slots = (knowledge.param_names(know, 'sheet.control_numbers')
|
||||
or knowledge.get(know, 'nomenclature.control_slot_re', None))
|
||||
if not names or not slots:
|
||||
output.print_md('## Job Recon cannot run')
|
||||
output.print_md('Nothing was reconciled, and that is **not** a clean '
|
||||
'result -- it is a refusal.')
|
||||
print(HOW_TO_MAP)
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
|
||||
report_text=('Job Recon cannot run: the control-number '
|
||||
'roles are not mapped.\n' + HOW_TO_MAP))
|
||||
return
|
||||
|
||||
recon = jobreconscan.run(doc, know=know)
|
||||
|
||||
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
|
||||
'last-report.txt')
|
||||
written = False
|
||||
try:
|
||||
written = write_full(recon, full_path)
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold '
|
||||
'headers.'.format(exc))
|
||||
|
||||
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
|
||||
full_path=full_path if written else None)
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if not tail:
|
||||
# The headline alone was the whole page -- recorded the same as a
|
||||
# full page, with the file copy's path when one was written.
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path if written else None,
|
||||
report_text='Job Recon ran -- nothing below the headline '
|
||||
'to report.')
|
||||
return
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one.')
|
||||
if written:
|
||||
output.print_md('The whole report, nothing folded and nothing capped, '
|
||||
'is in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
# The page is complete and the file copy exists. The verdict in numbers
|
||||
# is the headline, which `sections[0]` carried as markdown -- this short
|
||||
# summary says what reconciled, and points at the file for the rest.
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path if written else None,
|
||||
report_text='Job Recon ran; full report at {0}'
|
||||
.format(full_path if written else 'this page'))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Job Recon', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Project Config"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SizeToContent="Height"
|
||||
Width="880" MaxHeight="940" MinHeight="380"
|
||||
ResizeMode="CanResize"
|
||||
Background="#f6f7f9">
|
||||
|
||||
<!--
|
||||
THE WINDOW IS CHROME ONLY. Every field is built in `script.py` from
|
||||
`vibe.configform`, because the field list is data: it depends on which
|
||||
build of `configmodel` this Revit session is holding, on which stages
|
||||
the job has, and on how many checklist items are muted. A XAML
|
||||
DataTemplate would have had to know all three.
|
||||
|
||||
What lives here is what does not change: the four module boxes, the
|
||||
reason line every job-layer write needs, and the four buttons. Every
|
||||
x:Name below is looked up by name in `script.py`, and every Click
|
||||
handler names a method on the window class. `tests/test_configform.py`
|
||||
parses this file and that one together and fails if the two have
|
||||
drifted, which is the only check available for a window that cannot be
|
||||
opened without Revit.
|
||||
|
||||
EVERY MODULE IS AN Expander, NOT A GroupBox, AND STARTS COLLAPSED. The
|
||||
owner's ruling, 2026-09-01, verbatim in `script.py`'s own comment (kept
|
||||
out of THIS file because the no-Chinese-in-the-XAML test below scans
|
||||
every byte of it, comments included, and not only the strings a build
|
||||
would render): every section folds, and folded is the default; open
|
||||
the one you came to change, adjust it, done. `IsExpanded="False"` here
|
||||
is the fallback for the very first time this window opens in a
|
||||
session; `script.py` overrides it per module from
|
||||
`vibe.configform.remembered_expanded`, which is the ONLY thing that
|
||||
may reopen one on its own, since a module the owner left open a minute
|
||||
ago staying open is a convenience, and a module opening because it
|
||||
carries a value is not this window's call to make silently, which is
|
||||
why the header text says so instead (see `configform.is_modified` /
|
||||
`modified_count`, and `script.py`'s use of them building each
|
||||
Header).
|
||||
|
||||
No double hyphen anywhere in this comment: XML forbids it inside a
|
||||
comment, and the test above catches it as a parse error rather than as
|
||||
a window that will not load.
|
||||
-->
|
||||
|
||||
<Window.Resources>
|
||||
<!-- No FontWeight or Foreground here: a GroupBox passes both down to
|
||||
its CONTENT, so setting them to style the header would make every
|
||||
label and every note in the box bold as well. The rows set their
|
||||
own weight and colour in script.py. -->
|
||||
<Style TargetType="{x:Type GroupBox}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
</Style>
|
||||
<!-- The four modules are Expanders now, not GroupBoxes; see the
|
||||
note above this Window's own opening comment. Same border,
|
||||
same spacing, so folding cost nothing visually except the
|
||||
fold itself. -->
|
||||
<Style TargetType="{x:Type Expander}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Background" Value="White"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type Button}">
|
||||
<Setter Property="Padding" Value="10,3,10,3"/>
|
||||
<Setter Property="MinWidth" Value="76"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Padding" Value="3,2,3,2"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Margin="14">
|
||||
|
||||
<!-- The sentence that used to be cut off by a 36-pixel Label. It
|
||||
wraps here, because there is room here. -->
|
||||
<TextBlock x:Name="header"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
FontWeight="Bold"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,4"
|
||||
Text="Project Config"/>
|
||||
<TextBlock x:Name="subheader"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#5a6b7d"
|
||||
Margin="0,0,0,10"
|
||||
Text=""/>
|
||||
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
Background="#eef1f5"
|
||||
BorderBrush="#c9d2dd"
|
||||
BorderThickness="1"
|
||||
Padding="8"
|
||||
Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="status"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,6"
|
||||
Text="Nothing changed yet."/>
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
VerticalAlignment="Center"
|
||||
Width="150"
|
||||
Text="Why (written into the file)"/>
|
||||
<TextBox x:Name="reason_box" Height="24"/>
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="save_button"
|
||||
Content="Save to the job layer"
|
||||
MinWidth="150"
|
||||
Click="save_click"/>
|
||||
<Button x:Name="cancel_button"
|
||||
Content="Close"
|
||||
Click="cancel_click"/>
|
||||
<Button x:Name="openfile_button"
|
||||
Content="Open the job file"
|
||||
MinWidth="130"
|
||||
Click="openfile_click"/>
|
||||
<Button x:Name="fullview_button"
|
||||
Content="Full view"
|
||||
Click="fullview_click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel>
|
||||
<Expander x:Name="group_file_path" Header="File paths"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_file_path"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_lifting" Header="Lifting and handling"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_lifting"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_ticketqc" Header="Ticket QC"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_ticketqc"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_job_knowledge" Header="Job knowledge"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_job_knowledge"/>
|
||||
</Expander>
|
||||
<Border Background="#fff8e6"
|
||||
BorderBrush="#e0c98a"
|
||||
BorderThickness="1"
|
||||
Padding="8">
|
||||
<TextBlock x:Name="rest_line"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#6b5a20"
|
||||
Text="Everything else: edit the config file directly."/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,29 @@
|
||||
title: Project Config
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom. Second-hottest button on this ribbon --
|
||||
# 14 commits to configmodel/configpage/configform/configdump/knowledge in the
|
||||
# seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# "Project Config" rather than plain "Config": the button one level up is
|
||||
# already called Config, and a Config row inside a Config button reads like
|
||||
# a mistake even though the two name tables never collide.
|
||||
|
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.
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Report whether this machine, this Revit and this model can run the QC tools.
|
||||
#
|
||||
# Written for the moment the extension lands on a computer it was not developed
|
||||
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
|
||||
# so a failure names itself instead of arriving as a traceback from four frames
|
||||
# deep in a checking routine.
|
||||
#
|
||||
# Reads nothing but metadata and does not touch the model.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Self Check'
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, HOST_APP
|
||||
|
||||
from vibe import runlog
|
||||
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
doc = revit.doc
|
||||
rows = []
|
||||
|
||||
|
||||
def report(area, item, state, detail):
|
||||
rows.append([area, item, state, detail])
|
||||
|
||||
|
||||
def probe(area, item, fn, missing='FAIL'):
|
||||
"""Run one probe. An exception is a finding, never a crash."""
|
||||
try:
|
||||
state, detail = fn()
|
||||
report(area, item, state or missing, detail)
|
||||
except Exception as ex:
|
||||
# Name the frame that blew, not just the exception: a bare
|
||||
# "SystemError: Object reference not set to an instance of an object"
|
||||
# (IronPython's face for a .NET NullReferenceException) says nothing
|
||||
# about WHERE, and 2026-09-05 one of these cost a round trip to the
|
||||
# owner's machine to find out. Innermost frame of our own code first.
|
||||
where = ''
|
||||
try:
|
||||
import traceback
|
||||
frames = traceback.extract_tb(sys.exc_info()[2])
|
||||
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
|
||||
if ours:
|
||||
fn, ln, func, _ = ours[-1]
|
||||
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
|
||||
except Exception:
|
||||
pass
|
||||
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- host
|
||||
|
||||
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
|
||||
|
||||
|
||||
def _revit():
|
||||
app = HOST_APP.app
|
||||
version = app.VersionNumber
|
||||
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
|
||||
if version == DEVELOPED_ON['revit']:
|
||||
return 'OK', detail
|
||||
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
|
||||
DEVELOPED_ON['revit'])
|
||||
|
||||
|
||||
def _pyrevit():
|
||||
from pyrevit import versionmgr
|
||||
version = versionmgr.get_pyrevit_version().get_formatted()
|
||||
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
|
||||
return state, version
|
||||
|
||||
|
||||
def _engine():
|
||||
detail = sys.version.split('\n')[0]
|
||||
major = sys.version_info[0]
|
||||
if major == 2:
|
||||
return 'OK', detail
|
||||
# The code is written for both, but only Python 2 has been exercised.
|
||||
return 'WARN', detail + ' (only Python 2 has been run in anger)'
|
||||
|
||||
|
||||
def _routes():
|
||||
from pyrevit.userconfig import user_config
|
||||
if not user_config.routes_server:
|
||||
return 'WARN', ('routes server off - the Claude Code bridge will not '
|
||||
'connect. The buttons themselves do not need it.')
|
||||
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
|
||||
user_config.routes_port)
|
||||
|
||||
|
||||
probe('host', 'Revit', _revit)
|
||||
probe('host', 'pyRevit', _pyrevit)
|
||||
probe('host', 'python engine', _engine)
|
||||
probe('host', 'routes bridge', _routes, missing='WARN')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- library
|
||||
|
||||
def _import_all():
|
||||
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
|
||||
ticketqc, ticketchecks, aiextract)
|
||||
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
|
||||
'ticketchecks, aiextract'
|
||||
|
||||
|
||||
def _units():
|
||||
from vibe import ft, mm
|
||||
round_trip = mm(ft(1000.0))
|
||||
if abs(round_trip - 1000.0) > 1e-6:
|
||||
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
|
||||
return 'OK', '1000 mm round-trips exactly'
|
||||
|
||||
|
||||
def _checklist():
|
||||
from vibe import ticketpage
|
||||
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
|
||||
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
|
||||
return 'OK', '{0} row(s) from {1}'.format(
|
||||
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
|
||||
|
||||
|
||||
probe('library', 'modules import', _import_all)
|
||||
probe('library', 'unit conversion', _units)
|
||||
probe('library', 'published checklist', _checklist, missing='WARN')
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- tooling
|
||||
|
||||
def _claude():
|
||||
from vibe import aiextract
|
||||
found = aiextract.find_claude()
|
||||
if not found:
|
||||
return 'WARN', ('not found - Extract Design cannot run. Install the '
|
||||
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
|
||||
return 'OK', found
|
||||
|
||||
|
||||
probe('tooling', 'Claude Code CLI', _claude, missing='WARN')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- model
|
||||
|
||||
def _saved():
|
||||
if not doc.PathName:
|
||||
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
|
||||
return 'OK', doc.PathName
|
||||
|
||||
|
||||
def _data_dir():
|
||||
from vibe import designspec
|
||||
return 'OK', designspec.root_dir(doc)
|
||||
|
||||
|
||||
def _knowledge():
|
||||
"""Every knowledge layer, in the order load() merges them.
|
||||
|
||||
Which layers exist decides what the tool actually believes, so the answer
|
||||
is the list, not a yes/no: naming conventions that should be plant-wide
|
||||
sitting only in a project file is a real and invisible mistake.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
layers = knowledge.sources(doc)
|
||||
detail = ' | '.join(
|
||||
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
|
||||
for label, path, ok in layers])
|
||||
|
||||
# A project config that has gone missing is worse than one never set: the
|
||||
# run looks entirely normal and uses a configuration nobody chose.
|
||||
gone = [path for label, path, ok in layers
|
||||
if label == 'project config' and not ok]
|
||||
if gone:
|
||||
return 'FAIL', ('the project config this model runs on is not there: '
|
||||
'{0} || {1}'.format(gone[0], detail))
|
||||
if not any(ok for _label, _p, ok in layers):
|
||||
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _knowledge_parses():
|
||||
"""Read every layer file that IS there, exactly the way load() reads it.
|
||||
|
||||
The layer list above says which files exist. It does not say whether any
|
||||
of them can be READ, and those are different questions with the same
|
||||
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
|
||||
mapping raises `Duplicate key` and it returns nothing -- and a layer that
|
||||
fails to load looks exactly like a layer that agrees with the defaults.
|
||||
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
|
||||
layer, every parameter role came back None, the sweep enumerated 0
|
||||
assemblies, and every report rendered normally.
|
||||
|
||||
So this re-reads each file through the same reader and prints ok or the
|
||||
parser's own message. `knowledge.config_problems()` reports the same
|
||||
failure from the merge itself; this is the early warning, on the layer
|
||||
list, before a button is pressed. `python tools/knowledgelint.py` is the
|
||||
same check on the host, where the edit is made.
|
||||
"""
|
||||
from pyrevit.coreutils import yaml as pyyaml
|
||||
from vibe import knowledge
|
||||
|
||||
notes = []
|
||||
bad = []
|
||||
for label, path, ok in knowledge.sources(doc):
|
||||
if not ok:
|
||||
notes.append('{0}: absent'.format(label))
|
||||
continue
|
||||
try:
|
||||
pyyaml.load_as_dict(path)
|
||||
notes.append('{0}: ok'.format(label))
|
||||
except Exception as ex:
|
||||
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
|
||||
label, path, type(ex).__name__, ex))
|
||||
if bad:
|
||||
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
|
||||
'was used, and the run fell back to the layers under '
|
||||
'it. Fix the file, then run '
|
||||
'tools/knowledgelint.py to confirm.')
|
||||
if not notes:
|
||||
return 'WARN', 'no knowledge layer to read yet'
|
||||
return 'OK', ' | '.join(notes)
|
||||
|
||||
|
||||
def _specs():
|
||||
from vibe import designspec
|
||||
specs = designspec.list_specs(doc)
|
||||
if not specs:
|
||||
return 'WARN', 'none yet - run Extract Design'
|
||||
unreviewed = [d for d, s in specs.items()
|
||||
if not (s.get('source') or {}).get('reviewed_by')]
|
||||
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
|
||||
if unreviewed:
|
||||
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _scope():
|
||||
"""What the job declares this engineer is answerable for.
|
||||
|
||||
Never a FAIL. An undeclared scope is a legitimate state -- it means every
|
||||
button judges every piece -- and it is reported as such rather than as a
|
||||
misconfiguration, because 'judge everything' is the honest default and
|
||||
the only wrong answer here would be a scope the tool chose for itself.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
from vibe import scope as scopelib
|
||||
the_scope = scopelib.of(knowledge.load(doc))
|
||||
if not the_scope.is_declared:
|
||||
return 'WARN', ('no scope declared -- every button judges every '
|
||||
'piece on the job, including work whose plant '
|
||||
'knowledge has never been validated. Set '
|
||||
'scope.mark_prefixes in the job knowledge file to '
|
||||
'be told what is being taken on trust.')
|
||||
detail = the_scope.label()
|
||||
if the_scope.declared_by:
|
||||
detail += ' (declared by {0})'.format(the_scope.declared_by)
|
||||
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
|
||||
|
||||
|
||||
probe('model', 'saved', _saved)
|
||||
probe('model', 'declared scope', _scope, missing='WARN')
|
||||
probe('model', 'data folder', _data_dir)
|
||||
probe('model', 'knowledge layers', _knowledge, missing='WARN')
|
||||
probe('model', 'knowledge layers parse', _knowledge_parses)
|
||||
probe('model', 'design specs', _specs, missing='WARN')
|
||||
|
||||
|
||||
# --------------------------------------------------- project conventions
|
||||
# These are the assumptions most likely to be wrong on a different job: they
|
||||
# are naming and parameter conventions, not Revit API.
|
||||
|
||||
def _mapping():
|
||||
"""Which parameter roles this office has told us the names for.
|
||||
|
||||
Nothing in the code knows what a panel's length parameter is called, so
|
||||
an unmapped role is not a detail -- it is a check that will not run. It
|
||||
reports before the convention probes below, because they depend on it.
|
||||
"""
|
||||
from vibe import knowledge, designspec
|
||||
know = knowledge.load(doc)
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
total = len(knowledge.PARAMETER_ROLES)
|
||||
if missing:
|
||||
# The template is the plant's, so that is where the names belong; a
|
||||
# project file may still override one.
|
||||
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
|
||||
len(missing), total, ', '.join(missing),
|
||||
knowledge.plant_file_in_use(know),
|
||||
'; {0} declared not applicable: {1}'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
if not_applicable else '')
|
||||
mapped = ['{0}={1}'.format(r.split('.')[-1],
|
||||
knowledge.param_names(know, r)[0])
|
||||
for r in knowledge.PARAMETER_ROLES
|
||||
if knowledge.param_names(know, r)]
|
||||
extra = ''
|
||||
if not_applicable:
|
||||
extra = '; {0} declared not applicable at this plant ({1})'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
|
||||
|
||||
|
||||
def _plant_gaps():
|
||||
"""Plant-owned data the buttons need, checked at setup time.
|
||||
|
||||
The per-button refusals gate on `handling.lifter_groups`,
|
||||
`handling.stages` and friends, so a gap is normally found by the user
|
||||
as a per-piece NOT RUN. This probe runs the same gate off the merged
|
||||
tree and says which plant file to edit, so the developer finds it here
|
||||
instead -- see knowledge.plant_layer_gaps for the wording.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
know = knowledge.load(doc)
|
||||
gaps = knowledge.plant_layer_gaps(know)
|
||||
if not gaps:
|
||||
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
|
||||
knowledge.plant_file_in_use(know))
|
||||
texts = [g['text'] for g in gaps]
|
||||
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
|
||||
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
|
||||
|
||||
|
||||
def _designs():
|
||||
from vibe import ticketqc
|
||||
from vibe import knowledge
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
|
||||
if not grouped:
|
||||
# Two states that used to share one FAIL (issue #20). A model with
|
||||
# NO assemblies at all is a job that has not reached the reinforcing
|
||||
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
|
||||
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
|
||||
# Assemblies present but none carrying a design number is the
|
||||
# convention failure this probe exists for.
|
||||
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
|
||||
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
|
||||
if n_asm == 0:
|
||||
return 'WARN', ('no assemblies in this model yet - the job has not '
|
||||
'reached the reinforcing stage; the design-number '
|
||||
'grouping is checked once assemblies exist')
|
||||
return 'FAIL', ('{0} assemblies, none carries a design number - either '
|
||||
'the panel.design_number role is mapped to the wrong '
|
||||
'parameter, or this model does not group pieces so'
|
||||
.format(n_asm))
|
||||
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
|
||||
return 'OK', '{0} designs, {1} pieces: {2}'.format(
|
||||
len(grouped), sum([len(v) for v in grouped.values()]),
|
||||
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
|
||||
|
||||
|
||||
def _summaries():
|
||||
from vibe import ticketqc
|
||||
summaries, sheets = ticketqc.collect_context(doc)
|
||||
if not summaries:
|
||||
return 'FAIL', ('no drafting view is named after a design number - '
|
||||
'Extract Design has nothing to read')
|
||||
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
|
||||
len(sheets))
|
||||
|
||||
|
||||
probe('conventions', 'parameter role mapping', _mapping)
|
||||
probe('conventions', 'plant layer completeness', _plant_gaps)
|
||||
probe('conventions', 'design number groups assemblies', _designs)
|
||||
probe('conventions', 'design summary views', _summaries)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ verdict
|
||||
#
|
||||
# The whole report body is wrapped so an unexpected exception still leaves a
|
||||
# run-log record (outcome='error') before pyRevit shows its own error dialog.
|
||||
|
||||
try:
|
||||
output.print_md('# Self check')
|
||||
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
|
||||
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
|
||||
|
||||
fails = len([r for r in rows if r[2] == 'FAIL'])
|
||||
warns = len([r for r in rows if r[2] == 'WARN'])
|
||||
|
||||
if fails:
|
||||
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
|
||||
output.print_md('A **FAIL** means a QC button will not work here. Start '
|
||||
'with the first one - they cascade.')
|
||||
elif warns:
|
||||
output.print_md('## Ready, with {0} warning(s)'.format(warns))
|
||||
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
|
||||
else:
|
||||
output.print_md('## Everything checks out')
|
||||
|
||||
outcome = 'ok' if not fails else 'fail'
|
||||
runlog.record(
|
||||
'Self Check', doc_title=doc.Title,
|
||||
outcome=outcome,
|
||||
report_text='\n'.join(
|
||||
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
|
||||
except Exception as exc:
|
||||
runlog.record('Self Check',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='error', error=str(exc),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,10 @@
|
||||
title: Takeoff
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,210 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# The takeoff, for the whole job: what it contains, per piece and in total.
|
||||
#
|
||||
# The counting lives in `vibe.takeoffcore` (classification and both totals),
|
||||
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
|
||||
# (the two renderings), all three Revit-free and unit-tested on host CPython.
|
||||
# `vibe.takeoffgather` is the only part that touches a document. This file is
|
||||
# the button and nothing more.
|
||||
#
|
||||
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
|
||||
# reports what a model contains; it has no business changing it.
|
||||
#
|
||||
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
|
||||
# -----------------------------------------------------------
|
||||
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
|
||||
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
|
||||
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
|
||||
# takeoff of the model as it was, and the report says when the index was built
|
||||
# so a reader who has been editing can see the stamp predates his edits. A
|
||||
# button that silently spends ten seconds rebuilding is a button that sometimes
|
||||
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
|
||||
# and it has the progress bar for it.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
|
||||
# ordering is done once for the job, and a per-piece takeoff is already in
|
||||
# the by-piece CSV this writes.
|
||||
# 2. It multiplies by control number, and prints both figures. Per-mark and
|
||||
# per-job answer different questions and a reader will want to check one
|
||||
# against the other, so neither is hidden behind the other.
|
||||
# 3. The multiplier comes from the control numbers on framing elements, via
|
||||
# the job reconciliation's own gatherer -- not from assembly names, which
|
||||
# were measured through the bridge and found to be 755 unique names for 755
|
||||
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
|
||||
# printed as a refusal at the top of the multiplier section, never as a job
|
||||
# where every mark happens to be one piece.
|
||||
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
|
||||
# that looks like a takeoff of a job with nothing in it is the worst
|
||||
# possible output, so it says the index has not been built and names the
|
||||
# button that builds it.
|
||||
# 5. The reports go beside the model, never into the repository. They name
|
||||
# marks, part numbers and quantities for one client's job.
|
||||
#
|
||||
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
|
||||
# --------------------------------------------------
|
||||
# The owner's standing rule for the whole tab, and it lives in
|
||||
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
|
||||
# first and never folded; everything else follows FOLDED; a section with a
|
||||
# problem in it opens itself and says on its header why. The contract that
|
||||
# buys is "if nothing below the headline is open, nothing needs me", and it
|
||||
# is worth more than any one section being visible.
|
||||
#
|
||||
# Here the headline is the ORDER -- hardware by part number, reinforcing by
|
||||
# bar mark, both figures on every line, with what multiplied them written
|
||||
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
|
||||
# tallies fold. The refusal does not: an unmapped control-number role means
|
||||
# the model was never asked how many pieces a mark is, every per-job figure
|
||||
# is an undercount, and that renders OPEN as plain HTML.
|
||||
#
|
||||
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
|
||||
# ---------------------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
|
||||
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
|
||||
# `<details>` does not exist there and fails SILENTLY. So the fold is an
|
||||
# inline `onclick` plus `style.display`, and it has not been exercised inside
|
||||
# Revit by the agent that wrote it. Two things make that survivable:
|
||||
# a section with a problem renders open with no script at all, and the whole
|
||||
# report, nothing folded, is the `.md` written beside the model every press
|
||||
# -- which this page names. A dead toggle costs a file open, never a
|
||||
# sentence.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Takeoff'
|
||||
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import runlog
|
||||
from vibe import takeoffgather as gather
|
||||
from vibe import takeoffreport as report
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Takeoff'
|
||||
|
||||
|
||||
def not_judged(headline, message):
|
||||
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
|
||||
a dialog that vanishes. It must never read as 'judged and found
|
||||
nothing'."""
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
output.print_md('## {0}'.format(headline))
|
||||
output.print_md(message)
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='not-run',
|
||||
report_text='{0}: {1}'.format(headline, message))
|
||||
script.exit()
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='refused',
|
||||
report_text='\n'.join(problems))
|
||||
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
|
||||
|
||||
takeoff, counts, extras = gather.run(doc, know=know)
|
||||
|
||||
coverage = takeoff.get('coverage') or {}
|
||||
if not coverage.get('indexed_marks'):
|
||||
not_judged(
|
||||
'The reinforcing index has not been built for this model',
|
||||
'A takeoff is built from that index and there is nothing in it '
|
||||
'yet. This is not a job with no hardware in it -- it is a '
|
||||
'question nobody has asked the model.\n\n'
|
||||
'Run **Reinforcing Suggestion** once; it builds the index. Then '
|
||||
'press this again.')
|
||||
|
||||
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
|
||||
|
||||
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
|
||||
# printed -- the file then exists even if the printing throws, and the
|
||||
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
|
||||
# every section in full, folded or not, so it can never say less than
|
||||
# this page does.
|
||||
paths, write_error = {}, None
|
||||
try:
|
||||
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
|
||||
except Exception as exc:
|
||||
write_error = exc
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded, and the one line that says whether any of
|
||||
# it needs him. That line IS the contract; without it a page of shut
|
||||
# headers looks the same whether or not something is wrong.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} section(s) below opened themselves — '
|
||||
'they have something in them that needs you.** The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every section is folded; click '
|
||||
'a header to open one.')
|
||||
if paths.get('.md'):
|
||||
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
|
||||
.format(paths['.md']))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
output.print_md('---')
|
||||
if write_error is not None:
|
||||
# The page is still complete, but only while the toggle works -- so
|
||||
# this says which, rather than the old flat report's promise that
|
||||
# what is on the page is all of it.
|
||||
output.print_md(
|
||||
'**The report could not be written to disk:** {0}. Every section '
|
||||
'is still on this page, but the folded ones need the click to '
|
||||
'open; there is no file copy of them this press.'.format(
|
||||
write_error))
|
||||
else:
|
||||
output.print_md('Written beside the model:')
|
||||
for suffix in sorted(paths):
|
||||
output.print_md('- `{0}`'.format(paths[suffix]))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'_Read only. Nothing was written to the model. Index built '
|
||||
'{0}._'.format(coverage.get('built_at') or 'never'))
|
||||
|
||||
# The takeoff is on the page and (usually) in the files beside the model
|
||||
# -- the record is 'ok' with the paths that were written.
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='ok',
|
||||
report_path=paths.get('.md'),
|
||||
report_text=('Takeoff ran; index built {0}. Reports: {1}'
|
||||
.format(coverage.get('built_at') or 'never',
|
||||
', '.join(sorted(paths)) or 'not '
|
||||
'written')))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Takeoff', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,31 @@
|
||||
# A `.pulldown` is a large button with a downward arrow: one icon on the
|
||||
# panel, and every item below opens as a row when it is clicked
|
||||
# (`components.PullDownButtonGroup`, drawn by
|
||||
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
|
||||
# keep their own name table, so the "Project Config" row inside this button
|
||||
# does not collide with the button itself.
|
||||
#
|
||||
# The pulldown's own label. Unlike a stack, a pulldown container does read a
|
||||
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
|
||||
# from it, and `_produce_ui_pulldown` passes that straight to
|
||||
# `create_pulldown_button`.
|
||||
title: Config
|
||||
|
||||
# Row order, top to bottom. Rows are text beside a 16px icon and the
|
||||
# dropdown is as wide as it needs to be, so these titles are free to say
|
||||
# what they mean -- unlike the four large buttons on Tools, which are
|
||||
# rationed to ~9 characters a line.
|
||||
#
|
||||
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
|
||||
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
|
||||
# 2026-09-02, at the owner's request, they are parked again: pulled out of
|
||||
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
|
||||
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
|
||||
# the two lines -- the owner will decide later how to handle them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ProjectConfig
|
||||
- ExtractDesign
|
||||
- SelfCheck
|
||||
- HotReload
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Setup").
|
||||
#
|
||||
# One pulldown, on purpose. These are the commands you run to get a job
|
||||
# ready or to look at it sideways -- not the four you press all day. They
|
||||
# were taking two stack slots and a whole panel between them; behind one
|
||||
# arrow they take none.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Config
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Reinforce Suggestion
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 into the new Modeling.pulldown (see
|
||||
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
|
||||
# four large icons on Tools.panel. A pulldown row renders as text beside a
|
||||
# 16px icon and the dropdown grows to fit the longest row, so the title is
|
||||
# free to say what it means, unlike a large button rationed to ~9 characters
|
||||
# a line.
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,459 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reinforcing suggestion for one assembly: which already-reinforced panels,
|
||||
# between them, account for every feature of this one.
|
||||
#
|
||||
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
|
||||
# a similarity ranking. Every detail of the target has to be covered by
|
||||
# something, and the number of donors should be as small as possible. The
|
||||
# search lives in `vibe.reinforcematch`, the candidate index in
|
||||
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
|
||||
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
|
||||
# the Revit end and nothing more: it resolves the selection, decides how
|
||||
# current the index has to be, and prints.
|
||||
#
|
||||
# READ ONLY, AND THAT IS THE POINT
|
||||
# --------------------------------
|
||||
# No Transaction is opened anywhere in this file or anything it calls. Nothing
|
||||
# is written to the model -- not a parameter, not a rebar, not a copy of a
|
||||
# donor's cage. The tool proposes; a human decides and a human draws it. This
|
||||
# is the single most important constraint on the feature, because a suggestion
|
||||
# tool is exactly the thing somebody will ask to "just copy it in for me", and
|
||||
# the middle band of a wall panel's vertical reinforcing is explicitly PER
|
||||
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
|
||||
# automatically would look like an ordinary suggestion and would be the most
|
||||
# dangerous thing this feature could do.
|
||||
#
|
||||
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
|
||||
# ----------------------------------------------------
|
||||
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
|
||||
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
|
||||
# on which a button press deserves. Which one to spend is a UI policy question
|
||||
# (how many seconds may a click cost, and what does a modifier key mean), so it
|
||||
# belongs at the UI, in one readable place, not buried in the library where two
|
||||
# callers would end up disagreeing.
|
||||
#
|
||||
# The policy, with the measured numbers it rests on:
|
||||
#
|
||||
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
|
||||
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
|
||||
# moved. Nothing moved is the common case and then nothing is rebuilt and
|
||||
# nothing is rewritten: about a second. A full rebuild on every press is
|
||||
# 10 s of frozen UI to answer a question that usually has not changed.
|
||||
# * cold or empty index -- a full build regardless, ~10 s, with a progress
|
||||
# bar. There is no cheaper honest answer available.
|
||||
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
|
||||
#
|
||||
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
|
||||
# ---------------------------------------------------
|
||||
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
|
||||
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
|
||||
# only between donor sets the geometry cannot separate. Owner's ruling: fit
|
||||
# wins. The report marks it and says so.
|
||||
#
|
||||
# The staleness signature is `reinforced | on_hold | master | member-count |
|
||||
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
|
||||
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
|
||||
# catch a geometry edit that leaves the member set alone -- an opening moved or
|
||||
# resized in place, a panel stretched. Those pieces serve a stale measurement
|
||||
# until something else about them moves.
|
||||
#
|
||||
# So an ordinary press can be wrong in exactly that one way, and the report
|
||||
# says so every time, in `reinforcereport.staleness_note`, along with the
|
||||
# instruction to shift-click. The report also always prints when the index was
|
||||
# built, so a reader who knows he has been editing geometry can tell whether
|
||||
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
|
||||
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
|
||||
# inconvenience, not a last resort.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. One target per press. The engineer is reinforcing one piece; a batch of
|
||||
# suggestions is a report nobody reads. Several assemblies preselected asks
|
||||
# which one rather than fanning out.
|
||||
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
|
||||
# told, so it is in the footer of every report and in the staleness note
|
||||
# the report prints when the index may be out of date. It used to be in the
|
||||
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
|
||||
# the report is now the only place that says it. A dialog
|
||||
# on every press would be worse: the answer is the same 99 times out of 100.
|
||||
# 3. The outline audit runs automatically the first time only. It costs one
|
||||
# geometry read per indexed piece, and its answer changes only when the
|
||||
# model does. Never-audited and audited-and-found-none are different
|
||||
# answers, so the report says which one it has.
|
||||
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
|
||||
# four cannot account for everything, the report prints the best four and
|
||||
# names what they miss -- it does not relax to a fifth. That overflow rule
|
||||
# is INFERRED from the ruling, not stated by the owner, and is the thing to
|
||||
# overrule first if this behaves wrongly on a real piece.
|
||||
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
|
||||
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
|
||||
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
|
||||
# 6. A target that is itself already reinforced, or on hold, is answered
|
||||
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
|
||||
# rules -- but silently treating an already-reinforced piece as a fresh
|
||||
# request would hide a misclick.
|
||||
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
|
||||
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
|
||||
# count as a problem is `reinforcereport`'s decision and is stated there,
|
||||
# section by section; the one to argue with first is the staleness rule,
|
||||
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
|
||||
# a folded header names what it holds, and the whole report is written
|
||||
# unfolded to `last-report.txt` beside the index on every press.
|
||||
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
|
||||
# "not judged" and an exit. It used to lose the index stamp and the
|
||||
# staleness note on the way out, which are exactly what a reader needs in
|
||||
# order to know whether to believe a refusal.
|
||||
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
|
||||
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
|
||||
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
|
||||
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
|
||||
# before a single comparison is made, and when no same-design donor exists
|
||||
# this button REFUSES -- it names the design, counts what the pool actually
|
||||
# held, and says the way out. It never falls back to the nearest other
|
||||
# design, not even labelled as one. Packet suffixes do not split a design:
|
||||
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
|
||||
# `specformat.design_key` reduction Ticket QC already uses.
|
||||
# A design number nobody could read is UNJUDGED, never a match -- an index
|
||||
# whose design numbers are all empty (which is what a build with no
|
||||
# knowledge layer produces) refuses and asks for a rebuild.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Reinforce Suggestion'
|
||||
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
|
||||
# the force-full-rebuild gesture. See the notes at the top of this file.
|
||||
__shiftclick__ = True
|
||||
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import compat
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
from vibe import reinforcebind as bind
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import reinforcereport as R
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = R.TITLE
|
||||
|
||||
|
||||
def die(message):
|
||||
"""A usage error: the user has not given us something yet. Not a refusal
|
||||
to judge -- those go in the report, where they survive the dialog."""
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused', report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
# `not_judged` used to live here: a short page and an exit, for a target that
|
||||
# could not be measured. It is gone, and deliberately -- 'could not judge' is
|
||||
# a RESULT, and printing it on a stripped-down page dropped the index stamp
|
||||
# and the staleness note, which are precisely what tells a reader whether to
|
||||
# believe the refusal. `reinforcereport.render` now returns the whole page for
|
||||
# that case too, with the headline saying NOT JUDGED, and this file prints it
|
||||
# the same way it prints an answer.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# ------------------------------------------------------------ force rebuild
|
||||
|
||||
def _forced():
|
||||
"""True when the button was shift-clicked. Never fatal if unavailable."""
|
||||
try:
|
||||
from pyrevit import EXEC_PARAMS
|
||||
return bool(EXEC_PARAMS.config_mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
#
|
||||
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, nothing
|
||||
# picked offers a list. The one difference is that this tool answers about
|
||||
# ONE piece, so several preselected assemblies ask which.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not picked_assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
picked_assemblies = owners
|
||||
|
||||
if selection and not picked_assemblies:
|
||||
die('What is selected is not part of an assembly, so there is no piece '
|
||||
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
|
||||
'any member of it -- and run this again.')
|
||||
|
||||
all_assemblies = index.assemblies(doc)
|
||||
by_name = dict(all_assemblies)
|
||||
|
||||
# The knowledge file is read here rather than further down, because the pick
|
||||
# list below needs the DECLARED SCOPE before it can offer anything.
|
||||
try:
|
||||
know = knowledge.load(doc)
|
||||
except Exception as exc:
|
||||
know = None
|
||||
output.print_md('> The knowledge file could not be read ({0}). No scope '
|
||||
'is declared, so every assembly in the model is offered '
|
||||
'— and **design numbers will be blank**, which '
|
||||
'since 2026-09-01 means this run will REFUSE rather than '
|
||||
'answer: a donor has to carry the same design number as '
|
||||
'the target, and an unread design number is never assumed '
|
||||
'to match. Fix the knowledge file, or shift-click to '
|
||||
'rebuild the index once it reads.'.format(exc))
|
||||
|
||||
the_scope = scopelib.of(know) if know else scopelib.Scope()
|
||||
|
||||
if not picked_assemblies:
|
||||
# The list this button OFFERS is scoped: this is the TOOL choosing, and
|
||||
# it chooses inside the declared scope. What the user picks HIMSELF is
|
||||
# never filtered -- see the branches below and the report's annotation.
|
||||
names, left_alone = the_scope.split_names(
|
||||
sorted(n for n, _a in all_assemblies))
|
||||
if not names:
|
||||
die('There is nothing here to pick from.\n\n{0}'
|
||||
.format(the_scope.line(left_alone, total=len(all_assemblies),
|
||||
unit='assembly')))
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False, title=TITLE + ' - which piece',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Piece picker closed -- no target chosen.')
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
elif len(picked_assemblies) > 1:
|
||||
names = sorted(str(a.Name) for a in picked_assemblies)
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False,
|
||||
title=TITLE + ' - one piece at a time',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Piece picker closed -- no target chosen.')
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
else:
|
||||
target_name = str(picked_assemblies[0].Name)
|
||||
|
||||
target_asm = by_name.get(target_name)
|
||||
if target_asm is None:
|
||||
die('{0} is not an assembly in this model.'.format(target_name))
|
||||
|
||||
|
||||
def _flag(el, pname):
|
||||
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
|
||||
try:
|
||||
p = el.LookupParameter(pname)
|
||||
return None if p is None else int(p.AsInteger())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
target_reinforced = _flag(target_asm, index.P_REINFORCED)
|
||||
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
|
||||
|
||||
# ------------------------------------------------------------------ the index
|
||||
#
|
||||
# See the notes at the top of this file for the policy. The cheap plan is
|
||||
# computed here rather than inside `refresh` so that the commonest press --
|
||||
# nothing changed -- can skip `refresh` entirely and avoid a second
|
||||
# signature pass, and so the progress bar knows how many pieces it is about
|
||||
# to build.
|
||||
|
||||
forced = _forced()
|
||||
|
||||
# `know` was loaded above, for the pick list. Past the scope it is used for
|
||||
# one optional thing -- reading each piece's design number for the report --
|
||||
# so a missing or broken one costs a label, not the answer. It is not worth
|
||||
# stopping the run over, unlike in Ticket QC where every check depends on it.
|
||||
|
||||
cache = index.read(doc)
|
||||
if cache.get('_load_error'):
|
||||
output.print_md('> The index on disk could not be read ({0}); it is being '
|
||||
'rebuilt from the model. A cache is derived data, so this '
|
||||
'costs time and nothing else.'
|
||||
.format(cache['_load_error']))
|
||||
forced = True
|
||||
|
||||
live = index.live_signatures(doc, all_assemblies, know=know)
|
||||
the_plan = core.plan(cache, live)
|
||||
todo = len(the_plan['build'])
|
||||
refreshed = bool(todo or the_plan['drop']) or forced
|
||||
|
||||
refresh_report = None
|
||||
if refreshed:
|
||||
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
|
||||
bar = 'Indexing donors ({value} of {max_value})'
|
||||
if forced:
|
||||
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
|
||||
with forms.ProgressBar(title=bar, cancellable=False) as pb:
|
||||
state = {'n': 0}
|
||||
|
||||
def _tick(_name):
|
||||
state['n'] += 1
|
||||
pb.update_progress(state['n'], max(total, 1))
|
||||
|
||||
pb.update_progress(0, max(total, 1))
|
||||
cache, refresh_report = index.refresh(
|
||||
doc, rebuild_all=forced, know=know, on_piece=_tick)
|
||||
|
||||
# The outline audit: once, unless forced. It is what keeps an approximated
|
||||
# edge condition from ever being printed as a measured exact match.
|
||||
audit_needed = forced or (bind.load_audit(doc) is None)
|
||||
|
||||
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
|
||||
indeterminate=True):
|
||||
suggestion, info = bind.suggest_for_piece(
|
||||
doc, target_name, refresh=False, audit=audit_needed, know=know)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- report
|
||||
#
|
||||
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
|
||||
#
|
||||
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
|
||||
# 可以折叠的样子。"
|
||||
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
|
||||
# 给做成默认折叠。"
|
||||
#
|
||||
# So the target and the donor proposals are the page, printed big and first
|
||||
# through pyRevit's own markdown and tables -- which is also what can linkify
|
||||
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
|
||||
# section that has a problem in it opens itself. That turns the page into a
|
||||
# contract: if nothing below the answer is open, nothing needs him.
|
||||
#
|
||||
# The rule lives in `vibe.foldreport`, not here, and the sections come from
|
||||
# `reinforcereport.render` -- the same sections the offline CLI prints as
|
||||
# plain text, so the two cannot say different things.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
|
||||
# -----------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
|
||||
# hosted in WinForms, running in IE7 document mode unless the host process
|
||||
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
|
||||
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
|
||||
# element renders inline and the "collapsed" content is simply always visible.
|
||||
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
|
||||
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
|
||||
# but the toggle has NOT been exercised inside Revit, so it is designed for
|
||||
# failure rather than trusted:
|
||||
#
|
||||
# * a section with a problem in it renders OPEN, as plain HTML, and does not
|
||||
# need the script to run at all;
|
||||
# * the whole report, nothing folded, is written to `last-report.txt` beside
|
||||
# the index every press, and the page says where. A fold that will not
|
||||
# open therefore costs a file open, never a lost sentence.
|
||||
|
||||
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
|
||||
'last-report.txt')
|
||||
|
||||
|
||||
def _donor_link(_name, record):
|
||||
"""pyRevit's clickable element id, for a donor's line in the headline."""
|
||||
if not (record or {}).get('asm_id'):
|
||||
return None
|
||||
try:
|
||||
return output.linkify(ElementId(record['asm_id']), 'select assembly')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
best = suggestion.best if suggestion is not None else None
|
||||
rows = bind.explain(suggestion, info, best) if best is not None else []
|
||||
|
||||
sections = R.render(suggestion, info, rows, cache,
|
||||
model=doc.Title,
|
||||
refresh_report=refresh_report,
|
||||
rebuilt_all=forced, refreshed=refreshed,
|
||||
offline=False,
|
||||
reinforced=target_reinforced, on_hold=target_on_hold,
|
||||
full_path=full_path, donor_link=_donor_link)
|
||||
|
||||
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
|
||||
# even if the printing throws.
|
||||
try:
|
||||
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
|
||||
# string, and a piece mark or design number carrying a non-ASCII character
|
||||
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
payload = compat.as_text(fold.text(sections, show_all=True))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(exc))
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
if opened:
|
||||
output.print_md('---')
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('---')
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one. The whole report is also '
|
||||
'in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
# The report is on the page and the full text is on disk -- record the run
|
||||
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
|
||||
# the page led with; keep the record short, since report_text is capped.
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path,
|
||||
report_text=('Target {0}; suggestion {1}'
|
||||
.format(target_name,
|
||||
'found' if best is not None
|
||||
else 'not judged')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,28 @@
|
||||
title: Reinf QC
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses (it shares
|
||||
# `lib/vibe/ticketqc_run.py` and `ticketchecks.py` with Ticket QC) is
|
||||
# visible without a Revit restart. See TicketQC.pushbutton/bundle.yaml for
|
||||
# the full explanation; this one carries the same key for the same reason.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Added 2026-09-05, on the owner's own instruction: a cut-down TicketQC for
|
||||
# a panel whose reinforcing is finished (the model already has everything it
|
||||
# should have) but whose ticketing has not started. Shares its engine with
|
||||
# TicketQC -- both call into `lib/vibe/ticketqc_run.py`, which takes a
|
||||
# `mode` of 'ticket' or 'reinforcing' -- and reads the same evidence one
|
||||
# stage earlier: no ticket sheet is required, and any check whose evidence
|
||||
# is a ticket artefact (a hatch, a callout, a BOM table, a sheet name...)
|
||||
# does not run here. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` for
|
||||
# exactly which of the 41 checks that is, and why, one by one.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means -- kept
|
||||
# short anyway so it reads at a glance beside "Ticket QC".
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check a panel's MODELLED reinforcing against its design spec -- for a
|
||||
# piece whose reinforcing is finished (the model already has everything it
|
||||
# should have) but whose ticketing has not started yet.
|
||||
#
|
||||
# A cut-down Ticket QC: same engine, same evidence-first discipline, but
|
||||
# only the checks whose evidence is the panel's modelled content run here
|
||||
# (connectors/embeds, lifters, strands, bars, WWF, solid zones as modelled,
|
||||
# member interference, weight, cast extras, release ratio, the handling
|
||||
# result, lift positions/points). Anything that needs a ticket artefact --
|
||||
# a sheet, a placed view, a printed call-out, a hatch pattern, a BOM table,
|
||||
# a bed-plan note -- does not run here; it is listed, with its reason, in
|
||||
# the report's own "Not checked at this stage" section rather than left
|
||||
# silent. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` /
|
||||
# `REINFORCING_EXCLUDED` for the check-by-check classification, and
|
||||
# `lib/vibe/ticketqc_run.py` for the shared engine this and Ticket QC both
|
||||
# call.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Reinf QC'
|
||||
|
||||
from pyrevit import revit
|
||||
|
||||
from vibe import ticketqc_run
|
||||
|
||||
ticketqc_run.run(revit.doc, revit.uidoc, mode='reinforcing')
|
||||
@@ -0,0 +1,27 @@
|
||||
title: Ticket QC
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom, and it is the hottest button here --
|
||||
# 51 commits to its six modules in the seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 into the new Modeling.pulldown (see
|
||||
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
|
||||
# four large icons on Tools.panel. One line, no `\n`: a pulldown row renders
|
||||
# as text beside a 16px icon and the dropdown grows to fit the longest row,
|
||||
# so the title is free to say what it means -- unlike a large button, which
|
||||
# is as wide as its widest line.
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check shop tickets against their design spec.
|
||||
#
|
||||
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
|
||||
# then compares every piece of the chosen design numbers against them.
|
||||
#
|
||||
# Every CHECK here is pure arithmetic - the same model always gives the same
|
||||
# answer, and every row of the checklist table is computed. One thing in the
|
||||
# report is not: with the run-time option `vision_escalation` on, the checks
|
||||
# code cannot settle (5.5) are escalated to a small model reading one rendered
|
||||
# view. That result is quarantined - its own list, its own codes, its own
|
||||
# section, and it can never set a checklist row. See vibe.ticketvision and
|
||||
# docs/ticket-checklist.md section 8bis.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction, including the image export.
|
||||
#
|
||||
# THE WHOLE BUTTON MOVED into `lib/vibe/ticketqc_run.py` on 2026-09-05, so
|
||||
# the new Reinforcing QC button could reuse it instead of these 765 lines
|
||||
# being copy-pasted -- see that module's own header for exactly what is
|
||||
# shared and what differs. This script is `mode='ticket'`, which is BYTE
|
||||
# FOR BYTE what used to live here: nothing about Ticket QC's own behaviour
|
||||
# changed in the move.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Ticket QC'
|
||||
|
||||
from pyrevit import revit
|
||||
|
||||
from vibe import ticketqc_run
|
||||
|
||||
ticketqc_run.run(revit.doc, revit.uidoc, mode='ticket')
|
||||
@@ -0,0 +1,27 @@
|
||||
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
|
||||
# why a container may carry a `title:`.
|
||||
title: Modeling
|
||||
|
||||
# THE MODELLING-STAGE TOOLS, gathered under one arrow (2026-09-05, on the
|
||||
# owner's own instruction: "加一个 Modeling pulldown,把 Reinforcing 和
|
||||
# TicketQC 放进去"). ReinforceSuggest and TicketQC moved here from being two
|
||||
# of the four large buttons this panel used to show side by side; the fourth
|
||||
# tool that lived here, Panel Lift Check, moved the other way -- onto
|
||||
# Design.panel/DesignHub.pulldown, see that bundle's own comment. Snapshot
|
||||
# stays put as the one remaining large icon.
|
||||
#
|
||||
# ReinforcingQC is new: a cut-down TicketQC for a panel whose reinforcing is
|
||||
# finished but whose ticketing has not started yet -- see
|
||||
# lib/vibe/ticketqc_run.py and lib/vibe/ticketchecks.REINFORCING_CHECKS for
|
||||
# what it does and does not judge at that stage. It sits beside TicketQC
|
||||
# because it shares the engine and reads the same evidence, one stage
|
||||
# earlier.
|
||||
#
|
||||
# Order is the natural order of the work: suggest the reinforcing, check the
|
||||
# reinforcing once it is modelled, check the ticket once it is drawn.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ReinforceSuggest
|
||||
- ReinforcingQC
|
||||
- TicketQC
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,11 @@
|
||||
title: Snapshot
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
|
||||
# four large buttons on this panel all come out the same width.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,889 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
||||
#
|
||||
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
||||
# ------------------------------------
|
||||
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
||||
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
||||
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
||||
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
||||
#
|
||||
# So the whole cycle moved out of this button and into
|
||||
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
||||
# it, and can be started by Claude. What is left here is the one job that
|
||||
# genuinely requires the live session and cannot be done anywhere else:
|
||||
# handing over the model's IDENTITY. Only the open document can cheaply report
|
||||
# its cloud region and GUIDs, and without those nothing else can find the
|
||||
# model to copy.
|
||||
#
|
||||
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
||||
# ----------------------------------------------
|
||||
# The design this replaced expected an in-session background open --
|
||||
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
||||
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
||||
# does not exist for this model, and the reason is not a version guard:
|
||||
#
|
||||
# * a cloud model cannot be opened detached at all. Any
|
||||
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
||||
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
||||
# does not allow it and that SaveAs is the way; and the UI agrees --
|
||||
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
||||
# * Revit holds one Document per model per session, so the live session
|
||||
# cannot open the model it already has open.
|
||||
# * the only call that could copy the document already in memory is
|
||||
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
||||
# here it would leave HIS Revit sitting in a local copy, disconnected
|
||||
# from the cloud. There is no SaveCopyAs.
|
||||
#
|
||||
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
||||
# The copy is therefore made by a Revit that is not his, and this button's
|
||||
# phase C is a spawn rather than an open.
|
||||
#
|
||||
# THE OLD RITUAL, KEPT
|
||||
# ---------------------
|
||||
# `snapshot.after_close` is NOT retired. The close-model path below is
|
||||
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
||||
# day, where the answer really is "close everything first". It is simply no
|
||||
# longer the only way to get a copy, and it is no longer the default.
|
||||
#
|
||||
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
||||
# ------------------------------------------------
|
||||
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
||||
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
||||
# session and a detached sandbox ran side by side all morning. What nobody has
|
||||
# measured is three, so the pipeline sequences to keep it to two and this
|
||||
# button never opens a model at all.
|
||||
#
|
||||
# The close-model ritual, still here as one of the options:
|
||||
#
|
||||
# he syncs his act, outside this button. This button NEVER
|
||||
# calls SynchronizeWithCentral and never asks about it.
|
||||
# phase A this button indexes the OPEN document. The model is
|
||||
# already in RAM and already paid for; a snapshot read
|
||||
# from the live document at sync state and one read
|
||||
# from a detached copy of that same state contain the
|
||||
# same numbers. Seconds, not minutes, and no copy.
|
||||
# phase B Revit closes. The press IS the authorisation for the
|
||||
# close -- that is what the button is called.
|
||||
# phase C AFTER Revit has exited and the 10 GB is free, a
|
||||
# deferred host process opens the model detached, saves
|
||||
# a timestamped copy and quits. Optional, off unless
|
||||
# the job layer configures it, and its outcome lands in
|
||||
# a log this button reads out on the next press.
|
||||
#
|
||||
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
||||
# --------------------------------------------------------------------
|
||||
# No Transaction is opened in this file or in anything it calls. Nothing is
|
||||
# written to the model -- not a parameter, not a workset, not a save. The only
|
||||
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
||||
# file, in another process, after this one has exited. The sync that used to
|
||||
# live here has been removed entirely at the owner's instruction.
|
||||
#
|
||||
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
||||
# ----------------------------------------------------
|
||||
# Revit is closed LAST and only after phase A has reported complete. A crash
|
||||
# halfway through the index must leave the session open, with the traceback on
|
||||
# screen, because the alternative is a closed Revit and no way to find out
|
||||
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
||||
#
|
||||
# The other half of that discipline is phase C. It runs when nobody is
|
||||
# watching, in a process this one has already outlived, and a day it silently
|
||||
# did not run looks exactly like a day it worked. So the request is journalled
|
||||
# BEFORE the close and the deferred process journals its own outcome; a
|
||||
# request with nothing after it is reported, in capitals, at the top of the
|
||||
# next press. Silence is not an acceptable report.
|
||||
#
|
||||
# JUDGEMENT CALLS, for the owner to overrule
|
||||
# -------------------------------------------
|
||||
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
||||
# press elsewhere trusts the cheap signature, which cannot see an opening
|
||||
# moved or resized in place. This is the daily archival snapshot and it is
|
||||
# the one that gets trusted months later, so it pays the measured 6-10 s
|
||||
# for an answer that owes nothing to yesterday's cache.
|
||||
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
||||
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
||||
# lunchtime checkpoint). Cancel writes nothing at all.
|
||||
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
||||
# declared scope and no parameter-role mapping, so the index would measure
|
||||
# the whole model and mark every piece could-not-judge on the review
|
||||
# signal -- and would overwrite a good index to do it. That is worth a
|
||||
# dialog. It is not worth a refusal on a machine that legitimately has no
|
||||
# job layer yet.
|
||||
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
||||
# default and None means not configured, never "pick somewhere". The button
|
||||
# says so instead of guessing a folder for a 300 MB file.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Snapshot'
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import runlog
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Snapshot & Close'
|
||||
|
||||
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
||||
# because three separate places need it now (the deferred tool, the cycle
|
||||
# pipeline and the runner) and computing it three times is how two of them end
|
||||
# up one directory apart.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
||||
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
||||
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
||||
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
||||
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
||||
|
||||
CYCLE = 'Snapshot, then run the sandbox cycle'
|
||||
CLOSE = 'Snapshot, then close Revit'
|
||||
STAY = 'Snapshot only, stay open'
|
||||
CANCEL = 'Cancel'
|
||||
|
||||
|
||||
def die(message):
|
||||
runlog.record('Snapshot Close',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='refused', report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
if doc is None:
|
||||
# `doc.Title` would itself fail on a None document -- record what there is
|
||||
# to record, then hand the refusal to the dialog.
|
||||
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
|
||||
report_text='No model is open, so there is nothing to '
|
||||
'snapshot.')
|
||||
die('No model is open, so there is nothing to snapshot.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- versions
|
||||
|
||||
def _revit_version():
|
||||
try:
|
||||
app = doc.Application
|
||||
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pyrevit_version():
|
||||
try:
|
||||
from pyrevit import versionmgr
|
||||
return versionmgr.get_pyrevit_version().get_formatted()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _revit_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# ------------------------------------------------------ what already happened
|
||||
#
|
||||
# Read and printed FIRST, before anything is decided, because the one thing
|
||||
# this design can lose is a phase C that never ran -- and the only moment
|
||||
# anybody is looking is the moment they press this button again.
|
||||
|
||||
know = knowledge.load(doc)
|
||||
job_folder = None
|
||||
try:
|
||||
from vibe import designspec
|
||||
job_folder = designspec.root_dir(doc)
|
||||
except Exception:
|
||||
job_folder = None
|
||||
|
||||
log = S.log_path(job_folder) if job_folder else None
|
||||
history = S.read(log) if log else []
|
||||
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
for line in S.summary(history):
|
||||
if line.startswith('WARNING'):
|
||||
output.print_md('**{0}**'.format(line))
|
||||
else:
|
||||
output.print_md('- {0}'.format(line))
|
||||
|
||||
|
||||
# ---------------------------------------------------- is this job set up
|
||||
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
die('\n\n'.join(problems))
|
||||
|
||||
has_job_layer = False
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
has_job_layer = True
|
||||
|
||||
if not has_job_layer:
|
||||
keep_going = forms.alert(
|
||||
'This model has no job knowledge file.\n\n'
|
||||
'Without it there is no declared scope and no parameter-name '
|
||||
'mapping, so the index will measure every assembly in the model and '
|
||||
'will record "could not judge" for the review signal on all of them '
|
||||
'-- and it will overwrite whatever index is there now.\n\n'
|
||||
'Snapshot anyway?',
|
||||
title=TITLE, yes=True, no=True)
|
||||
if not keep_going:
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='No job knowledge file; user declined to '
|
||||
'snapshot anyway.')
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------- phase C settings
|
||||
#
|
||||
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
||||
# happen after the close, rather than promising a detached copy that turns out
|
||||
# not to be configured.
|
||||
|
||||
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
||||
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
||||
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
||||
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
||||
# TEXT, so two call sites spelling the same value differently are reported as
|
||||
# disagreeing about what "unset" means.
|
||||
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
||||
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
||||
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
||||
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
||||
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
||||
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
||||
or S.revit_exe_guess(revit_year)
|
||||
if not revit_year:
|
||||
# `pyrevit run` (both the cycle route's detach step and the close-model
|
||||
# route) takes an optional `--revit=<year>`, and a job layer that states
|
||||
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
||||
# job's shape -- used to leave it off entirely. On a machine with more
|
||||
# than one Revit version attached that is not a neutral omission: see
|
||||
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
||||
# is named for. `revit_exe`'s own path almost always carries the year.
|
||||
revit_year = S.revit_year_from_exe(revit_exe)
|
||||
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
||||
|
||||
|
||||
def _pyrevit_cli():
|
||||
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
||||
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
||||
return pyrevit_exe
|
||||
if pyrevit_exe:
|
||||
return None # stated and wrong: never fall back
|
||||
return S.pyrevit_exe_guess()
|
||||
|
||||
|
||||
def _copy_blocker():
|
||||
"""What stops ANY detached copy being made, whichever route. Or None."""
|
||||
if not detached_dir:
|
||||
return ('not configured -- set snapshot.detached_dir in the job '
|
||||
'knowledge file to switch it on')
|
||||
if not os.path.isdir(detached_dir):
|
||||
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
||||
if _pyrevit_cli() is None:
|
||||
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
||||
if liftexport.find_python(know) is None:
|
||||
return 'no host Python was found to run the deferred step'
|
||||
return None
|
||||
|
||||
|
||||
def phase_c_blocker():
|
||||
"""Why the OLD after-close route cannot run, or None.
|
||||
|
||||
`snapshot.after_close` is deliberately still consulted here and nowhere
|
||||
else: it gates the close-model ritual, which is the thing it has always
|
||||
meant. The cycle route below does not read it -- it is not chained to an
|
||||
exit, so "after close" has nothing to say about it, and making one key
|
||||
mean two things is how a setting becomes untrustworthy.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not after_close:
|
||||
return ('switched off -- snapshot.after_close is false in the job '
|
||||
'knowledge file')
|
||||
return None
|
||||
|
||||
|
||||
def cycle_blocker():
|
||||
"""Why the sandbox cycle cannot be started, or None.
|
||||
|
||||
Note what is NOT in here: `after_close`, and any check on other Revits.
|
||||
The cycle runs beside this session on purpose -- that is the whole point
|
||||
of it -- and it sequences its own steps so that at most two Revits are
|
||||
ever resident.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not revit_exe:
|
||||
return ('no Revit.exe to open the sandbox with -- set '
|
||||
'snapshot.revit_exe or snapshot.revit_year')
|
||||
if not os.path.isfile(revit_exe):
|
||||
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
||||
if not batch:
|
||||
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
||||
if not os.path.isfile(CYCLE_TOOL):
|
||||
return 'tools/snapshot_cycle.py is not where this button expects it'
|
||||
return None
|
||||
|
||||
|
||||
blocker = phase_c_blocker()
|
||||
cycle_stop = cycle_blocker()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the decision
|
||||
|
||||
if cycle_stop is None:
|
||||
cycle_tail = (
|
||||
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
||||
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
||||
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
||||
.format(detached_dir, ' + '.join(batch)))
|
||||
else:
|
||||
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
||||
|
||||
if blocker is None:
|
||||
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
||||
'copy is written to:\n{0}'.format(detached_dir))
|
||||
else:
|
||||
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
||||
|
||||
choice = forms.alert(
|
||||
'Sync first if you have not -- this button does not sync, and it never '
|
||||
'touches the model.\n\n'
|
||||
'It rebuilds the reinforcing index from the model as it stands now and '
|
||||
'stamps it with where it came from.' + cycle_tail + close_tail,
|
||||
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
||||
|
||||
if choice not in (CYCLE, CLOSE, STAY):
|
||||
output.print_md('---')
|
||||
output.print_md('Cancelled. Nothing was read, written or closed.')
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='User cancelled the snapshot dialog.')
|
||||
script.exit()
|
||||
|
||||
closing = (choice == CLOSE)
|
||||
cycling = (choice == CYCLE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase A
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase A - indexing the open model')
|
||||
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
||||
'an earlier press. Measured at 6-10 s on this model.')
|
||||
|
||||
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
||||
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
||||
# only loads the file, it never touches the model. Absent or damaged is fine:
|
||||
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
||||
try:
|
||||
old_cache = index.read(doc)
|
||||
except Exception:
|
||||
old_cache = None
|
||||
|
||||
report = None
|
||||
cache = None
|
||||
failure = None
|
||||
try:
|
||||
# An upper bound for the bar: a full rebuild measures at most every
|
||||
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
||||
# buys a progress bar that moves, instead of one pinned at 100%.
|
||||
total = max(len(index.assemblies(doc)), 1)
|
||||
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
||||
cancellable=False) as bar:
|
||||
state = {'n': 0}
|
||||
|
||||
def tick(_name):
|
||||
state['n'] += 1
|
||||
bar.update_progress(state['n'], total)
|
||||
|
||||
bar.update_progress(0, total)
|
||||
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
||||
on_piece=tick)
|
||||
except Exception:
|
||||
failure = traceback.format_exc()
|
||||
|
||||
if failure is not None:
|
||||
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
||||
output.print_md('## Phase A FAILED - Revit has not been closed')
|
||||
output.print_md('```\n{0}\n```'.format(failure))
|
||||
if log:
|
||||
S.append(log, {'event': 'phase-a-failed',
|
||||
'error': failure.splitlines()[-1]})
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Phase A failed - Revit has not been closed.',
|
||||
error=failure.splitlines()[-1])
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- stamp it with where it came from
|
||||
#
|
||||
# `refresh` writes the index; this adds the block that says whose numbers
|
||||
# those are, and rewrites. It is written on EVERY press, including the press
|
||||
# where nothing changed and `refresh` therefore did not save -- an index whose
|
||||
# provenance is a week older than its numbers is worse than none.
|
||||
|
||||
source_path = None
|
||||
try:
|
||||
source_path = doc.PathName or None
|
||||
except Exception:
|
||||
source_path = None
|
||||
|
||||
prov = S.provenance(
|
||||
'A', S.LIVE_DOC,
|
||||
source_path=source_path,
|
||||
source_stamp=None, # a live document has no snapshot stamp
|
||||
source_title=index.doc_key(doc),
|
||||
revit=_revit_version(),
|
||||
pyrevit=_pyrevit_version(),
|
||||
job_folder=job_folder,
|
||||
note=('read from the open document immediately after the engineer synced; '
|
||||
'no detached copy was involved in these numbers'))
|
||||
|
||||
pieces = len(cache.get('pieces') or {})
|
||||
try:
|
||||
cache['provenance'] = prov
|
||||
core.save(report['path'], cache)
|
||||
stamped = True
|
||||
except Exception:
|
||||
stamped = False
|
||||
|
||||
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
||||
.format(report['assemblies'], pieces))
|
||||
output.print_md('- scope: {0}'.format(report['scope']))
|
||||
output.print_md('- built in {0} s, written to `{1}`'
|
||||
.format(report['seconds_total'], report['path']))
|
||||
if stamped:
|
||||
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
||||
else:
|
||||
output.print_md('- **the provenance stamp could not be written** -- this '
|
||||
'index cannot say where it came from')
|
||||
|
||||
missing = (report.get('coverage') or {}).get('missing') or []
|
||||
if missing:
|
||||
output.print_md('- **{0} pieces could not be measured** and are not in '
|
||||
'this snapshot'.format(len(missing)))
|
||||
|
||||
if log:
|
||||
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
||||
'path': report['path'], 'scope': report['scope'],
|
||||
'seconds': report['seconds_total'],
|
||||
'provenance': prov})
|
||||
|
||||
|
||||
# ------------------------------------------------- update statistics
|
||||
#
|
||||
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
||||
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
||||
# press changed: which pieces are newly donors, how many features each carries,
|
||||
# and which feature-IDs went into the shared vocabulary for the first time
|
||||
# versus which were already known. READ ONLY -- this only reads the two caches
|
||||
# and one cheap signature pass; nothing about the model is written.
|
||||
#
|
||||
# `index.refresh` already grew the registry (via `core.register_features`), so
|
||||
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
||||
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
||||
# runs, redone here so the diff sees the model exactly as it stands now.
|
||||
|
||||
output.print_md('## Update statistics -- what this snapshot marked')
|
||||
try:
|
||||
live = index.live_signatures(doc, know=know)
|
||||
stats = core.snapshot_stats(old_cache, cache, live)
|
||||
except Exception:
|
||||
stats = None
|
||||
output.print_md('- **the update statistics could not be computed** -- the '
|
||||
'index was still written; only this summary is missing')
|
||||
|
||||
if stats is not None:
|
||||
newly = stats['newly']
|
||||
if stats['first_snapshot']:
|
||||
output.print_md('- **first snapshot for this index** -- there was '
|
||||
'nothing to diff against, so every reinforced piece '
|
||||
'below is new to the donor pool, not a change since '
|
||||
'last press.')
|
||||
if not newly:
|
||||
output.print_md('- no piece was newly marked reinforced since the last '
|
||||
'snapshot. (This is "nothing changed", not "nothing '
|
||||
'is reinforced".)')
|
||||
else:
|
||||
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
||||
'{1}'.format(len(newly), ', '.join(newly)))
|
||||
output.print_md('- feature-IDs new to the vocabulary this press: '
|
||||
'**{0}**; already known: **{1}**'
|
||||
.format(len(stats['new_ids']),
|
||||
len(stats['known_ids'])))
|
||||
for panel in stats['panels']:
|
||||
if panel['feature_count'] is None:
|
||||
output.print_md(' - **{0}** -- could not be measured; no '
|
||||
'features recorded'.format(panel['name']))
|
||||
continue
|
||||
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
||||
if panel['new_ids']:
|
||||
bits.append('new IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
||||
if panel['known_ids']:
|
||||
bits.append('known IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
||||
if panel['unregistered']:
|
||||
bits.append('{0} unregistered'.format(panel['unregistered']))
|
||||
output.print_md(' - **{0}** -- {1}'.format(
|
||||
panel['name'], '; '.join(bits)))
|
||||
|
||||
if log:
|
||||
# Journal the same figures. Per-panel id lists are kept so the ledger
|
||||
# can be replayed without the model; `first_snapshot` distinguishes a
|
||||
# cold index from a genuine no-change press.
|
||||
S.append(log, {'event': 'phase-a-update-stats',
|
||||
'first_snapshot': stats['first_snapshot'],
|
||||
'newly': newly,
|
||||
'new_ids': stats['new_ids'],
|
||||
'known_ids': stats['known_ids'],
|
||||
'panels': stats['panels']})
|
||||
|
||||
|
||||
# ---------------------------------------------- handing the model over
|
||||
#
|
||||
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
||||
# HOW to find the model again (its identity). Built once, here, so the cycle
|
||||
# and the close-model ritual cannot drift into naming files differently.
|
||||
|
||||
|
||||
def _source_block():
|
||||
"""How another session is to find this model again.
|
||||
|
||||
Cloud GUIDs and file path both, when both are available: a cloud model is
|
||||
reopened from its GUIDs, a file-based one from its path, and which one a
|
||||
job is was never something to assume. A cloud model reports no usable
|
||||
local path, which is the ordinary case here, not an error.
|
||||
"""
|
||||
src = {'path': source_path, 'title': index.doc_key(doc)}
|
||||
try:
|
||||
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
||||
mp = doc.GetCloudModelPath()
|
||||
src['cloud'] = {
|
||||
'region': str(mp.Region),
|
||||
'project_guid': str(mp.GetProjectGUID()),
|
||||
'model_guid': str(mp.GetModelGUID()),
|
||||
}
|
||||
except Exception as exc:
|
||||
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
||||
# path is then the only handle and the runner says so if it is absent.
|
||||
src['cloud'] = None
|
||||
src['cloud_error'] = str(exc)
|
||||
return src
|
||||
|
||||
|
||||
def _write_request(run_id, plan, source, extra=None):
|
||||
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
||||
request = {
|
||||
'run': run_id,
|
||||
'base': plan['title'],
|
||||
'layout': layout,
|
||||
'worksets': worksets,
|
||||
'detached_dir': detached_dir,
|
||||
'plan': plan,
|
||||
'log': log,
|
||||
'job_folder': job_folder,
|
||||
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'requested_by_revit': _revit_version(),
|
||||
'source': source,
|
||||
}
|
||||
request.update(extra or {})
|
||||
req_dir = os.path.join(detached_dir, '_requests')
|
||||
req_path = os.path.join(req_dir, run_id + '.json')
|
||||
if not os.path.isdir(req_dir):
|
||||
os.makedirs(req_dir)
|
||||
import json
|
||||
handle = open(req_path, 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
# And the identity on its own, where the host pipeline looks for it.
|
||||
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
||||
# machine knows those except a session with the model open -- this one.
|
||||
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
||||
# later with no Revit anywhere and still find the model to copy. It is
|
||||
# identity, never state: it says which model, never anything about a copy.
|
||||
try:
|
||||
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
||||
try:
|
||||
cache.write(json.dumps(source, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
cache.close()
|
||||
except Exception:
|
||||
pass # a lost cache costs a flag, not a snapshot
|
||||
return req_path
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
"""Start a host process that outlives this one. Returns its pid.
|
||||
|
||||
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
||||
minutes with nobody watching, and a console window popping up over the
|
||||
engineer's Revit is the kind of thing that gets a tool switched off.
|
||||
"""
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = argv[0]
|
||||
psi.Arguments = S.quote(argv[1:])
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
psi.WorkingDirectory = REPO_ROOT
|
||||
return Process.Start(psi).Id
|
||||
|
||||
|
||||
def _job_knowledge_path():
|
||||
"""The job knowledge file, so the host pipeline reads the same layer.
|
||||
|
||||
Handed over as a path rather than as resolved values: the pipeline needs
|
||||
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
||||
block, and a button that copies six settings across is a button that
|
||||
forgets the seventh the day one is added.
|
||||
"""
|
||||
try:
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- the sandbox cycle
|
||||
#
|
||||
# The new route, and the one the commission is about. Nothing here opens,
|
||||
# saves or closes a model: it writes the handoff and starts a host process
|
||||
# that does all of it in Revits that are not this one.
|
||||
|
||||
run_id = None
|
||||
if cycling and cycle_stop is None:
|
||||
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
started_pid = None
|
||||
argv = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block(),
|
||||
{'requested_by': 'SnapshotClose button, '
|
||||
'cycle route'})
|
||||
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
||||
'--fresh-detach',
|
||||
'--request', req_path,
|
||||
'--detached-dir', detached_dir,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--revit-exe', revit_exe]
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
if log:
|
||||
argv.extend(['--log', log])
|
||||
job_knowledge = _job_knowledge_path()
|
||||
if job_knowledge:
|
||||
argv.extend(['--knowledge', job_knowledge])
|
||||
for command in batch:
|
||||
argv.extend(['--run', command])
|
||||
started_pid = _spawn(argv)
|
||||
except Exception:
|
||||
started_pid = None
|
||||
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - running now, beside this session')
|
||||
if started_pid:
|
||||
output.print_md(
|
||||
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
||||
'-- it is not closed, not saved, and not read again.'
|
||||
.format(run_id, started_pid))
|
||||
output.print_md('It writes:')
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('then opens that copy in a Revit of its own, runs '
|
||||
'`{0}`, and closes it again. Its log lands beside the '
|
||||
'copy and its outcome in `{1}`.'
|
||||
.format(' + '.join(batch), log))
|
||||
output.print_md('The command it is running:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'cycle', 'host_pid': started_pid})
|
||||
else:
|
||||
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'cycle', 'reason': cycle_stop})
|
||||
elif cycling:
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - not run')
|
||||
output.print_md('Not started: {0}'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
||||
'reason': cycle_stop})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase C
|
||||
#
|
||||
# The OLD route, kept: requested here, run later, by a process that outlives
|
||||
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
||||
# passes no arguments to the script it runs, so a file named by an environment
|
||||
# variable is the only handoff there is.
|
||||
#
|
||||
# This is the only place `snapshot.after_close` still means anything.
|
||||
|
||||
if closing and blocker is None:
|
||||
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
launched = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block())
|
||||
argv = [liftexport.find_python(know), DEFER_TOOL,
|
||||
'--request', req_path,
|
||||
'--runner', RUNNER,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--wait-pid', str(_revit_pid()),
|
||||
'--wait-minutes', str(wait_minutes),
|
||||
# The close-model ritual is the one case where waiting for the
|
||||
# whole machine to be free is still the right answer: nobody
|
||||
# is at the keyboard and nothing else is queued behind it.
|
||||
'--machine-free', 'require']
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
_spawn(argv)
|
||||
launched = argv
|
||||
except Exception:
|
||||
launched = None
|
||||
blocker = 'the deferred process could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - the detached copy, after Revit exits')
|
||||
if launched:
|
||||
output.print_md('Requested as `{0}`. It waits for this Revit '
|
||||
'(pid {1}) to exit -- and then for the machine to be '
|
||||
'free of Revit altogether -- then opens the model in a '
|
||||
'session of its own and writes:'
|
||||
.format(run_id, _revit_pid()))
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('The command it will run:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(
|
||||
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
||||
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
||||
'next time this button is pressed.'.format(log))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'after-close', 'wait_pid': _revit_pid()})
|
||||
else:
|
||||
output.print_md('**Not requested: {0}**'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'after-close', 'reason': blocker})
|
||||
elif closing:
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - not run')
|
||||
output.print_md('No detached copy: {0}'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
||||
'reason': blocker})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase B
|
||||
|
||||
output.print_md('---')
|
||||
if not closing:
|
||||
if cycling:
|
||||
output.print_md('Revit stays open. The index is written and stamped, '
|
||||
'and the cycle is running in processes of its own -- '
|
||||
'nothing here was closed and your model was never '
|
||||
'touched.')
|
||||
else:
|
||||
output.print_md('Revit stays open, as asked. The index is written and '
|
||||
'stamped; nothing was closed.')
|
||||
# A normal completion for the STAY / cycle choices: Revit stays open,
|
||||
# the snapshot index is on disk. The CLOSE path below records its own
|
||||
# completion (Revit is about to exit, so this is the last write).
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
||||
report_path=report['path'] if report else None,
|
||||
report_text=('Revit stays open. Index written: {0}'
|
||||
.format(report['path'] if report else '?')))
|
||||
script.exit()
|
||||
|
||||
output.print_md('## Phase B - closing Revit')
|
||||
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
||||
'That question is yours to answer -- this button never syncs '
|
||||
'and never saves the model.')
|
||||
|
||||
posted = False
|
||||
# There is no PostableCommand for quitting, so the internal command id is
|
||||
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
||||
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
||||
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
||||
# helper is a version-dependent convenience and this is the one step of the
|
||||
# button that cannot be retried by pressing it again.
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
HOST_APP.post_command('ID_APP_EXIT')
|
||||
posted = True
|
||||
except Exception:
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
from Autodesk.Revit.UI import RevitCommandId
|
||||
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
||||
if cmd is not None:
|
||||
HOST_APP.uiapp.PostCommand(cmd)
|
||||
posted = True
|
||||
except Exception:
|
||||
posted = False
|
||||
|
||||
if not posted:
|
||||
output.print_md('**Revit would not accept the close command.** The '
|
||||
'snapshot is written and stamped; close Revit yourself.')
|
||||
|
||||
# The CLOSE path's completion. If the close command was accepted Revit is
|
||||
# about to exit, so this record is the button's last write; if not, the
|
||||
# run still produced its snapshot and says so.
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
||||
report_path=report['path'] if report else None,
|
||||
report_text=('Close posted: {0}. Index written: {1}'
|
||||
.format(posted,
|
||||
report['path'] if report else '?')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Snapshot Close',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,28 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
|
||||
# `title:` here -- `uimaker._produce_ui_panels` calls
|
||||
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
|
||||
#
|
||||
# REORGANISED 2026-09-05, on the owner's own instruction: "把 ribbon 重新
|
||||
# organize 一下,加一个 Modeling pulldown,把 Reinforcing 和 TicketQC 放进去".
|
||||
# This panel used to hold four large icons side by side (reinforce, LIFT,
|
||||
# ticketqc, snapshot -- see the 2026-08-30 note this comment replaces). Two
|
||||
# of those four moved into a new `Modeling.pulldown` (ReinforceSuggest and
|
||||
# TicketQC, joined there by the new ReinforcingQC), and the third,
|
||||
# PanelLiftCheck, moved the other way onto Design.panel/DesignHub.pulldown --
|
||||
# see that bundle's own comment for why. SnapshotClose is the one large icon
|
||||
# still on this panel: it never belonged to the modelling/ticketing group,
|
||||
# it closes and archives a session, so it keeps its own button rather than
|
||||
# joining a pulldown about a different kind of work.
|
||||
#
|
||||
# What is left is one pulldown and one large button, side by side. Nothing
|
||||
# half-built may join them -- that is what Setup and Design are for.
|
||||
#
|
||||
# Every title here keeps each line under ~9 characters. A large ribbon button
|
||||
# is as wide as its widest line of text, so one long title makes one button
|
||||
# dwarf its neighbours, which is exactly what the owner objected to when
|
||||
# these four were spread across three panels.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Modeling
|
||||
- SnapshotClose
|
||||
@@ -0,0 +1,20 @@
|
||||
# Panel order on the tab, left to right.
|
||||
#
|
||||
# Without this key pyRevit falls back to whatever order the filesystem hands
|
||||
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
|
||||
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
|
||||
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
|
||||
# looks at a `title:` here, so do not add one expecting it to render.
|
||||
#
|
||||
# Four panels, and the split between them is what the owner asked for on
|
||||
# 2026-08-30: the four finished tools stand together as large icons, the
|
||||
# set-up commands collapse behind one arrow, the unbuilt design work
|
||||
# collapses behind another, and GOD_MOD keeps its own corner.
|
||||
#
|
||||
# A panel not listed below is not created at all
|
||||
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
|
||||
layout:
|
||||
- Tools
|
||||
- Setup
|
||||
- Design
|
||||
- Lab
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -0,0 +1,316 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Launch the second, AI extraction pass over a scraped design summary.
|
||||
|
||||
OWNER'S RULING, 2026-09-01 (Discord), approving the two-stage-extraction
|
||||
proposal: "可以,不过别用opus,用dsv4f带vision吧" -- yes, but not opus, use
|
||||
DSV4F with vision. That REPLACES the opus-via-Claude-Code-CLI path this
|
||||
module used to launch. `source.extractor` is now stamped 'dsv4f-vision' by
|
||||
the new pass (see `tools/aiextract_run.py`, which does the stamping -- this
|
||||
module only launches it); specs written under the earlier rulings still say
|
||||
'opus' or, before the model was named at all, 'claude-code'. Nothing reads
|
||||
that field to make a decision (see `ExtractDesign/script.py` and
|
||||
`specformat._SOURCE_NOTE`), so a reader only ever has to tolerate whichever
|
||||
of the four values it finds, never branch on it.
|
||||
|
||||
WHERE THE OLD OPUS-CLI PATH WENT, AND WHY IT WAS DELETED RATHER THAN KEPT
|
||||
BEHIND A FLAG
|
||||
--------------------------------------------------------------------------
|
||||
The old `PROMPT` / `build_prompt()` / `launch()` here spawned the Claude
|
||||
Code CLI (`claude -p --model opus`) with a prompt written against an OLDER
|
||||
spec shape -- it still asked for `spacings_in` / `positions_in` /
|
||||
`strands_per_row` under `reinforcing.strand_layout`, three fields that
|
||||
stopped existing the day the spec grew its `columns_in` / `rows` matrix
|
||||
shape (commit `73fdd9a`, "spec 改成人类可读 YAML + 矩阵 + 去掉 AI") -- because
|
||||
once the mechanical reader stopped calling this module, nothing ever ran
|
||||
that prompt again to notice it had drifted. A dead code path that LOOKS
|
||||
live is worse than an absent one: keeping it "behind a flag" would mean
|
||||
shipping a second, silently-wrong extraction contract that only gets
|
||||
exercised the day somebody flips the flag back on, which is exactly the
|
||||
kind of quiet failure this codebase's other rules (see CLAUDE.md's
|
||||
"'Could not judge' and 'judged, found nothing' are different answers")
|
||||
exist to prevent. It is deleted outright. `find_claude()` (below) is
|
||||
UNRELATED to that decision and stays: Self Check and the GOD_MOD button
|
||||
both still use it to report whether the Claude Code CLI is installed on
|
||||
this machine, independent of anything this module extracts.
|
||||
|
||||
The AI pass runs as a separate detached CPython process, same as the old
|
||||
one did. That is still deliberate, for the same two reasons:
|
||||
|
||||
* a pushbutton script runs on Revit's UI thread, and a model call taking
|
||||
a couple of minutes would freeze Revit for a couple of minutes;
|
||||
* the extraction touches no Revit API at all -- it reads JSON and PNG
|
||||
files and writes a spec file -- so there is no reason for it to run
|
||||
inside Revit. `tools/aiextract_run.py` is plain CPython (dsh is talked
|
||||
to over local HTTP, and IronPython 2.7 has no HTTP client worth
|
||||
trusting with that).
|
||||
|
||||
THE NEVER-OVERWRITE CONTRACT lives in `aiextract_run.apply_ai_fill`, not
|
||||
here: this module's whole job is gathering paths and starting that process.
|
||||
See that module's docstring for the merge rules and for the DSV4F pricing
|
||||
note (off-peak is the owner's own daytime use of the harness; a button
|
||||
press is inherently daytime, so this never competes with that window).
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import os
|
||||
import io
|
||||
|
||||
from vibe import designspec
|
||||
|
||||
|
||||
# The CLI is installed per-user and lands in a different place depending on how
|
||||
# it was installed, so it is looked up rather than assumed. Set the
|
||||
# CLAUDE_CODE_EXE environment variable to override on an unusual machine.
|
||||
#
|
||||
# UNRELATED TO THE AI EXTRACTION PASS below -- see the module docstring.
|
||||
# `find_claude()` only ever answers "is the Claude Code CLI installed here",
|
||||
# for Self Check and GOD_MOD.
|
||||
_CLAUDE_NAMES = ('claude.exe', 'claude.cmd', 'claude.bat', 'claude')
|
||||
|
||||
_CLAUDE_DIRS = (
|
||||
os.path.join(os.path.expanduser('~'), '.local', 'bin'),
|
||||
os.path.join(os.environ.get('APPDATA', ''), 'npm'),
|
||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'claude'),
|
||||
os.path.join(os.path.expanduser('~'), 'AppData', 'Roaming', 'npm'),
|
||||
)
|
||||
|
||||
|
||||
def find_claude():
|
||||
"""Absolute path to the Claude Code CLI, or None when it is not installed."""
|
||||
override = os.environ.get('CLAUDE_CODE_EXE')
|
||||
if override and os.path.isfile(override):
|
||||
return override
|
||||
|
||||
dirs = list(_CLAUDE_DIRS) + os.environ.get('PATH', '').split(os.pathsep)
|
||||
for folder in dirs:
|
||||
if not folder:
|
||||
continue
|
||||
for name in _CLAUDE_NAMES:
|
||||
candidate = os.path.join(folder, name)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _datum_clause(know):
|
||||
"""Tell the extractor which rail the strand chain is measured from.
|
||||
|
||||
Plant knowledge, not a guess: reading the chain from the wrong end mirrors
|
||||
the whole pattern, and nothing downstream would look wrong.
|
||||
"""
|
||||
from vibe import knowledge as K
|
||||
|
||||
datum = K.get(know, 'rails.strand_datum', 'left_rail')
|
||||
fixed = K.get(know, 'rails.left_rail_is_fixed', None)
|
||||
where = {
|
||||
'left_rail': 'the LEFT rail -- the left-hand edge where the concrete '
|
||||
'starts. The first segment of the chain is that edge to '
|
||||
'the first strand.',
|
||||
'right_rail': 'the RIGHT rail -- the right-hand edge where the '
|
||||
'concrete starts, reading right to left.',
|
||||
'centerline': 'the CENTERLINE of the piece.',
|
||||
}.get(datum, datum)
|
||||
|
||||
if fixed is True:
|
||||
rail = ' That rail is the bed\'s fixed rail.'
|
||||
elif fixed is False:
|
||||
rail = (' That rail is NOT the bed\'s fixed rail on this job, so do '
|
||||
'not assume dimensions accumulate away from it.')
|
||||
else:
|
||||
rail = (' Nobody has recorded whether that rail is the bed\'s fixed '
|
||||
'rail, so do not reason about which edge tolerance '
|
||||
'accumulates from.')
|
||||
|
||||
return '{0} (datum = "{1}").{2}'.format(where, datum, rail)
|
||||
|
||||
|
||||
def prepare(doc, design, know=None):
|
||||
"""Scrape the summary and write the evidence file. Returns its path.
|
||||
|
||||
know carries the plant's nomenclature, which decides which sheets make up
|
||||
this design's packet, and its rails, which decide how the strand chain
|
||||
reads.
|
||||
"""
|
||||
if know is None:
|
||||
from vibe import knowledge as K
|
||||
know = K.load(doc)
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
path = designspec.evidence_path(doc, design)
|
||||
designspec.write_json(path, evidence)
|
||||
return path, evidence
|
||||
|
||||
|
||||
# dsh's local HTTP RPC. Overridable per-machine/testing, same idiom as
|
||||
# WILLTOOLS_JOBS and CLAUDE_CODE_EXE elsewhere in this file.
|
||||
DSH_URL_ENV = 'WILLTOOLS_DSH_URL'
|
||||
DEFAULT_DSH_BASE_URL = 'http://127.0.0.1:3080'
|
||||
|
||||
# The model name and timeout are resolved on the CPython side
|
||||
# (`aiextract_run.resolve_model`) so the real ~/.dsh/settings.yaml can be
|
||||
# read there -- IronPython has no reason to parse that file too, and a
|
||||
# second parser is a second place for the two to disagree. Set either
|
||||
# override here if it ever needs to travel from inside Revit instead
|
||||
# (job.plant knowledge, say); today neither does.
|
||||
MODEL_ENV = 'WILLTOOLS_DSV4F_MODEL'
|
||||
TIMEOUT_ENV = 'WILLTOOLS_DSV4F_TIMEOUT_S'
|
||||
# Vision model on a long prompt + drawing PNG measured well over 180s to
|
||||
# produce even a first assistant message on 2026-09-03 -- the old 180s
|
||||
# default cancelled a session that was still working, never one that had
|
||||
# failed. Keep in lockstep with the identically-named constant in
|
||||
# tools/aiextract_run.py.
|
||||
DEFAULT_TIMEOUT_S = 900
|
||||
|
||||
|
||||
def _repo_root():
|
||||
"""The checkout root, from this file's own path.
|
||||
|
||||
Same idiom as `liftexport.panellift_src()`: three `dirname()` calls off
|
||||
this file strip vibe/lib/WillTools.extension, landing on the folder that
|
||||
holds both `WillTools.extension` and `tools`.
|
||||
"""
|
||||
here = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))) # WillTools.extension
|
||||
return os.path.dirname(here)
|
||||
|
||||
|
||||
def _job_dict(doc, design, know, image_paths):
|
||||
"""The plain-data job description `tools/aiextract_run.py` reads.
|
||||
|
||||
Pure data assembly -- no process, no file write -- so it can be
|
||||
inspected on its own. `launch()` is the only caller that also writes it
|
||||
to disk and starts the process.
|
||||
"""
|
||||
return {
|
||||
'design_number': design,
|
||||
'evidence_path': designspec.evidence_path(doc, design),
|
||||
'spec_path': designspec.spec_path(doc, design, 'yaml'),
|
||||
'image_paths': list(image_paths or []),
|
||||
'datum_clause': _datum_clause(know),
|
||||
'dsh_base_url': os.environ.get(DSH_URL_ENV) or DEFAULT_DSH_BASE_URL,
|
||||
'model': os.environ.get(MODEL_ENV) or None,
|
||||
'timeout_s': int(os.environ.get(TIMEOUT_ENV) or DEFAULT_TIMEOUT_S),
|
||||
'log_path': designspec.log_path(doc, design),
|
||||
}
|
||||
|
||||
|
||||
def launch(doc, design, work_dir=None, know=None, image_paths=None):
|
||||
"""Start a detached process that runs the DSV4F-vision AI pass.
|
||||
|
||||
Returns the started `Process`. The window stays open (same as the old
|
||||
opus-CLI path did) so the prompt, the reply and the merge outcome can be
|
||||
read; the spec on disk is updated in place -- or left exactly as the
|
||||
mechanical reader wrote it, with one new `unresolved` line -- regardless
|
||||
of whether anybody is watching the window.
|
||||
|
||||
`image_paths` are PNGs of the design's summary view for the model to
|
||||
read with its own vision tool (see `tools/aiextract_run.py`'s prompt) --
|
||||
rendering them is Revit API work and is the caller's job (the
|
||||
ExtractDesign button), not this module's: this file already has no
|
||||
other reason to touch a live View.
|
||||
"""
|
||||
if know is None:
|
||||
from vibe import knowledge as K
|
||||
know = K.load(doc)
|
||||
from vibe import liftexport
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
|
||||
python_exe = liftexport.find_python(know)
|
||||
if not python_exe:
|
||||
raise IOError(
|
||||
'No CPython 3 found to run the AI pass with. Set '
|
||||
'handling.python_exe in this job\'s knowledge file, or the '
|
||||
'WILLTOOLS_PYTHON environment variable -- the same setting '
|
||||
'Ticket QC\'s vision escalation uses.')
|
||||
|
||||
root = _repo_root()
|
||||
runner_script = os.path.join(root, 'tools', 'aiextract_run.py')
|
||||
|
||||
job = _job_dict(doc, design, know, image_paths)
|
||||
job_path = os.path.join(designspec.sub_dir(doc, 'log'),
|
||||
designspec._safe(design) + '.aijob.json')
|
||||
designspec.write_json(job_path, job)
|
||||
|
||||
runner = os.path.join(designspec.sub_dir(doc, 'log'),
|
||||
designspec._safe(design) + '.run.cmd')
|
||||
log = designspec.log_path(doc, design)
|
||||
with io.open(runner, 'w', encoding='utf-8') as fh:
|
||||
fh.write(u'@echo off\r\n')
|
||||
fh.write(u'chcp 65001 >nul\r\n')
|
||||
fh.write(u'set "PYTHONIOENCODING=utf-8"\r\n')
|
||||
fh.write(u'title AI pass (DSV4F vision) - {0}\r\n'.format(design))
|
||||
fh.write(u'cd /d "{0}"\r\n'.format(work_dir or root))
|
||||
fh.write(u'echo Running the DSV4F-vision AI pass for {0} ...\r\n'
|
||||
.format(design))
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'"{0}" "{1}" --job "{2}" > "{3}" 2>&1\r\n'
|
||||
.format(python_exe, runner_script, job_path, log))
|
||||
fh.write(u'type "{0}"\r\n'.format(log))
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'echo [done - spec updated in place: specs\\{0}.yaml]\r\n'
|
||||
.format(design))
|
||||
fh.write(u'pause >nul\r\n')
|
||||
|
||||
psi = ProcessStartInfo()
|
||||
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
|
||||
'Microsoft', 'WindowsApps', 'wt.exe')
|
||||
if os.path.isfile(wt):
|
||||
psi.FileName = wt
|
||||
psi.Arguments = 'cmd.exe /c "{0}"'.format(runner)
|
||||
else:
|
||||
psi.FileName = 'cmd.exe'
|
||||
psi.Arguments = '/c "{0}"'.format(runner)
|
||||
psi.UseShellExecute = True
|
||||
return Process.Start(psi)
|
||||
|
||||
|
||||
# ------------------------------------------------------- independent check
|
||||
|
||||
def cross_check(spec, regex_spec):
|
||||
"""Compare the extracted spec against the old regex reading of the summary.
|
||||
|
||||
Two readings that agree are worth more than either alone. This is not a
|
||||
correctness proof -- both can be wrong the same way -- but a disagreement
|
||||
is always worth a human glance before the spec is trusted.
|
||||
"""
|
||||
from vibe import ticketqc
|
||||
|
||||
rows = []
|
||||
|
||||
def row(label, ai, rx):
|
||||
same = (ai is None and rx is None)
|
||||
if not same and ai is not None and rx is not None:
|
||||
try:
|
||||
same = abs(float(ai) - float(rx)) < 0.5
|
||||
except Exception:
|
||||
same = str(ai).strip().upper() == str(rx).strip().upper()
|
||||
rows.append({'field': label, 'ai': ai, 'regex': rx, 'agree': bool(same)})
|
||||
|
||||
row("f'c", designspec.get_path(spec, 'concrete.fc_psi'), regex_spec.get('fc'))
|
||||
row("f'ci", designspec.get_path(spec, 'concrete.fci_psi'), regex_spec.get('fci'))
|
||||
row('unit weight', designspec.get_path(spec, 'concrete.unit_weight_pcf'),
|
||||
regex_spec.get('pcf'))
|
||||
def mesh(value):
|
||||
import re as _re
|
||||
if not value:
|
||||
return None
|
||||
return _re.sub(r'\s+', '', str(value).upper()).replace('.0', '')
|
||||
|
||||
row('W.W.F. mesh', mesh(designspec.get_path(spec, 'reinforcing.wwf.mesh')),
|
||||
mesh(regex_spec.get('wwf_mesh')))
|
||||
|
||||
ai_groups = designspec.get_path(spec, 'reinforcing.strand_groups', []) or []
|
||||
ai_sig = ticketqc.strand_signature([
|
||||
{'qty': g.get('qty'), 'dia': g.get('dia_in'),
|
||||
'ksi': g.get('grade_ksi'), 'it': g.get('it_lb')} for g in ai_groups])
|
||||
row('strand groups', ai_sig,
|
||||
ticketqc.strand_signature(regex_spec.get('strands') or []))
|
||||
|
||||
envelope = regex_spec.get('envelope') or {}
|
||||
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
|
||||
for bound in ('min', 'max'):
|
||||
rx = envelope.get('{0}_{1}'.format(bound.upper(), key))
|
||||
row('{0} {1}'.format(dim, bound),
|
||||
designspec.get_path(spec, 'envelope.{0}.{1}_ft'.format(dim, bound)),
|
||||
rx[0] if rx else None)
|
||||
return rows
|
||||
@@ -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
|
||||
@@ -0,0 +1,423 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
|
||||
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
|
||||
# sample_job. The iron rule is: read-only inside live, never write.
|
||||
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
|
||||
|
||||
The rule this enforces is the owner's, and it was a memory until now: the
|
||||
LIVE model is his, and nothing here may read it, run in it, or touch it. The
|
||||
sandbox -- a timestamped detached copy, opened in a Revit session of its own
|
||||
-- is the only session an agent is allowed to drive.
|
||||
|
||||
WHY IT HAS TO BE MECHANICAL
|
||||
----------------------------
|
||||
pyRevit's routes server picks its port by walking UP from the configured one
|
||||
past every port a running Revit has already taken
|
||||
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
|
||||
session I meant"; it means "whichever Revit started first". Restart the
|
||||
sandbox while the live session is up and the live session is now on 48884 and
|
||||
answering. Every request an agent then sends -- `status`, `exec`, a whole
|
||||
sweep -- lands in the model it must never touch, and every one of them looks
|
||||
completely normal.
|
||||
|
||||
Nothing about that is detectable from the client side: a `status` that says
|
||||
`ok` and names the right title is exactly what a correct sandbox says (the
|
||||
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
|
||||
note). So the refusal has to be made where the document actually is.
|
||||
|
||||
WHY A TITLE ALONE CANNOT DECIDE IT
|
||||
-----------------------------------
|
||||
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
|
||||
leaves the model's own name -- and therefore `doc.Title` -- constant, because
|
||||
the job folder is keyed on the title and a title that moves daily moves the
|
||||
job folder with it. The consequence lands here: the sandbox copy's title is
|
||||
IDENTICAL to the live model's. A denylist of titles would refuse both, which
|
||||
is a bridge that refuses everything.
|
||||
|
||||
So the title is the ACCUSATION and the path is the ALIBI. A document whose
|
||||
title is on the denylist is refused unless it can prove it is a copy:
|
||||
|
||||
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
|
||||
plus anything in `bridge.allow_roots`), or
|
||||
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
|
||||
Revit process's own environment -- which only the pipeline that
|
||||
launched the sandbox can have set.
|
||||
|
||||
The live cloud model has no local `PathName` at all (it is
|
||||
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
|
||||
so it can produce neither alibi and is refused. That is the intended outcome
|
||||
and it is not an accident of string matching.
|
||||
|
||||
WHAT IS NOT HERE, DELIBERATELY
|
||||
-------------------------------
|
||||
No file reading, no `knowledge.load`, no environment lookups except the two
|
||||
helpers at the bottom that are handed their own input. Everything is a pure
|
||||
function of arguments so `startup.py` can be tested at the function level
|
||||
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
|
||||
around by parsing source text, which is a worse test than this one.
|
||||
|
||||
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
|
||||
------------------------------------------------------------
|
||||
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
|
||||
cloud model outright. Hours later the owner reversed it, in his own words:
|
||||
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
|
||||
rule is: read-only inside live, never write." -- the
|
||||
bridge may run on the live model now; the iron rule is it may never write to
|
||||
it. `bridge.readonly_titles` is that rule. A title on it answers every read
|
||||
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
|
||||
transaction or arbitrary code, so they are reads by construction and never
|
||||
consult this at all) and `/exec` -- the only route that can write -- refuses
|
||||
when the request carries a transaction, or when the snippet text matches
|
||||
`WRITE_PATTERNS` below.
|
||||
|
||||
`refuse_titles` and `readonly_titles` are NOT layered. A title on
|
||||
`refuse_titles` is refused before anything here is reached; a title only on
|
||||
`readonly_titles` is never refused outright, only stopped from writing.
|
||||
|
||||
THIS IS A GUARD RAIL, NOT A SANDBOX
|
||||
-------------------------------------
|
||||
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
|
||||
not a permissions model. It catches the ordinary way `/exec` writes -- an
|
||||
explicit transaction, or a snippet that calls a mutating Revit API method by
|
||||
its literal name -- because that is what an ordinary agent request looks
|
||||
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
|
||||
setter (Revit exposes several that mutate without any method call at all),
|
||||
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
|
||||
code that reaches a write through a helper this list has not been told
|
||||
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
|
||||
A determined snippet gets through. This module only stops the request an
|
||||
agent following instructions would send.
|
||||
|
||||
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
|
||||
pathlib, `os.path` throughout.
|
||||
"""
|
||||
# pylint: disable=broad-except,invalid-name
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
|
||||
from vibe.snapshotmeta import MODEL_EXT
|
||||
|
||||
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
|
||||
# was launched on in its own environment. A session started from the taskbar
|
||||
# does not, and cannot be made to look as though it did without somebody
|
||||
# deciding to.
|
||||
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
|
||||
|
||||
# The denylist, semicolon-separated, for the case where the knowledge layers
|
||||
# cannot be read. It is a way to STATE the answer, not a way to switch the
|
||||
# guard off -- there is deliberately no off switch, because an off switch is
|
||||
# the hole this module exists to close.
|
||||
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
|
||||
|
||||
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
|
||||
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
|
||||
|
||||
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
|
||||
# for a machine whose knowledge layers will not load. Stating the answer
|
||||
# here does not widen what the bridge may do -- read-only is already the
|
||||
# more permissive of the two lists.
|
||||
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
|
||||
|
||||
|
||||
def normalize_title(text):
|
||||
"""A title as it is compared: no model extension, trimmed, case-folded.
|
||||
|
||||
`doc.Title` is sometimes reported with the extension and sometimes
|
||||
without, depending on version and on whether the document was opened from
|
||||
a path or from the cloud, so both have to compare equal or the denylist
|
||||
works on one machine and not the next.
|
||||
"""
|
||||
try:
|
||||
text = '{0}'.format(text or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
if text.lower().endswith(MODEL_EXT):
|
||||
text = text[:-len(MODEL_EXT)]
|
||||
return text.strip().lower()
|
||||
|
||||
|
||||
def split_titles(raw):
|
||||
"""A `;`-separated (or list) denylist into a clean list of strings.
|
||||
|
||||
Accepts what a YAML layer produces (a list), what an environment variable
|
||||
produces (one string), and a single string in the YAML -- the last is a
|
||||
predictable mis-edit and reading it as a one-item list beats reading it as
|
||||
a list of characters.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
items = list(raw)
|
||||
else:
|
||||
items = '{0}'.format(raw).split(';')
|
||||
out = []
|
||||
for item in items:
|
||||
try:
|
||||
text = '{0}'.format(item).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if text:
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _norm_path(path):
|
||||
"""Normalize a model path. These are always WINDOWS paths (the bridge
|
||||
|
||||
only ever names Revit model files, drive letters and all) even when this
|
||||
code runs on a Linux dev box, so the normalization has to use `ntpath`
|
||||
rather than `os.path` -- `os.path.normcase` is a no-op on POSIX and would
|
||||
silently break the case-insensitivity this promises.
|
||||
"""
|
||||
try:
|
||||
text = '{0}'.format(path or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
if not text:
|
||||
return ''
|
||||
text = text.replace('/', ntpath.sep).replace('\\', ntpath.sep)
|
||||
try:
|
||||
text = ntpath.normcase(ntpath.normpath(text))
|
||||
except Exception:
|
||||
pass
|
||||
return text.rstrip(ntpath.sep)
|
||||
|
||||
|
||||
def is_under(path, root):
|
||||
"""True when `path` sits inside `root`. Case- and separator-insensitive.
|
||||
|
||||
A prefix test on its own says `C:\\snapshots-old\\x` is under
|
||||
`C:\\snapshots`, so the separator is required after the root.
|
||||
"""
|
||||
path = _norm_path(path)
|
||||
root = _norm_path(root)
|
||||
if not path or not root:
|
||||
return False
|
||||
return path == root or path.startswith(root + ntpath.sep)
|
||||
|
||||
|
||||
def same_model(path_a, path_b):
|
||||
"""True when two paths name the same model file, as strings."""
|
||||
a, b = _norm_path(path_a), _norm_path(path_b)
|
||||
return bool(a) and a == b
|
||||
|
||||
|
||||
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
|
||||
"""True when `title` is on `listed` and this document cannot prove it is
|
||||
a sandbox copy. Shared by `refusal()` (refuse_titles) and
|
||||
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
|
||||
|
||||
No document open (`title` falsy after normalising) is never "on the
|
||||
list": there is nothing to protect and nothing to touch.
|
||||
"""
|
||||
banned = [normalize_title(t) for t in split_titles(listed)]
|
||||
banned = [t for t in banned if t]
|
||||
if not banned:
|
||||
return False
|
||||
|
||||
here = normalize_title(title)
|
||||
if not here or here not in banned:
|
||||
return False
|
||||
|
||||
# The title is on the list. Can this document prove it is a copy?
|
||||
if sandbox_model and same_model(path, sandbox_model):
|
||||
return False
|
||||
for root in (sandbox_roots or ()):
|
||||
if root and is_under(path, root):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
|
||||
config_error=None):
|
||||
"""None when the bridge may serve this document; one sentence when not.
|
||||
|
||||
`config_error` is how a caller says "I could not read the denylist". That
|
||||
is NOT the same answer as "the denylist is empty", and it is refused --
|
||||
a bridge that cannot prove it is not in the live model has not proved
|
||||
anything. The refusal names the two ways to answer it, so the fix is in
|
||||
the message rather than in somebody's memory.
|
||||
"""
|
||||
if config_error:
|
||||
return ('REFUSED: this bridge cannot tell whether the open document '
|
||||
'is the live model -- the refuse-titles configuration could '
|
||||
'not be read ({0}). Fix the job/plant knowledge layer, or '
|
||||
'state the answer in {1} (semicolon separated), then restart '
|
||||
'Revit. Nothing was read or run.'
|
||||
.format(config_error, ENV_TITLES))
|
||||
|
||||
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
|
||||
sandbox_model):
|
||||
return None
|
||||
|
||||
return ('REFUSED: the open document is "{0}", a title on '
|
||||
'bridge.refuse_titles -- the live model this bridge must never '
|
||||
'touch. Nothing was read, run or changed. Its file is {1}, which '
|
||||
'is under no configured snapshot root and is not {2}. If this '
|
||||
'really is a detached snapshot, open it from under '
|
||||
'snapshot.detached_dir, or launch its Revit with {2} set to its '
|
||||
'path.'
|
||||
.format(title, path or '(no local file -- a cloud model)',
|
||||
ENV_SANDBOX))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- read-only
|
||||
#
|
||||
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
|
||||
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
|
||||
|
||||
# Grounded in what this codebase's own write idioms actually look like:
|
||||
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
|
||||
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
|
||||
# needs a Transaction" and "never reload" sections name the rest. Matched
|
||||
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
|
||||
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
|
||||
WRITE_PATTERNS = [
|
||||
('Transaction(', re.compile(r'\bTransaction\s*\(')),
|
||||
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
|
||||
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
|
||||
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
|
||||
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
|
||||
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
|
||||
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
|
||||
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
|
||||
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
|
||||
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
|
||||
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
|
||||
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
|
||||
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
|
||||
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
|
||||
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
|
||||
('doc.Save(', re.compile(r'\.Save\s*\(')),
|
||||
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
|
||||
('Purge', re.compile(r'\bPurge\w*\s*\(')),
|
||||
('.Rename(', re.compile(r'\.Rename\s*\(')),
|
||||
('WorksharingUtils / SynchronizeWithCentral',
|
||||
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
|
||||
]
|
||||
|
||||
|
||||
def write_signature(code):
|
||||
"""The name of the first write-capable pattern found in `code`, or None.
|
||||
|
||||
A plain regex scan over the snippet text -- not a parser, and not a
|
||||
defence against an obfuscated call. See the module docstring.
|
||||
"""
|
||||
text = '{0}'.format(code or '')
|
||||
for name, pattern in WRITE_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
|
||||
sandbox_model=None, tx_name=None, code=None):
|
||||
"""None when `/exec` may run this request; one sentence when not.
|
||||
|
||||
Only reached once `refusal()` above has already let the request through
|
||||
-- a title on `bridge.refuse_titles` is refused outright and this is
|
||||
never consulted. `readonly_titles` is the softer rule: the document may
|
||||
still be read, but nothing that could write to it may run.
|
||||
"""
|
||||
if not _on_list_and_not_exempt(title, path, readonly_titles,
|
||||
sandbox_roots, sandbox_model):
|
||||
return None
|
||||
|
||||
if tx_name:
|
||||
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
||||
'rule is read-only, never write. This request asked to run '
|
||||
'inside a transaction ("{1}"), so it was not run. Nothing '
|
||||
'was changed.'.format(title, tx_name))
|
||||
|
||||
hit = write_signature(code)
|
||||
if hit:
|
||||
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
||||
'rule is read-only, never write. This snippet contains '
|
||||
'{1}, which can write to the model, so it was not run. '
|
||||
'Nothing was changed.'.format(title, hit))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def payload(title, reason):
|
||||
"""The refusal as a routes response body.
|
||||
|
||||
`ok: False` so every existing client already treats it as a failure, plus
|
||||
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
|
||||
grabbed the port" apart from "the sandbox is not up yet" -- two failures
|
||||
that look identical from the outside and need opposite responses.
|
||||
|
||||
The title is reported even though the request is refused: "which model is
|
||||
open" is the one question a refusal still has to answer, or nobody can
|
||||
work out which session they reached.
|
||||
"""
|
||||
return {'ok': False, 'refused': True, 'doc_title': title,
|
||||
'error': reason}
|
||||
|
||||
|
||||
def config_from(know, getter, environ=None):
|
||||
"""(titles, roots) out of the knowledge tree and the environment.
|
||||
|
||||
`getter` is `knowledge.get` passed in rather than imported, so this module
|
||||
keeps its promise of importing nothing that reads a file. The environment
|
||||
is a UNION with the configured values, never a replacement: a denylist you
|
||||
can shrink by unsetting a variable is not a denylist.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
titles = []
|
||||
roots = []
|
||||
try:
|
||||
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
titles.extend(split_titles(environ.get(ENV_TITLES)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
|
||||
detached = getter(know, 'snapshot.detached_dir', None)
|
||||
if detached:
|
||||
roots.append(detached)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
roots.extend(split_titles(environ.get(ENV_ROOTS)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|
||||
seen = set()
|
||||
roots = [r for r in roots if not (_norm_path(r) in seen or
|
||||
seen.add(_norm_path(r)))]
|
||||
return titles, roots
|
||||
|
||||
|
||||
def readonly_titles_from(know, getter, environ=None):
|
||||
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
|
||||
|
||||
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
|
||||
as its own function, returning one list rather than folding a third value
|
||||
into `config_from`'s tuple, so every existing caller of `config_from`
|
||||
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
|
||||
roots are shared with `config_from` -- a copy is a copy whichever list is
|
||||
being checked against -- so this does not read `bridge.allow_roots` or
|
||||
`snapshot.detached_dir` again; callers pass the roots `config_from`
|
||||
already returned.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
titles = []
|
||||
try:
|
||||
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 `—`, ` `, `†` and `&` and nothing else, so a
|
||||
# `·` 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 — what is read-only and why, where each '
|
||||
'file lives, the whole provenance chain, the run-time options '
|
||||
'— 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}** — {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** — 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 — 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('|', '|')
|
||||
@@ -0,0 +1,818 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Design summaries as reviewable data.
|
||||
|
||||
A design summary is a drafting view full of free text that a human wrote. The
|
||||
checking scripts want numbers. Turning one into the other is the only part of
|
||||
the job that genuinely needs judgement, so it happens once, up front, and the
|
||||
result is written to disk where you can read and correct it:
|
||||
|
||||
<model folder>/_ticketqc/
|
||||
evidence/<DESIGN>.json everything we could scrape off the summary
|
||||
specs/<DESIGN>.json the structured spec <-- the thing to review
|
||||
knowledge.yaml hand-written rules that outlive one project
|
||||
log/<DESIGN>.txt what the extractor printed
|
||||
|
||||
Evidence is gathered deterministically here. So is the spec now: the shape
|
||||
of a spec, and how it reads and writes as text, live in `specformat.py` --
|
||||
which touches no Revit API at all, on purpose, so that shape can be unit
|
||||
tested without a licence or a model (see its docstring). This module is the
|
||||
Revit side of that boundary: it reads the model, and it reads and writes
|
||||
specformat's shapes to disk beside it. Everything downstream reads only the
|
||||
spec, so a checking run is pure, repeatable arithmetic.
|
||||
|
||||
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
|
||||
whole point of this file is that an engineer reads it and corrects it by hand.
|
||||
An older spec written as plain JSON is still read -- see load_spec.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import json
|
||||
import datetime
|
||||
|
||||
from Autodesk.Revit.DB import (
|
||||
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
|
||||
BrowserOrganization, Element, StorageType,
|
||||
)
|
||||
|
||||
from vibe import specformat
|
||||
from vibe import specsave
|
||||
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
|
||||
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
|
||||
normalize_spec, new_spec, strand_positions, strand_row_marks,
|
||||
strand_diagram, spec_text, parse_spec_text, design_key,
|
||||
)
|
||||
from vibe.compat import as_text
|
||||
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
|
||||
# path arithmetic and lives in a module with no Revit import, so it can be
|
||||
# tested on host CPython -- but every caller has always asked designspec for
|
||||
# it, and a second spelling of the same question is how two answers start.
|
||||
from vibe.plantpath import ( # noqa: F401 -- re-exported
|
||||
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
|
||||
)
|
||||
|
||||
# Control characters and format characters that ride along in Revit text and
|
||||
# crash downstream printers / parsers. Measured: a model parameter whose
|
||||
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
|
||||
# way into evidence/WP1.json and broke a tool that printed it. Everything
|
||||
# that scrapes model text goes through clean_text at the boundary; the
|
||||
# function itself lives in ticketpage (the Revit-free module, unit-tested).
|
||||
from vibe.ticketpage import clean_text
|
||||
|
||||
# The strand-matrix geometry reader. A sibling module, developed in parallel
|
||||
# -- guarded, and PERMANENTLY guarded even once it always ships, because a
|
||||
# machine missing the file must get an honest refusal from build_spec rather
|
||||
# than an ImportError that takes the whole extraction down with it.
|
||||
try:
|
||||
from vibe import strandmatrix
|
||||
except ImportError:
|
||||
strandmatrix = None
|
||||
|
||||
FOLDER = '_ticketqc'
|
||||
|
||||
JOBS_ENV = 'WILLTOOLS_JOBS'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- layout
|
||||
|
||||
def root_dir(doc):
|
||||
"""Where this project's QC data lives.
|
||||
|
||||
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
|
||||
underneath it. Everything a project knows then sits in one place the
|
||||
engineer chose, rather than in two that the tool chose for him.
|
||||
|
||||
Without it, beside the model, falling back to APPDATA when the model has
|
||||
no path yet or its folder cannot be written. That fallback is the reason
|
||||
the override exists. Saving a detached copy gives it a path, so the
|
||||
lookup silently moves from APPDATA to the model folder, and a job
|
||||
knowledge file written before the save stops being read -- measured
|
||||
2026-08-30, and from the outside it looks exactly like edits that did not
|
||||
take.
|
||||
"""
|
||||
override = os.environ.get(JOBS_ENV)
|
||||
if override:
|
||||
return _ensure(os.path.join(override, doc.Title or 'untitled'))
|
||||
model = doc.PathName
|
||||
if model:
|
||||
base = os.path.join(os.path.dirname(model), FOLDER)
|
||||
try:
|
||||
_ensure(base)
|
||||
return base
|
||||
except Exception:
|
||||
pass
|
||||
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
|
||||
'pyrevit-ticketqc', doc.Title or 'untitled')
|
||||
_ensure(fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
def _ensure(path):
|
||||
if not os.path.isdir(path):
|
||||
os.makedirs(path)
|
||||
return path
|
||||
|
||||
|
||||
def sub_dir(doc, name):
|
||||
return _ensure(os.path.join(root_dir(doc), name))
|
||||
|
||||
|
||||
def evidence_path(doc, design):
|
||||
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
|
||||
|
||||
|
||||
def spec_path(doc, design, ext='yaml'):
|
||||
"""Where one design's spec lives. New specs are written as '.yaml'.
|
||||
|
||||
A spec written before this file carried comments is still on disk as
|
||||
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
|
||||
whichever one is actually there.
|
||||
"""
|
||||
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
|
||||
|
||||
|
||||
def find_spec_path(doc, design):
|
||||
"""(path, ext) of the spec actually on disk for this design.
|
||||
|
||||
'.yaml' wins when both exist -- it is the one a human could have
|
||||
corrected, and load_spec must not silently prefer the one they didn't
|
||||
touch. Neither existing, this is where a new spec would be written.
|
||||
"""
|
||||
yaml_path = spec_path(doc, design, 'yaml')
|
||||
if os.path.isfile(yaml_path):
|
||||
return yaml_path, 'yaml'
|
||||
json_path = spec_path(doc, design, 'json')
|
||||
if os.path.isfile(json_path):
|
||||
return json_path, 'json'
|
||||
return yaml_path, 'yaml'
|
||||
|
||||
|
||||
def log_path(doc, design):
|
||||
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
|
||||
|
||||
|
||||
def knowledge_path(doc):
|
||||
return os.path.join(root_dir(doc), 'knowledge.yaml')
|
||||
|
||||
|
||||
# ------------------------------------------------------- the project config
|
||||
#
|
||||
# A project's configuration file is written wherever the engineer wants it --
|
||||
# the job folder on a share, usually, where the rest of the job's paperwork
|
||||
# lives and where a reviewer will look for it. That is the whole point of
|
||||
# letting them choose, and it means the tool cannot go and find it by
|
||||
# convention afterwards.
|
||||
#
|
||||
# So a one-line pointer stays behind, beside the model with the rest of the QC
|
||||
# data. It holds the path and nothing else. Losing it costs one button press;
|
||||
# putting the config itself here would defeat the choice.
|
||||
|
||||
CONFIG_POINTER = 'active-config.txt'
|
||||
|
||||
|
||||
def config_pointer_path(doc):
|
||||
return os.path.join(root_dir(doc), CONFIG_POINTER)
|
||||
|
||||
|
||||
def active_config_path(doc):
|
||||
"""The project config file in force for this model, or None.
|
||||
|
||||
Returns the path even when the file has gone missing. A pointer to
|
||||
nothing is a fact the caller has to be told about -- resolving it to None
|
||||
would silently fall back to the layered files, and the run would use a
|
||||
different configuration than the one the engineer set, without saying so.
|
||||
"""
|
||||
pointer = config_pointer_path(doc)
|
||||
if not os.path.isfile(pointer):
|
||||
return None
|
||||
try:
|
||||
with io.open(pointer, 'r', encoding='utf-8') as fh:
|
||||
path = fh.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return path or None
|
||||
|
||||
|
||||
def set_active_config(doc, path):
|
||||
"""Make this file the configuration this model runs on."""
|
||||
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(path))
|
||||
return path
|
||||
|
||||
|
||||
def clear_active_config(doc):
|
||||
"""Go back to the layered files. Returns what was in force, or None."""
|
||||
pointer = config_pointer_path(doc)
|
||||
previous = active_config_path(doc)
|
||||
if os.path.isfile(pointer):
|
||||
os.remove(pointer)
|
||||
return previous
|
||||
|
||||
|
||||
def _safe(name):
|
||||
"""A design number as a filename.
|
||||
|
||||
One rule, and it lives in `specsave` -- the Extract Design button names
|
||||
the COPY it saves elsewhere with the same function, so a copy can never
|
||||
end up called something the canonical file is not.
|
||||
"""
|
||||
return specsave.safe_name(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- design numbers
|
||||
#
|
||||
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
|
||||
# file, alongside the rest of the spec shape. It is the same function under
|
||||
# the same name; nothing that calls `designspec.design_key` has to change.
|
||||
#
|
||||
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
|
||||
# asking "are these two design numbers the same design?" needed a licence.
|
||||
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
|
||||
# out of a suggestion -- and it must answer both inside Revit and in
|
||||
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
|
||||
# the reduction there would have been a second spelling of one question, which
|
||||
# is how two answers start; so the function went to the Revit-free module and
|
||||
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
|
||||
# `specformat.FALLBACK_DESIGN_RE`.
|
||||
|
||||
|
||||
def _nomen(know, key, default):
|
||||
"""One nomenclature setting, tolerating a knowledge dict that is not there.
|
||||
|
||||
Imported late: knowledge imports this module, so a module-level import
|
||||
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
|
||||
and resolves `nomenclature.<key>` at each call site, so do not rename it
|
||||
without teaching the scanner.
|
||||
"""
|
||||
if know is None:
|
||||
return default
|
||||
from vibe import knowledge as K
|
||||
return K.get(know, 'nomenclature.' + key, default)
|
||||
|
||||
|
||||
def list_specs(doc):
|
||||
"""Design numbers that already have a spec on disk.
|
||||
|
||||
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
|
||||
wins, same rule as find_spec_path, so a spec a human has never touched
|
||||
(still '.json') does not shadow one they have.
|
||||
"""
|
||||
folder = sub_dir(doc, 'specs')
|
||||
by_design = {}
|
||||
for fn in sorted(os.listdir(folder)):
|
||||
low = fn.lower()
|
||||
if low.endswith('.yaml'):
|
||||
rank = 0
|
||||
elif low.endswith('.json'):
|
||||
rank = 1
|
||||
else:
|
||||
continue
|
||||
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
|
||||
if not spec or not spec.get('design_number'):
|
||||
continue
|
||||
design = spec['design_number']
|
||||
prior = by_design.get(design)
|
||||
if prior is None or rank < prior[0]:
|
||||
by_design[design] = (rank, spec)
|
||||
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- io
|
||||
|
||||
def read_json(path):
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
return json.loads(fh.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
_ensure(os.path.dirname(path))
|
||||
text = as_text(json.dumps(data, indent=2, sort_keys=True,
|
||||
ensure_ascii=False))
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(text)
|
||||
return path
|
||||
|
||||
|
||||
def read_yaml(path):
|
||||
"""A spec file written by save_spec, or None. Comments are just dropped."""
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
text = fh.read()
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return specformat.parse_spec_text(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_spec_file(path):
|
||||
"""A spec, whichever of the two formats it was written in."""
|
||||
if path.lower().endswith('.json'):
|
||||
return read_json(path)
|
||||
return read_yaml(path)
|
||||
|
||||
|
||||
def load_spec(doc, design):
|
||||
path, _ext = find_spec_path(doc, design)
|
||||
return normalize_spec(read_spec_file(path))
|
||||
|
||||
|
||||
def save_spec(doc, design, spec):
|
||||
"""Write a spec as hand-editable YAML.
|
||||
|
||||
Always '.yaml', regardless of what was there before -- a spec that gets
|
||||
re-extracted goes back to being machine-written, and a stale '.json'
|
||||
left beside it would otherwise look like an untouched original. Nothing
|
||||
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
|
||||
here on, same as list_specs.
|
||||
"""
|
||||
path = spec_path(doc, design, 'yaml')
|
||||
_ensure(os.path.dirname(path))
|
||||
text = specformat.spec_text(spec)
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(text))
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------ element utils
|
||||
|
||||
def _name(el):
|
||||
try:
|
||||
return Element.Name.GetValue(el)
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _eid(el):
|
||||
try:
|
||||
return el.Id.IntegerValue
|
||||
except Exception:
|
||||
return el.Id.Value
|
||||
|
||||
|
||||
def _params(el, wanted=None):
|
||||
out = {}
|
||||
for p in el.Parameters:
|
||||
pname = p.Definition.Name
|
||||
if wanted is not None and pname not in wanted:
|
||||
continue
|
||||
try:
|
||||
if p.StorageType == StorageType.String:
|
||||
val = p.AsString()
|
||||
elif p.StorageType == StorageType.Integer:
|
||||
val = p.AsInteger()
|
||||
elif p.StorageType == StorageType.Double:
|
||||
val = p.AsValueString()
|
||||
else:
|
||||
val = p.AsValueString()
|
||||
except Exception:
|
||||
val = None
|
||||
if val not in (None, ''):
|
||||
out[pname] = val
|
||||
return out
|
||||
|
||||
|
||||
def _dimension(d):
|
||||
"""One dimension, with enough geometry to say what it measures.
|
||||
|
||||
A bare string is not enough for a strand chain. Where the strands sit is
|
||||
dimensioned as a multi-segment chain -- rail to first strand, then strand
|
||||
to strand, then last strand to the far edge -- so the segments in order,
|
||||
their exact lengths, and which way the chain runs are all part of the
|
||||
reading. N segments locate N-1 strands and the total is the piece width,
|
||||
which is what makes the reading checkable.
|
||||
|
||||
Two traps, both met on real summaries:
|
||||
|
||||
* Dimension.Curve throws on these drafting views, so the direction is
|
||||
inferred from how the segment origins are spread instead.
|
||||
* segment.Value is the drawn geometry and segment.ValueString is what is
|
||||
printed. On an overridden segment they disagree, and only the printed
|
||||
text is the instruction -- so both are recorded and neither is
|
||||
silently preferred.
|
||||
"""
|
||||
entry = {}
|
||||
try:
|
||||
entry['value'] = clean_text(d.ValueString)
|
||||
except Exception:
|
||||
entry['value'] = None
|
||||
for key, attr in (('above', 'Above'), ('below', 'Below'),
|
||||
('prefix', 'Prefix'), ('suffix', 'Suffix')):
|
||||
try:
|
||||
val = getattr(d, attr, None)
|
||||
except Exception:
|
||||
val = None
|
||||
if val:
|
||||
entry[key] = val
|
||||
try:
|
||||
raw = d.Value
|
||||
if raw is not None:
|
||||
entry['value_ft'] = round(float(raw), 6)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
entry['references'] = d.References.Size
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
origin = d.Origin
|
||||
entry['x'] = round(origin.X, 4)
|
||||
entry['y'] = round(origin.Y, 4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
segments = []
|
||||
try:
|
||||
if d.NumberOfSegments > 1:
|
||||
for seg in d.Segments:
|
||||
item = {}
|
||||
try:
|
||||
item['value'] = clean_text(seg.ValueString)
|
||||
except Exception:
|
||||
item['value'] = None
|
||||
try:
|
||||
if seg.Value is not None:
|
||||
item['value_ft'] = round(float(seg.Value), 6)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
point = seg.Origin
|
||||
item['x'] = round(point.X, 4)
|
||||
item['y'] = round(point.Y, 4)
|
||||
except Exception:
|
||||
pass
|
||||
segments.append(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if segments:
|
||||
entry['segments'] = segments
|
||||
entry['segment_count'] = len(segments)
|
||||
xs = [s['x'] for s in segments if 'x' in s]
|
||||
ys = [s['y'] for s in segments if 'y' in s]
|
||||
if xs and ys:
|
||||
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
|
||||
else 'vertical')
|
||||
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
|
||||
if len(lengths) == len(segments):
|
||||
entry['total_ft'] = round(sum(lengths), 6)
|
||||
|
||||
if entry.get('value') or segments:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _view_content(doc, view):
|
||||
"""Text, dimensions and drawn components of one view, positions included.
|
||||
|
||||
Positions matter: the min/max table in a summary is a label note and a
|
||||
value note sitting side by side, and only the geometry links them.
|
||||
"""
|
||||
notes = []
|
||||
for t in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
|
||||
try:
|
||||
c = t.Coord
|
||||
notes.append({'text': t.Text.replace('\r', '').strip(),
|
||||
'x': round(c.X, 4), 'y': round(c.Y, 4)})
|
||||
except Exception:
|
||||
notes.append({'text': t.Text.replace('\r', '').strip()})
|
||||
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
|
||||
|
||||
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_Dimensions)
|
||||
.WhereElementIsNotElementType().ToElements())]
|
||||
dims = [d for d in dims if d]
|
||||
|
||||
components = {}
|
||||
symbols = []
|
||||
symbols_skipped = 0
|
||||
for e in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
|
||||
try:
|
||||
key = e.Symbol.Family.Name
|
||||
except Exception:
|
||||
key = type(e).__name__
|
||||
components[key] = components.get(key, 0) + 1
|
||||
|
||||
# Positions, for the strand-matrix reader: which symbol is drawn
|
||||
# where. A FilledRegion (the usual strand marker -- see
|
||||
# knowledge.DEFAULTS['design_summary']) has no .Symbol, so `key`
|
||||
# above is already its class name; the same key is reused here so
|
||||
# the two counts describe the same population.
|
||||
try:
|
||||
bbox = e.get_BoundingBox(view)
|
||||
if bbox is None:
|
||||
symbols_skipped += 1
|
||||
continue
|
||||
cx = (bbox.Min.X + bbox.Max.X) / 2.0
|
||||
cy = (bbox.Min.Y + bbox.Max.Y) / 2.0
|
||||
w = bbox.Max.X - bbox.Min.X
|
||||
h = bbox.Max.Y - bbox.Min.Y
|
||||
symbols.append({'kind': key, 'x': round(cx, 4), 'y': round(cy, 4),
|
||||
'w': round(w, 4), 'h': round(h, 4)})
|
||||
except Exception:
|
||||
symbols_skipped += 1
|
||||
|
||||
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
|
||||
'text_notes': notes, 'dimensions': dims, 'components': components,
|
||||
'symbols': symbols, 'symbols_skipped': symbols_skipped}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the gathering
|
||||
|
||||
def _sheet_folder(org, sheet):
|
||||
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
|
||||
if org is None:
|
||||
return ''
|
||||
try:
|
||||
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def find_summary_views(doc, design, know=None):
|
||||
"""The views and sheets that make up one design's packet.
|
||||
|
||||
Driven by SHEETS, not by view names. A view belongs to the design of the
|
||||
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
|
||||
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
|
||||
would file it under the wrong design without anything looking wrong.
|
||||
|
||||
Sheets are reduced through design_key, so the whole packet -- cover sheet
|
||||
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
|
||||
order, which puts the cover sheet (the design summary itself) first.
|
||||
"""
|
||||
key = design_key(design, know)
|
||||
if not key:
|
||||
return [], []
|
||||
|
||||
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
|
||||
ignore = [str(p).upper()
|
||||
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
|
||||
stale = _nomen(know, 'stale_view_re', None)
|
||||
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
sheets = []
|
||||
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if s.IsTemplate:
|
||||
continue
|
||||
number = (s.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(p) for p in ignore):
|
||||
continue
|
||||
if design_key(number, know) != key:
|
||||
continue
|
||||
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
|
||||
continue
|
||||
sheets.append(s)
|
||||
sheets.sort(key=lambda s: (s.SheetNumber or ''))
|
||||
|
||||
views, seen = [], set()
|
||||
for sheet in sheets:
|
||||
for vid in sheet.GetAllPlacedViews():
|
||||
if vid in seen:
|
||||
continue
|
||||
seen.add(vid)
|
||||
view = doc.GetElement(vid)
|
||||
if view is None:
|
||||
continue
|
||||
if stale:
|
||||
try:
|
||||
if re.search(stale, view.Name):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
views.append(view)
|
||||
return views, sheets
|
||||
|
||||
|
||||
def gather_evidence(doc, design, know=None):
|
||||
"""Scrape everything the extractor could possibly need. No interpretation."""
|
||||
views, sheets = find_summary_views(doc, design, know)
|
||||
|
||||
evidence = {
|
||||
'schema': SCHEMA_VERSION,
|
||||
'design_number': design,
|
||||
'model': doc.Title,
|
||||
'model_path': doc.PathName,
|
||||
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'summary_views': [_view_content(doc, v) for v in views],
|
||||
'sheets': [],
|
||||
}
|
||||
|
||||
for sheet in sheets:
|
||||
entry = {
|
||||
'number': sheet.SheetNumber,
|
||||
'name': sheet.Name,
|
||||
'id': _eid(sheet),
|
||||
'parameters': _params(sheet),
|
||||
'views': [],
|
||||
}
|
||||
for vid in sheet.GetAllPlacedViews():
|
||||
view = doc.GetElement(vid)
|
||||
if any(v.Id == view.Id for v in views):
|
||||
continue
|
||||
entry['views'].append(_view_content(doc, view))
|
||||
evidence['sheets'].append(entry)
|
||||
|
||||
# A worked example of a real piece helps the extractor ground its units.
|
||||
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
|
||||
return evidence
|
||||
|
||||
|
||||
def _sample_piece(doc, design, know=None):
|
||||
"""One assembly on this design, so the extractor can see real magnitudes."""
|
||||
from Autodesk.Revit.DB import AssemblyInstance
|
||||
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
for mid in asm.GetMemberIds():
|
||||
member = doc.GetElement(mid)
|
||||
if not (member.Category and member.Category.Name == 'Structural Framing'):
|
||||
continue
|
||||
from vibe.ticketqc import rstr
|
||||
# Compared as reduced keys: the piece may be stamped PNL1 while
|
||||
# the summary sheet is PNL1.0, and they are the same design.
|
||||
if design_key(rstr(member, know, 'panel.design_number'),
|
||||
know) != design_key(design, know):
|
||||
break
|
||||
bom = {}
|
||||
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
|
||||
try:
|
||||
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'mark': asm.AssemblyTypeName,
|
||||
'family': member.Symbol.Family.Name,
|
||||
'panel_parameters': _params(member),
|
||||
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
|
||||
'bill_of_material': bom,
|
||||
}
|
||||
else:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- spec shape
|
||||
#
|
||||
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
|
||||
# in specformat.py now -- see the imports at the top of this file -- because
|
||||
# none of them touch Revit and every one of them is worth testing without it.
|
||||
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
|
||||
# code written against this module.
|
||||
|
||||
|
||||
def build_spec(doc, design, know=None, evidence=None):
|
||||
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
|
||||
|
||||
Everything below either matched one of ticketqc's rules or it did not --
|
||||
there is no third option, and nothing here guesses. What matched is
|
||||
filled in; every text note on the design summary that nothing matched is
|
||||
recorded in 'unresolved' rather than silently dropped. The strand matrix
|
||||
is read from GEOMETRY, not guessed at from text -- the drawn strand
|
||||
symbols and the dimension chain that locates them -- and only when every
|
||||
identity check on that reading passes: the symbol count equals the
|
||||
stated strand quantity, each row's spacing matches a dimension chain, and
|
||||
row depths match a vertical chain. Any one of those failing leaves
|
||||
columns_in and rows empty with the reason on 'unresolved' rather than a
|
||||
grid nobody can trust; a wrong grid is worse than an honest blank one,
|
||||
and the AI pass and a human both fill what stays blank. datum comes from
|
||||
knowledge (rails.strand_datum) because that much genuinely is known
|
||||
already, independent of whether the geometry read succeeds.
|
||||
|
||||
Returns None when this design has no summary view to read.
|
||||
"""
|
||||
from vibe import knowledge as K
|
||||
from vibe import ticketqc
|
||||
|
||||
if know is None:
|
||||
know = K.load(doc)
|
||||
views, sheets = find_summary_views(doc, design, know)
|
||||
if not views:
|
||||
return None
|
||||
if evidence is None:
|
||||
evidence = gather_evidence(doc, design, know=know)
|
||||
|
||||
view = views[0] # the design's own summary view --
|
||||
# the rest of the packet's views
|
||||
# are sub-details, not this design
|
||||
reading = ticketqc.parse_design_summary(doc, view)
|
||||
|
||||
spec = new_spec(design, evidence)
|
||||
spec['source']['extractor'] = 'regex'
|
||||
spec['piece_description'] = sheets[0].Name if sheets else None
|
||||
|
||||
spec['concrete']['fc_psi'] = reading.get('fc')
|
||||
spec['concrete']['fci_psi'] = reading.get('fci')
|
||||
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
|
||||
|
||||
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
|
||||
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
|
||||
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
|
||||
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
|
||||
spec['reinforcing']['strand_groups'] = [
|
||||
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
|
||||
'it_lb': g.get('it'), 'role': None, 'note': None}
|
||||
for g in (reading.get('strands') or [])
|
||||
]
|
||||
spec['reinforcing']['strand_layout']['datum'] = K.get(
|
||||
know, 'rails.strand_datum', 'left_rail')
|
||||
|
||||
envelope = reading.get('envelope') or {}
|
||||
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
|
||||
for bound in ('min', 'max'):
|
||||
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
|
||||
if entry:
|
||||
val, raw = entry
|
||||
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
|
||||
# which does not land on a clean decimal even when the
|
||||
# drawing dimension is exact -- 34'-11 1/2" is really
|
||||
# 34.958333333333336 in binary float. Round to the nearest
|
||||
# 1/16" (6 decimal places comfortably clears that) so the
|
||||
# spec shows the number a person would actually write, not
|
||||
# float noise nobody typed.
|
||||
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
|
||||
spec['envelope'][dim][bound + '_text'] = raw
|
||||
|
||||
# ---- the strand matrix, read from geometry -------------------------
|
||||
#
|
||||
# `strandmatrix.read_layout` is the identity-checked reader: it counts
|
||||
# the drawn strand symbols against the stated quantity, matches each
|
||||
# row's spacing to a dimension chain and each row's depth to a vertical
|
||||
# chain, and refuses -- one sentence saying which identity failed --
|
||||
# rather than fill in a grid that only half matches. `symbol_kind` is
|
||||
# plant knowledge (design_summary.strand_symbol) because the drafting
|
||||
# standard's choice of marker is not universal: a filled region on this
|
||||
# default, a detail-component family name on another plant. Computed
|
||||
# here, applied below once `spec['unresolved']` exists to carry a
|
||||
# refusal reason -- never raised out of this function, because a bad
|
||||
# geometry read must lose only the matrix, not the whole spec.
|
||||
matrix_layout, matrix_why = None, 'strandmatrix module not installed'
|
||||
if strandmatrix is not None:
|
||||
try:
|
||||
qty = sum([g.get('qty') for g in spec['reinforcing']['strand_groups']
|
||||
if g.get('qty') is not None])
|
||||
view_ev = (evidence.get('summary_views') or [None])[0]
|
||||
symbol_kind = K.get(know, 'design_summary.strand_symbol', 'FilledRegion')
|
||||
width_ft = spec['envelope']['width']['max_ft']
|
||||
matrix_layout, matrix_why = strandmatrix.read_layout(
|
||||
view_ev, qty, thickness_in=spec.get('thickness_in'),
|
||||
width_ft=width_ft, symbol_kind=symbol_kind)
|
||||
except Exception as ex:
|
||||
matrix_layout = None
|
||||
matrix_why = 'strand matrix read failed: {0}: {1}'.format(
|
||||
type(ex).__name__, ex)
|
||||
|
||||
if matrix_layout:
|
||||
layout = spec['reinforcing']['strand_layout']
|
||||
layout['columns_in'] = matrix_layout.get('columns_in')
|
||||
layout['rows'] = matrix_layout.get('rows')
|
||||
layout['total_in'] = matrix_layout.get('total_in')
|
||||
layout['note'] = matrix_layout.get('note')
|
||||
|
||||
# Every note the generic rules could not place. The plant's declared rule
|
||||
# packs then get one look at that list and may move a note into `notes`
|
||||
# (prose a person reads) or leave it where it is with a better sentence
|
||||
# beside it -- never remove one. `apply_to` returns both halves and their
|
||||
# lengths always sum back to what went in, which is the property that
|
||||
# keeps a house-style rule from becoming a silent exclusion.
|
||||
#
|
||||
# No packs declared, or a declaration this build refuses, means this is
|
||||
# exactly the single assignment it used to be. A refusal is carried on the
|
||||
# spec rather than raised: a spec that lost its extraction because a
|
||||
# knowledge file named an unknown pack would be a worse outcome than one
|
||||
# that says so on its face and leaves every note visible.
|
||||
from vibe import extractrules
|
||||
|
||||
entries = ticketqc.unresolved_in_view(doc, view)
|
||||
packs, problems = extractrules.load(know)
|
||||
notes, unresolved = extractrules.apply_to(entries, packs)
|
||||
spec['notes'] = notes
|
||||
spec['unresolved'] = unresolved
|
||||
if problems:
|
||||
spec['source']['rule_pack_problems'] = problems
|
||||
|
||||
# Appended AFTER extractrules.apply_to so the rule packs never see this
|
||||
# line -- it is not a note off the drawing, it is this function's own
|
||||
# refusal, and folding it into `entries` above would let a house-style
|
||||
# rule pack move or reinterpret it like ordinary drawing text.
|
||||
if not matrix_layout:
|
||||
spec['unresolved'].append({'text': 'STRAND PATTERN (matrix)',
|
||||
'reason': matrix_why})
|
||||
return spec
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of
|
||||
the generic reader and loaded only where the plant knowledge says so.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`ticketqc.parse_design_summary` reads a design summary with rules that are
|
||||
true of precast drawings generally -- an f'c line, a strand callout, a mesh
|
||||
callout. Every text note it cannot place lands in the spec's `unresolved`
|
||||
list verbatim, which is what stops a narrow rule set from being a lossy one.
|
||||
|
||||
But a real sheet carries a second population of notes that are not general at
|
||||
all. They are one producer's house style: the title of a standard detail, the
|
||||
label above a legend, a standing instruction that appears on every sheet that
|
||||
producer draws. The generic reader cannot place them and should not try --
|
||||
they are not facts about precast, they are facts about an office.
|
||||
|
||||
Left alone they are not harmless. Seven of them on one sheet turn `unresolved`
|
||||
into a list nobody reads, and the day a genuinely unread value lands there it
|
||||
is the eighth line of a list the reader has already learned to skim. A signal
|
||||
that always fires is not a signal -- the same argument `.setup-opts` makes for
|
||||
a check that always FAILs, and the same one `checks.muted` makes for a row
|
||||
that is permanently NOT RUN.
|
||||
|
||||
WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO
|
||||
--------------------------------------------
|
||||
A pack classifies a note the generic rules did not match. It may say:
|
||||
|
||||
* `note` -- prose that belongs in the spec's `notes` list. An
|
||||
instruction, a condition, a standing rule. It is CARRIED,
|
||||
verbatim, into a field a person reads.
|
||||
* `furniture` -- drawing furniture that carries no data at all: the bare
|
||||
title of a legend or a detail. It STAYS in `unresolved`,
|
||||
with the pack's reason in place of the blanket one.
|
||||
|
||||
A pack may NEVER remove a note from the spec, and there is no third verdict
|
||||
that makes one disappear. `furniture` is not an ignore list: the line is still
|
||||
written out, still readable, still countable -- only the sentence beside it
|
||||
changes, from "no rule matched this" to "this is a legend title, it carries no
|
||||
data". An exclusion the code chose for itself is the one failure this project
|
||||
has already paid for (see `vibe/scope.py`), and nothing here may repeat it.
|
||||
|
||||
WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture`
|
||||
--------------------------------------------------
|
||||
The two errors are not symmetric, in the same way `vibe/scope.py`'s two are
|
||||
not. A note wrongly called `note` is copied into a field a person reads and
|
||||
costs them one line of attention. A note wrongly called `furniture` has been
|
||||
declared meaningless by a regex, and the reader is told there is nothing
|
||||
there. So a rule that is not certain writes `note`, and a title that carries
|
||||
any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a
|
||||
note, not furniture -- the condition is the part that could matter.
|
||||
|
||||
WHICH PACKS LOAD IS DECLARED, NEVER INFERRED
|
||||
--------------------------------------------
|
||||
From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though
|
||||
that key exists and would be enough to guess with: which house style a job is
|
||||
drawn in is a thing somebody knows and can write down, and a guess that is
|
||||
right today is a guess that goes wrong silently the first time a job is drawn
|
||||
to another producer's standard on this plant's template.
|
||||
|
||||
extract:
|
||||
rule_packs: [mc]
|
||||
|
||||
Undeclared means NO PACK LOADS and every note behaves exactly as it did
|
||||
before this module existed -- unmatched notes land in `unresolved` under the
|
||||
generic reason. That is the honest default: a machine that has been told
|
||||
nothing about an office's habits should claim to recognise nothing.
|
||||
|
||||
A declared name this module does not know is REFUSED, loudly, and no pack
|
||||
loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts`
|
||||
check: an opt file that has gone quiet about a line nobody honours is worse
|
||||
than no opt file, because the reader believes something is in force that is
|
||||
not. `problems` comes back beside the packs and the caller prints it.
|
||||
|
||||
Nothing here imports a module named in knowledge. `PACKS` is a whitelist of
|
||||
names this package ships; a name outside it is an error, never an import.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||||
"""
|
||||
|
||||
# Declared name -> module basename inside this package. A WHITELIST: the
|
||||
# knowledge layer chooses among these, it never names a module to import.
|
||||
PACKS = {
|
||||
'mc': 'mc',
|
||||
}
|
||||
|
||||
KEY = 'extract.rule_packs'
|
||||
|
||||
# What a pack may return from `classify`. `note` is carried into the spec's
|
||||
# notes; `furniture` stays in unresolved with a better reason. There is no
|
||||
# verdict that drops a line -- see the module docstring.
|
||||
NOTE = 'note'
|
||||
FURNITURE = 'furniture'
|
||||
KINDS = (NOTE, FURNITURE)
|
||||
|
||||
|
||||
class Placement(object):
|
||||
"""One pack's verdict on one note.
|
||||
|
||||
`rule` is the pack-qualified id of the rule that fired (`mc/legend-title`)
|
||||
so a reader of the spec can find the line of code that made the claim, and
|
||||
argue with it. A placement that cannot say which rule made it is not
|
||||
accepted -- an unattributable classification is exactly the kind nobody
|
||||
can correct.
|
||||
"""
|
||||
|
||||
__slots__ = ('kind', 'rule', 'reason')
|
||||
|
||||
def __init__(self, kind, rule, reason):
|
||||
if kind not in KINDS:
|
||||
raise ValueError('unknown placement kind: %r' % (kind,))
|
||||
if not rule:
|
||||
raise ValueError('a placement must name the rule that made it')
|
||||
self.kind = kind
|
||||
self.rule = rule
|
||||
self.reason = reason
|
||||
|
||||
def __repr__(self):
|
||||
return 'Placement(%r, %r)' % (self.kind, self.rule)
|
||||
|
||||
|
||||
def load(know, get=None):
|
||||
"""(packs, problems) for the rule packs this knowledge declares.
|
||||
|
||||
`packs` is a list of imported pack modules, in declared order. `problems`
|
||||
is a list of one-line strings naming anything refused; when it is
|
||||
non-empty `packs` is EMPTY -- a half-loaded rule set would classify some
|
||||
notes and not others, and the reader could not tell which sentences to
|
||||
trust. All or nothing, and say why.
|
||||
"""
|
||||
if get is None:
|
||||
from vibe import knowledge as K
|
||||
get = K.get
|
||||
declared = get(know, KEY, None) or []
|
||||
if isinstance(declared, str):
|
||||
declared = [declared]
|
||||
|
||||
problems = []
|
||||
names = []
|
||||
for raw in declared:
|
||||
name = (raw or '').strip().lower()
|
||||
if not name:
|
||||
problems.append('an empty name in %s -- remove the entry or '
|
||||
'name a pack' % (KEY,))
|
||||
elif name not in PACKS:
|
||||
problems.append("%s names '%s', which this build does not ship. "
|
||||
'Known packs: %s'
|
||||
% (KEY, name, ', '.join(sorted(PACKS)) or '(none)'))
|
||||
elif name in names:
|
||||
problems.append("%s names '%s' twice" % (KEY, name))
|
||||
else:
|
||||
names.append(name)
|
||||
|
||||
if problems:
|
||||
return [], problems
|
||||
|
||||
packs = []
|
||||
for name in names:
|
||||
packs.append(__import__('vibe.extractrules.' + PACKS[name],
|
||||
globals(), locals(), ['classify'], 0))
|
||||
return packs, []
|
||||
|
||||
|
||||
def classify(text, packs):
|
||||
"""The first pack that recognises `text`, or None.
|
||||
|
||||
First, not best: packs are tried in the order they were declared, and the
|
||||
reader can see that order in the job file. Nothing here merges two packs'
|
||||
opinions -- a note that two house styles both claim is a fact about the
|
||||
declaration, not something this function should quietly average.
|
||||
"""
|
||||
for pack in (packs or []):
|
||||
placed = pack.classify(text)
|
||||
if placed is not None:
|
||||
return placed
|
||||
return None
|
||||
|
||||
|
||||
def apply_to(entries, packs):
|
||||
"""Split `unresolved` entries into (notes, unresolved) using the packs.
|
||||
|
||||
`entries` are the dicts `ticketqc.unresolved_in_view` returns -- each
|
||||
carries at least 'text' and 'reason'. Every entry comes back in exactly
|
||||
one of the two lists and none is dropped: `len(notes) + len(unresolved)`
|
||||
always equals `len(entries)`, which `test_extractrules` asserts directly
|
||||
because it is the property the whole module rests on.
|
||||
|
||||
A `note` gains 'rule' so the spec says which rule claimed it. A
|
||||
`furniture` keeps its place in unresolved and only its 'reason' changes.
|
||||
"""
|
||||
notes = []
|
||||
unresolved = []
|
||||
for entry in (entries or []):
|
||||
placed = classify(entry.get('text') or '', packs)
|
||||
if placed is None:
|
||||
unresolved.append(entry)
|
||||
continue
|
||||
out = dict(entry)
|
||||
out['rule'] = placed.rule
|
||||
if placed.reason:
|
||||
out['reason'] = placed.reason
|
||||
if placed.kind == NOTE:
|
||||
notes.append(out)
|
||||
else:
|
||||
unresolved.append(out)
|
||||
return notes, unresolved
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The `mc` pack: one producer's design-summary habits.
|
||||
|
||||
Loaded only when the plant layer declares it (`extract.rule_packs: [mc]`) --
|
||||
see `vibe/extractrules/__init__.py` for why that is declared and not inferred
|
||||
from `plant.id`.
|
||||
|
||||
WHERE THESE RULES COME FROM
|
||||
---------------------------
|
||||
Every rule below was written against text observed on a real sheet, and each
|
||||
one says which. They are not a model of how this producer draws; they are a
|
||||
record of lines that actually landed in `unresolved` and what they turned out
|
||||
to be. A rule with no observation behind it does not belong here -- this file
|
||||
is evidence, and a guess mixed into evidence is indistinguishable from it
|
||||
afterwards.
|
||||
|
||||
Observed on `WPA1.0` (sample_job, 8" horizontal wall, read 2026-09-01):
|
||||
all seven notes the generic reader could not place, listed in the order they
|
||||
sit on the sheet.
|
||||
|
||||
WHAT IS DELIBERATELY NOT HERE
|
||||
------------------------------
|
||||
`STRIPPING & BLOCKING DETAIL < 25'` is NOT treated as the same thing as the
|
||||
bare `STRIPPING & BLOCKING DETAIL` beside it. The bare one is the title of a
|
||||
detail drawn on the sheet and carries no data. The other one carries a
|
||||
CONDITION -- which detail applies below twenty-five feet -- and a condition is
|
||||
the part that could change what gets built. So the bare title is furniture and
|
||||
the conditional one is a note, and the rule that separates them tests for
|
||||
anything trailing the title rather than matching both with one loose pattern.
|
||||
That asymmetry is the module's standing rule (`__init__`: when unsure, note).
|
||||
|
||||
Nothing here encodes a NUMBER. `*USE MC STANDARD REINFORCEMENT ...` names a
|
||||
standard this office keeps; what that standard says is plant knowledge and
|
||||
belongs in the plant knowledge file, looked up by name, never frozen into a
|
||||
regex in this file. The rule's whole job is to carry the sentence to a field a
|
||||
person reads.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||||
"""
|
||||
import re
|
||||
|
||||
from vibe.extractrules import Placement, NOTE, FURNITURE
|
||||
|
||||
NAME = 'mc'
|
||||
|
||||
|
||||
def _rule(rule_id, pattern, kind, reason):
|
||||
return (rule_id, re.compile(pattern, re.IGNORECASE), kind, reason)
|
||||
|
||||
|
||||
# Order matters and is not alphabetical: a more specific rule must come before
|
||||
# the looser one it would otherwise be swallowed by. The conditional detail
|
||||
# title is tested before the bare one for exactly that reason.
|
||||
RULES = (
|
||||
# ---------------------------------------------------------- instructions
|
||||
_rule('mc/standard-reinforcement',
|
||||
r"^\*?\s*USE\s+MC\s+STANDARD\s+REINFORCEMENT\b",
|
||||
NOTE,
|
||||
'a standing instruction naming this plant\'s standard reinforcement '
|
||||
'for cut-down panels and openings -- prose, not a number; what the '
|
||||
'standard says is plant knowledge, looked up by name'),
|
||||
|
||||
_rule('mc/conditional-bar',
|
||||
r"^#\s*\d+\s+BAR\b.*\bONLY\s+NEEDED\s+WHEN\b",
|
||||
NOTE,
|
||||
'a conditional reinforcing instruction -- the bar is required only '
|
||||
'when the condition in the sentence holds, so the condition is the '
|
||||
'value and it stays as prose'),
|
||||
|
||||
_rule('mc/pick-instruction',
|
||||
r"^USE\s+FOR\b.*\bPICK\b",
|
||||
NOTE,
|
||||
'a handling instruction saying which pick this detail is for -- '
|
||||
'prose, and it qualifies a detail rather than stating a number'),
|
||||
|
||||
_rule('mc/detail-title-conditional',
|
||||
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\b\s*\S+",
|
||||
NOTE,
|
||||
'a standard-detail title carrying a CONDITION (which detail applies, '
|
||||
'and when) -- the title alone would be furniture, the condition is '
|
||||
'not'),
|
||||
|
||||
# ------------------------------------------------------------- furniture
|
||||
_rule('mc/detail-title',
|
||||
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\s*$",
|
||||
FURNITURE,
|
||||
'the bare title of a standard detail drawn on the sheet -- names '
|
||||
'the detail, states nothing about this design'),
|
||||
|
||||
_rule('mc/legend-title',
|
||||
r"^STRAND\s+PATTERN\s*$",
|
||||
FURNITURE,
|
||||
'the title above the strand-pattern legend -- the legend\'s DATA is '
|
||||
'the dimension chain under it, which is read as geometry, not as '
|
||||
'this text'),
|
||||
|
||||
_rule('mc/legend-label',
|
||||
r"^STRAND\s*$",
|
||||
FURNITURE,
|
||||
'a bare column label in the strand legend -- a heading over values '
|
||||
'read elsewhere, with no value of its own'),
|
||||
)
|
||||
|
||||
|
||||
def classify(text):
|
||||
"""A Placement for `text`, or None when this pack does not recognise it.
|
||||
|
||||
`text` arrives already whitespace-normalised by the caller (one space
|
||||
between words, no leading or trailing run), so every pattern here can
|
||||
assume single spaces and anchor at both ends where it means to.
|
||||
"""
|
||||
probe = (text or '').strip()
|
||||
if not probe:
|
||||
return None
|
||||
for rule_id, pattern, kind, reason in RULES:
|
||||
if pattern.search(probe):
|
||||
return Placement(kind, rule_id, reason)
|
||||
return None
|
||||
@@ -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 += ' — ' + _text(section.problem)
|
||||
return head
|
||||
|
||||
|
||||
def _folded_header(section):
|
||||
bits = ['**' + _text(section.title) + '**']
|
||||
if section.summary:
|
||||
bits.append(_text(section.summary))
|
||||
return '- ' + _text(' — ').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 `—` 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('—', '--').replace(' ', ' ')
|
||||
.replace('†', '+').replace('&', '&'))
|
||||
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('—', '--').replace('†', '+')
|
||||
.replace(' ', ' ').replace('&', '&'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- 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 `—` 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">— ' +
|
||||
_entities(_escape(section.problem)) + '</span>')
|
||||
elif section.summary:
|
||||
head.append('— ' + _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 (`—`, `†`)
|
||||
# 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('&', '&').replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
|
||||
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 `—` (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('&' + name + ';', '&' + name + ';')
|
||||
return body
|
||||
@@ -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 [])
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -0,0 +1,944 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
u"""The Design Criteria section of the Panel Analysis input form.
|
||||
|
||||
WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three
|
||||
times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like
|
||||
this, where would a user even understand it), "现在这个panel analysis做的也太丑
|
||||
了" (even now it is still too ugly) -- and then gave the fix in one sentence:
|
||||
"全部给我做成普通人能看懂的样子", make every part of it something an ordinary
|
||||
person can understand. The form is being rebuilt to look like Eriksson Wall's
|
||||
own printed pages, split one Eriksson section per file so eight agents can
|
||||
build it at once without stepping on each other. This file owns exactly one
|
||||
of those sections: **Design Criteria**, which on Eriksson's own printout is
|
||||
four blocks stacked on one page -- Project Information, Material Properties,
|
||||
Load Combinations, Settings -- not four separate sections. `page()` below
|
||||
returns all four, in that order, as one flat block list.
|
||||
|
||||
`page()` NEVER RAISES. A form button that throws mid-render leaves the owner
|
||||
looking at a stack trace instead of a form, which is worse than an ugly form
|
||||
-- so every lookup here is defensive, and anything this module could not lay
|
||||
out is named in the `problems` list it returns instead.
|
||||
|
||||
THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF
|
||||
renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`,
|
||||
`table`, `lc_table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as
|
||||
nothing, so a typo here is a silently missing block, not an error. There is
|
||||
no test for "the renderer draws this correctly" (there is no renderer to run
|
||||
against on host CPython, and Revit is not running for this module to check
|
||||
against) -- only for "every block this function returns is a well-formed
|
||||
member of that vocabulary."
|
||||
|
||||
WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol`
|
||||
/ `hint` for every field, rather than importing them from
|
||||
`vibe.panelanalysisform`. Two reasons, both load-bearing:
|
||||
|
||||
1. Another agent is restructuring `panelanalysisform.FIELDS` while this file
|
||||
is being written, so importing text from it ties this module's stability
|
||||
to a moving target -- exactly what "the block list is stable" (tested
|
||||
below) is supposed to rule out.
|
||||
2. Roughly half of what Eriksson's own Design Criteria page prints has NO
|
||||
key in `panelanalysisform.FIELDS` at all yet (project identity, concrete
|
||||
elastic modulus, deflection-limit checkboxes, stress-limit checkboxes,
|
||||
resistance-factor splits, development multipliers by strand type, ...).
|
||||
Those need a label and a hint regardless of whether the backing field
|
||||
exists, so authoring locally is the only option that covers both cases the
|
||||
same way.
|
||||
|
||||
`panelanalysisform` IS still consulted, but only for one yes/no fact per
|
||||
field: does this key already exist in `FIELDS`. That answer decides EXISTS
|
||||
vs. MISSING for the `problems` list, per the owner's instruction that a field
|
||||
whose key does not exist yet degrades to an empty editor plus a named
|
||||
problem, never to a vanished block. The import is wrapped in `try/except
|
||||
Exception` (not just `ImportError`) because a sibling agent editing that file
|
||||
right now can leave it syntactically broken for a moment, and this module
|
||||
must keep rendering through that -- "could not judge whether the key exists"
|
||||
degrades to "treat it as missing", the same discipline CLAUDE.md's
|
||||
`same_face()` example applies to an unrecognised abbreviation.
|
||||
|
||||
WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH
|
||||
`columns`, NOT `table`. Eriksson prints both as grids, and the block
|
||||
vocabulary's `table` kind was the first thing tried -- but a `table` cell is
|
||||
only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no
|
||||
way to put a live numeric/text editor inside one. Every value in those two
|
||||
grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is
|
||||
something the user can actually type, so they are laid out as `columns` of
|
||||
`field` blocks that mirror Eriksson's rows and column groupings instead. The
|
||||
Load Combinations tables used `table` for exactly that reason, back when the
|
||||
only editable thing per row was the Include tick. They no longer do: the owner
|
||||
ruled (2026-09-03) that the ACI factors themselves are editable and that a user
|
||||
can add a combination of their own, so they emit `lc_table` -- the same grid,
|
||||
but every factor cell carries a live numeric editor and the block names its
|
||||
factor columns so the renderer can build an added row. The DEFAULT factors are
|
||||
public ACI 318 code text (not this job's data) and now live in
|
||||
`panelanalysisform.LOAD_COMBO_ROWS`, beside the keys they are stored under,
|
||||
rather than in this file.
|
||||
|
||||
UPDATE 2026-09-04, RESISTANCE FACTORS IS THE ONE EXCEPTION TO ALL OF THAT. A
|
||||
headless render of this page (WPA655) showed every phi editor squeezed to zero
|
||||
width: that block was a `columns` of two sub-columns ("Developed" |
|
||||
"Transferring"), each holding `field` blocks, nested inside `col_c` of the
|
||||
Settings section's own three-way split -- so each phi row's `_two_col_row` (the
|
||||
renderer's fixed-pixel `label* | symbol(66) | editor(138) | annot(150)` grid,
|
||||
see `script.py`) needed >450px it never had once two levels of column-halving
|
||||
were done to it. `_render_table`'s cell handling was generalised for `lc_table`
|
||||
(any editor kind, not just a tick, keyed per cell) and that generalisation
|
||||
reads for a plain `table` too -- its grid columns are ordinary (unconstrained)
|
||||
`ColumnDefinition`s, not `_two_col_row`'s fixed pixels, so a cell's editor gets
|
||||
real room regardless of how many columns the table has. Resistance Factors is
|
||||
now a `table`: rows Bending/Compression/Shear, columns Developed/Transferring,
|
||||
built by `_cell(F(key))` so the existing missing-field `problems` bookkeeping
|
||||
in `_field()` still runs even though the field block itself is only used for
|
||||
its `editor`/`key`/`choices`, never rendered directly -- which does mean these
|
||||
five phi keys carry no Notes-panel hint any more (`_render_field` is what
|
||||
files a field's hint into the notes; a bare table cell dict has no hook for
|
||||
that). The Concrete / Rebar-Mesh-Strand grids above stay on `columns`
|
||||
deliberately -- they are not being squeezed the way Resistance Factors was.
|
||||
|
||||
PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength
|
||||
keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in
|
||||
ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not
|
||||
perfectly consistent about it, and this file does not try to fix that, only
|
||||
to read it correctly field by field). Eriksson prints everything in **ksi**.
|
||||
Per the assignment: keep the psi keys as the source of truth and put the ksi
|
||||
reading in the field's `symbol` or `hint` instead of introducing a second,
|
||||
parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and
|
||||
`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this
|
||||
divided by 1000, in ksi" so the two reconcile by eye against a printed sheet;
|
||||
the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`,
|
||||
`strand_ep_ksi`) need no such note.
|
||||
|
||||
WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS.
|
||||
On Eriksson's own page these are not something the user types on THIS page at
|
||||
all -- they are a computed caption ("Design contains no exterior soil loads")
|
||||
that reads back whatever soil-load rows exist elsewhere in the model. Nothing
|
||||
in this codebase collects soil-load rows anywhere yet, so there is no field
|
||||
for this section to degrade to an "empty editor" for -- there is no editor to
|
||||
have. Rendering it as a static `sentence` instead of inventing a text field
|
||||
that would silently claim to collect something it cannot is the same
|
||||
discipline as `knowledge.same_face()` returning `None` rather than `False`:
|
||||
"cannot judge, and does not pretend to." One `problems` entry names this once
|
||||
for both columns, so the gap is visible without repeating the same sentence
|
||||
twice.
|
||||
|
||||
THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a
|
||||
checkbox that turns a calculation on, printed beside the sentence that
|
||||
applies when it is off ("R-Value not being calculated"). Both checkbox keys
|
||||
are new (`design_criteria.rvalue_calc_enabled`,
|
||||
`design_criteria.min_reinf_checked` -- see the naming note below) and both
|
||||
degrade the same way every other missing key does: an empty `check` editor
|
||||
plus a named `problems` entry, with the off-state sentence always shown
|
||||
underneath since "unchecked" is this page's honest default until the field
|
||||
exists to say otherwise.
|
||||
|
||||
NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted
|
||||
namespacing convention for one composite input (`stage.<stage_slug>.<opt>`,
|
||||
see `stage_field_key`). New keys here that do not fit a single flat name
|
||||
follow the same shape: `design_criteria.<thing>` for one-off Settings/Project
|
||||
fields (e.g. `design_criteria.analysis_beta_d`), and
|
||||
`load_combo.<strength|service>.<n>.include` /
|
||||
`load_combo.<strength|service>.min_earth_factor` for the Load Combinations
|
||||
grid, mirroring `stage_field_key`'s `<group>.<slug>.<opt>` shape exactly.
|
||||
|
||||
IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting
|
||||
is `'{0}'.format(...)`; every literal that carries the prime character or a
|
||||
non-ASCII letter is a `u'...'` literal with the coding cookie above declaring
|
||||
the file UTF-8, the same convention `panelanalysisform.py` uses throughout.
|
||||
Imports NOTHING beyond the standard library and, defensively,
|
||||
`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit,
|
||||
no `panelsketch`, so this loads and runs on plain CPython the way
|
||||
`tests/conftest.py` expects.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------- key existence
|
||||
|
||||
def _existing_keys():
|
||||
"""The set of keys `panelanalysisform.FIELDS` already declares, or None.
|
||||
|
||||
None means "could not judge" -- the import failed, or the module's shape
|
||||
changed out from under us mid-refactor -- and every caller below treats
|
||||
None the same as "key not found": every field on this page degrades to
|
||||
an empty editor plus a `problems` entry rather than silently assuming
|
||||
the other agent's in-flight edit added the key. Broad `except Exception`
|
||||
on purpose: a sibling agent saving a half-written `panelanalysisform.py`
|
||||
can raise a SyntaxError, not just ImportError, and this page must keep
|
||||
rendering through that.
|
||||
"""
|
||||
try:
|
||||
from vibe import panelanalysisform as pf
|
||||
return set(f['key'] for f in pf.FIELDS)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- fields
|
||||
#
|
||||
# One entry per field this page can lay out: key -> (label, symbol, hint,
|
||||
# required, editor, choices). Authored locally -- see the module docstring's
|
||||
# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the
|
||||
# two keys `panelanalysisform` itself already gates the Run button on
|
||||
# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching
|
||||
# the rest of Design Criteria in that module.
|
||||
_F = {
|
||||
# --------------------------------------------------- Project Information
|
||||
'project_name': (
|
||||
'Project name', '',
|
||||
'The name of the project this panel belongs to, for the record on '
|
||||
'this printout. Leave blank if the project has no name yet.',
|
||||
False, 'text', None),
|
||||
'client_name': (
|
||||
'Client name', '',
|
||||
"The client this panel's project is for. Leave blank if unknown.",
|
||||
False, 'text', None),
|
||||
'project_description': (
|
||||
'Project description', '',
|
||||
'A short free-text description of the project. Leave blank if '
|
||||
'there is nothing to add.',
|
||||
False, 'text', None),
|
||||
'project_location': (
|
||||
'Project location', '',
|
||||
'Where the project is built -- city, state, or site name. Leave '
|
||||
'blank if unknown.',
|
||||
False, 'text', None),
|
||||
'engineer_initials': (
|
||||
'Engineer of record', '',
|
||||
'Who is responsible for this design. Leave blank if not yet '
|
||||
'assigned.',
|
||||
False, 'text', None),
|
||||
'project_date': (
|
||||
'Design date', '',
|
||||
'The date this design applies to. Leave blank to leave it unset.',
|
||||
False, 'text', None),
|
||||
'job_number': (
|
||||
'Job number', '',
|
||||
"This job's internal tracking number, if it has one. Leave blank "
|
||||
'if none has been assigned yet.',
|
||||
False, 'text', None),
|
||||
'design_spec': (
|
||||
'Governing design code', '',
|
||||
'Which code edition this design is checked against. Leave blank '
|
||||
'to leave it unstated.',
|
||||
False, 'choice', ('ACI 318-19', 'ACI 318-14', 'ACI 318-11')),
|
||||
# ------------------------------------------- Material Properties/Concrete
|
||||
'fci_psi': (
|
||||
'Concrete strength at release', u"f′ci (psi)",
|
||||
u"How strong the concrete must be when the strand is cut or the "
|
||||
u"panel is stripped from the bed -- usually lower than the 28-day "
|
||||
u"strength. Eriksson prints this divided by 1000, in ksi. Leave "
|
||||
u"blank if this panel is not prestressed.",
|
||||
False, 'num', None),
|
||||
'fc_psi': (
|
||||
'Concrete strength at 28 days', u"f′c (psi)",
|
||||
u"The concrete's design strength once fully cured; every stress "
|
||||
u"limit on this page is a multiple of this number. Eriksson prints "
|
||||
u"this divided by 1000, in ksi. Required -- the solve cannot run "
|
||||
u"without it.",
|
||||
True, 'num', None),
|
||||
# Both moduli are DERIVED (`panelanalysisform.DERIVED_FIELDS`, ACI 318
|
||||
# 19.2.2, Ec = 33 wc^1.5 sqrt(f'c)) and the page keeps them live as
|
||||
# f'c / f'ci / unit weight are edited until the user types into one
|
||||
# (issue #22, item 1). The old text here said a rule was still to be
|
||||
# agreed; it had been in the field model since 2026-09-03. The solve
|
||||
# still does not read either -- `panellift` computes its own -- and the
|
||||
# hint says so, so the page mutes them honestly.
|
||||
'conc_eci_psi': (
|
||||
'Concrete elastic modulus at release', u"E′ci (psi)",
|
||||
u"How stiff the concrete is at release strength. Computed by ACI "
|
||||
u"318 from f′ci and the unit weight (Ec = 33 wc^1.5 sqrt(f′ci)) and "
|
||||
u"kept up to date as you edit those; type a value to override it. "
|
||||
u"Not yet used by the solve.",
|
||||
False, 'num', None),
|
||||
'conc_ec_psi': (
|
||||
'Concrete elastic modulus, in-place', u"E′c (psi)",
|
||||
u"How stiff the concrete is once fully cured. Computed by ACI 318 "
|
||||
u"from f′c and the unit weight (Ec = 33 wc^1.5 sqrt(f′c)) and kept "
|
||||
u"up to date as you edit those; type a value to override it. Not "
|
||||
u"yet used by the solve.",
|
||||
False, 'num', None),
|
||||
'unit_weight_pcf': (
|
||||
'Concrete unit weight', 'pcf',
|
||||
"How much the concrete weighs per cubic foot; the panel's own "
|
||||
'weight is computed from this. Required.',
|
||||
True, 'num', None),
|
||||
'concrete_type': (
|
||||
'Concrete type', '',
|
||||
'What kind of concrete this panel is cast from (normal weight, '
|
||||
'lightweight, ...). Reference only -- nothing computes from it '
|
||||
'yet.',
|
||||
False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')),
|
||||
# ----------------------------------------------- Material Properties/Bar
|
||||
'rebar_fy_ksi': (
|
||||
'Rebar yield strength', 'fy (ksi)',
|
||||
'The stress at which ordinary (non-prestressed) reinforcing bar '
|
||||
'starts to yield. Shown for the record; not yet used by the '
|
||||
'solve.',
|
||||
False, 'num', None),
|
||||
'rebar_es_ksi': (
|
||||
'Rebar elastic modulus', 'Es (ksi)',
|
||||
'How stiff ordinary reinforcing steel is. Shown for the record; '
|
||||
'not yet used by the solve.',
|
||||
False, 'num', None),
|
||||
'ps_fpu_ksi': (
|
||||
u'Strand ultimate strength', u'fₚᵤ (ksi)',
|
||||
'The breaking strength of the prestressing strand. Leave blank if '
|
||||
'this panel is not prestressed.',
|
||||
False, 'num', None),
|
||||
'strand_ep_ksi': (
|
||||
'Strand elastic modulus', 'Ep (ksi)',
|
||||
'How stiff the prestressing strand is. Shown for the record; not '
|
||||
'yet used by the solve.',
|
||||
False, 'num', None),
|
||||
# ----------------------------------------------- Material Properties/Mesh
|
||||
'mesh_fy_ksi': (
|
||||
'Mesh yield strength', 'fy (ksi)',
|
||||
'The yield strength of the welded wire mesh. Defaults to the '
|
||||
'common 80 ksi. Shown for the record; not yet used by the solve.',
|
||||
False, 'num', None),
|
||||
'mesh_es_ksi': (
|
||||
'Mesh elastic modulus', 'Es (ksi)',
|
||||
'How stiff the welded wire mesh is. Defaults to 29000 ksi. Shown '
|
||||
'for the record; not yet used by the solve.',
|
||||
False, 'num', None),
|
||||
# -------------------------------------------------------- Load combos
|
||||
'design_criteria.min_earth_factor_strength': (
|
||||
'Minimum earth factor, strength combinations', '',
|
||||
'The smallest lateral-earth-pressure factor applied across the '
|
||||
'strength combinations below. Leave blank to use none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.min_earth_factor_service': (
|
||||
'Minimum earth factor, service combinations', '',
|
||||
'The smallest lateral-earth-pressure factor applied across the '
|
||||
'service combinations below. Leave blank to use none.',
|
||||
False, 'num', None),
|
||||
# --------------------------------------------------- Settings/Deflection
|
||||
'deflection_limit': (
|
||||
'Typical deflection limit', 'L / n',
|
||||
'How much the panel is allowed to bow or sag, as a fraction of its '
|
||||
'span, e.g. "150" for L/150. Read into the in-place service check; '
|
||||
'blank leaves that check NOT JUDGED.',
|
||||
False, 'text', None),
|
||||
'design_criteria.deflection_add_bow': (
|
||||
'Add an initial bow to reported deflections?', '',
|
||||
'Whether the panel is assumed to already be slightly bowed before '
|
||||
'any load is applied, and that starting bow is added to every '
|
||||
'reported deflection. Leave unchecked to assume none.',
|
||||
False, 'check', None),
|
||||
'design_criteria.deflection_additional_bow_in': (
|
||||
'Additional bow assumed', 'in',
|
||||
'How much starting bow to add, if the box above is checked. Leave '
|
||||
'blank for none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.deflection_check_cantilever': (
|
||||
'Check deflections on cantilevers?', '',
|
||||
'Whether cantilevered portions of the panel get their own '
|
||||
'deflection check, separate from the typical limit above. Leave '
|
||||
'unchecked to skip it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.deflection_cantilever_limit': (
|
||||
'Cantilever deflection limit', 'L / n',
|
||||
'The separate deflection limit for cantilevered portions, if the '
|
||||
'box above is checked.',
|
||||
False, 'choice', ('L/75', 'L/120', 'L/150')),
|
||||
# ---------------------------------------------- Settings/Structural Anal.
|
||||
'second_order': (
|
||||
'2nd-order effects considered?', '',
|
||||
'Whether P-delta / second-order bending is considered for this '
|
||||
'panel. Not yet used by the solve.',
|
||||
False, 'text', None),
|
||||
'cracking_assumption': (
|
||||
'Cracked or uncracked section assumed', '',
|
||||
'Whether the stress check assumes the concrete section is cracked '
|
||||
'or uncracked. Read into the in-place service check as the '
|
||||
'cracking class.',
|
||||
False, 'choice', ('Cracked', 'Uncracked')),
|
||||
'design_criteria.analysis_beta_d': (
|
||||
u'Sway condition for 2nd-order analysis', u'βd',
|
||||
'Whether the 2nd-order analysis above treats this panel as braced '
|
||||
'(non-sway) or unbraced (sway). Leave blank if not applicable.',
|
||||
False, 'choice', ('Non-Sway', 'Sway')),
|
||||
'design_criteria.cracking_method': (
|
||||
'Cracking method', '',
|
||||
'Which published method decides whether the section is treated as '
|
||||
'cracked. Leave blank if not applicable.',
|
||||
False, 'choice', ('Mast Method', 'Uncracked', 'Cracked')),
|
||||
'design_criteria.cracking_limit_sqrt_fc': (
|
||||
u'Cracking stress limit', u"n·√f′c",
|
||||
u"The stress, as a multiple of √f′c, at which the "
|
||||
u"section is assumed to crack. Leave blank to use the built-in "
|
||||
u"default.",
|
||||
False, 'num', None),
|
||||
'design_criteria.rvalue_calc_enabled': (
|
||||
'Calculate an R-value for this panel?', '',
|
||||
'Whether an insulation R-value is calculated for this panel. Leave '
|
||||
'unchecked if it is not being calculated -- that is the default.',
|
||||
False, 'check', None),
|
||||
'design_criteria.min_reinf_checked': (
|
||||
'Check minimum reinforcement?', '',
|
||||
'Whether the ACI minimum-steel requirement is checked for this '
|
||||
'panel. Leave unchecked if it is not being checked -- that is the '
|
||||
'default.',
|
||||
False, 'check', None),
|
||||
# -------------------------------------------------- Settings/Stress opts
|
||||
'design_criteria.stress_failure_if_cracked': (
|
||||
'Fail the panel if it cracks?', '',
|
||||
'Whether a cracked section is treated as an outright failure '
|
||||
'rather than just a flag. Leave unchecked to only flag it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.stress_ignore_lateral_ecc': (
|
||||
'Ignore lateral strand eccentricity?', '',
|
||||
'Whether side-to-side strand placement is ignored when computing '
|
||||
'stress. Leave unchecked to include it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.stress_use_principal_axis': (
|
||||
'Use the principal axis for stress?', '',
|
||||
'Whether stress is computed about the section\'s principal axis '
|
||||
'rather than its geometric one. Leave unchecked to use the '
|
||||
'geometric axis.',
|
||||
False, 'check', None),
|
||||
# Eriksson prints these as three rows under one "In-Place Stress Limits"
|
||||
# heading -- Tension (uncracked), Tension (transition / modulus of
|
||||
# rupture), Compression -- and the review on 2026-09-04 (WPA655) found
|
||||
# this page missing the first of the three (`inplace_tension_sqrt` is a
|
||||
# real `panelanalysisform.FIELDS` key, just never surfaced here) and
|
||||
# labelling the other two by what they compute rather than by Eriksson's
|
||||
# own row names. All three keep the naming scheme now.
|
||||
'inplace_tension_sqrt': (
|
||||
u'Tension, uncracked', u"n·√f′c",
|
||||
u"The concrete's tensile stress limit once erected, while the "
|
||||
u"section is still assumed uncracked, as a multiple of √f′c. "
|
||||
u"Leave blank to use the built-in default.",
|
||||
False, 'num', None),
|
||||
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
|
||||
u'Tension, transition (modulus of rupture)', u"n·√f′c",
|
||||
u"The concrete's tensile cracking stress once erected, as a "
|
||||
u"multiple of √f′c. Leave blank to use the built-in "
|
||||
u"default.",
|
||||
False, 'num', None),
|
||||
'inplace_comp_ratio': (
|
||||
u'Compression', u"n·f′c",
|
||||
u"The compression limit for the panel once it is standing in "
|
||||
u"place, as a fraction of f′c. Not yet used by the solve.",
|
||||
False, 'num', None),
|
||||
'handling_tension_sqrt': (
|
||||
'Tension allowed while handling', u"n·√f′ci",
|
||||
u"How much the concrete may be pulled apart during stripping / "
|
||||
u"handling, as a multiple of √f′ci. Blank uses the "
|
||||
u"built-in default.",
|
||||
False, 'num', None),
|
||||
'handling_comp_ratio': (
|
||||
'Compression allowed while handling', u"n·f′ci",
|
||||
u"How much the concrete may be squeezed during handling, as a "
|
||||
u"fraction of f′ci. Blank uses the built-in default.",
|
||||
False, 'num', None),
|
||||
'design_criteria.flexure_method': (
|
||||
'Flexure calculation method', '',
|
||||
'Which method computes bending capacity. Leave blank to use the '
|
||||
'built-in default.',
|
||||
False, 'choice', ('Whitney Stress Block', 'Strain Compatibility')),
|
||||
'design_criteria.flexure_ignored_end_distance_in': (
|
||||
'Ignored end distance', 'in',
|
||||
'A length at each end of the panel excluded from the flexure '
|
||||
'check (strand not yet developed there). Leave blank for none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.flexure_cracking_check': (
|
||||
'Flexural cracking check', '',
|
||||
'Which cracking check applies to the flexure calculation, if any. '
|
||||
'Leave blank for none.',
|
||||
False, 'choice', ('None', '1.2 Mcr', '1.33 Mu')),
|
||||
# ------------------------------------------------ Settings/Resist. Fact.
|
||||
'phi_flexure': (
|
||||
'Bending strength reduction factor, developed', u'φ, tension',
|
||||
"The ACI safety factor applied to the panel's bending strength "
|
||||
'once the strand is fully developed. Blank uses the built-in '
|
||||
'default (0.90).',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_compression': (
|
||||
'Compression strength reduction factor, developed',
|
||||
u'φ, compression',
|
||||
"The ACI safety factor applied to the panel's compression "
|
||||
'strength once the strand is fully developed. Leave blank to use '
|
||||
'the built-in default.',
|
||||
False, 'num', None),
|
||||
'phi_shear': (
|
||||
'Shear strength reduction factor, developed', u'φ, shear',
|
||||
"The ACI safety factor applied to the panel's shear strength once "
|
||||
'the strand is fully developed. Blank uses the built-in default '
|
||||
'(0.75).',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_tension_transfer': (
|
||||
'Bending strength reduction factor, transferring',
|
||||
u'φ, tension',
|
||||
'The same safety factor as above, but while the strand is still '
|
||||
'transferring its force into the concrete. Leave blank if not '
|
||||
'applicable.',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_shear_transfer': (
|
||||
'Shear strength reduction factor, transferring', u'φ, shear',
|
||||
'The same safety factor as above, but while the strand is still '
|
||||
'transferring its force into the concrete. Leave blank if not '
|
||||
'applicable.',
|
||||
False, 'num', None),
|
||||
'dev_mult': (
|
||||
'Strand development multiplier, normal', '',
|
||||
'A multiplier on the strand development length for ordinary '
|
||||
'(non-debonded, uncut) strand. Blank uses the built-in default '
|
||||
'(1.0).',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_debonded': (
|
||||
'Strand development multiplier, debonded', '',
|
||||
'The same multiplier, for strand that is deliberately debonded '
|
||||
'over part of its length. Leave blank to use the built-in '
|
||||
'default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_cut': (
|
||||
'Strand development multiplier, cut', '',
|
||||
'The same multiplier, for strand that is field-cut rather than '
|
||||
'released normally. Leave blank to use the built-in default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_mild': (
|
||||
'Development multiplier, mild reinforcement', '',
|
||||
'The same idea applied to ordinary (non-prestressed) reinforcing '
|
||||
'bar. Leave blank to use the built-in default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.shear_treat_as_column': (
|
||||
'Treat this panel as a column for shear?', '',
|
||||
'Whether the shear check uses column rules rather than wall '
|
||||
'rules. Leave unchecked to use wall rules.',
|
||||
False, 'check', None),
|
||||
'design_criteria.axial_ignore_tensile_failures': (
|
||||
'Ignore axial tensile-strength failures?', '',
|
||||
'Whether the axial check is allowed to pass even when the '
|
||||
"concrete's tensile strength alone is exceeded. Leave unchecked "
|
||||
'to flag them.',
|
||||
False, 'check', None),
|
||||
'design_criteria.axial_ignore_buckling_failures': (
|
||||
'Ignore buckling failures?', '',
|
||||
'Whether the axial check is allowed to pass even when the panel '
|
||||
'would buckle. Leave unchecked to flag them.',
|
||||
False, 'check', None),
|
||||
}
|
||||
|
||||
# Prestress-loss group: shown as a sentence when every one of these is blank
|
||||
# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here"
|
||||
# style for Exterior/Interior Soil, applied to the one other Design Criteria
|
||||
# group that is entirely optional and entirely empty on a fresh panel.
|
||||
_PRESTRESS_LOSS_FIELDS = (
|
||||
'ps_loss_method', 'ps_loss_humidity_pct',
|
||||
'ps_loss_release_hours', 'ps_loss_erection_days',
|
||||
)
|
||||
_F['ps_loss_method'] = (
|
||||
'Prestress loss method', '',
|
||||
'Which published method (PCI, AASHTO, ...) the assumed loss below '
|
||||
'comes from. Reference only.',
|
||||
False, 'text', None)
|
||||
_F['ps_loss_humidity_pct'] = (
|
||||
'Ambient humidity assumed', '%',
|
||||
'The relative humidity the loss method uses -- losses are larger in '
|
||||
'drier air. Not yet used by the solve.',
|
||||
False, 'num', None)
|
||||
_F['ps_loss_release_hours'] = (
|
||||
'Hours from casting to release', '',
|
||||
'How many hours after casting the strand is cut / the panel is '
|
||||
'stripped. Not yet used by the solve.',
|
||||
False, 'num', None)
|
||||
_F['ps_loss_erection_days'] = (
|
||||
'Days from casting to erection', '',
|
||||
'How many days after casting the panel is erected in place. Not yet '
|
||||
'used by the solve.',
|
||||
False, 'num', None)
|
||||
|
||||
|
||||
def _field(key, problems, seen, existing):
|
||||
"""One `field` block for `key`, plus a `problems` entry if it is missing.
|
||||
|
||||
`seen` de-dupes: several groups below reuse a couple of keys (none do
|
||||
today, but the guard costs nothing and keeps a future edit from quietly
|
||||
filing the same problem twice). `existing` is `_existing_keys()`'s
|
||||
result, threaded through rather than re-read per field so one broken
|
||||
import during a render is reported once, not dozens of times.
|
||||
"""
|
||||
spec = _F.get(key)
|
||||
if spec is None:
|
||||
# Programming error inside this file, not a missing form key --
|
||||
# still degrade rather than raise, and say so plainly.
|
||||
problems.append(
|
||||
"Design Criteria has no text authored for the key '{0}' -- "
|
||||
'this is a bug in pa_criteria.py, not a missing form field.'
|
||||
.format(key))
|
||||
return {'kind': 'field', 'key': key, 'label': key, 'symbol': None,
|
||||
'hint': None, 'required': False, 'editor': 'text',
|
||||
'choices': None}
|
||||
label, symbol, hint, required, editor, choices = spec
|
||||
if existing is None or key not in existing:
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
problems.append(
|
||||
"'{0}' ({1}) has no backing field in panelanalysisform.FIELDS "
|
||||
'yet -- shown as an empty editor until it is added.'
|
||||
.format(label, key))
|
||||
return {'kind': 'field', 'key': key, 'label': label,
|
||||
'symbol': symbol or None, 'hint': hint, 'required': required,
|
||||
'editor': editor, 'choices': list(choices) if choices else None}
|
||||
|
||||
|
||||
def _heading(text, level):
|
||||
return {'kind': 'heading', 'text': text, 'level': level}
|
||||
|
||||
|
||||
def _cols(columns):
|
||||
return {'kind': 'columns', 'columns': columns}
|
||||
|
||||
|
||||
def _sentence(text):
|
||||
return {'kind': 'sentence', 'text': text}
|
||||
|
||||
|
||||
def _rule():
|
||||
return {'kind': 'rule'}
|
||||
|
||||
|
||||
def _space(h=1):
|
||||
return {'kind': 'space', 'h': h}
|
||||
|
||||
|
||||
def _table(headers, rows, header_groups=None):
|
||||
return {'kind': 'table', 'headers': list(headers),
|
||||
'header_groups': header_groups, 'rows': [list(r) for r in rows]}
|
||||
|
||||
|
||||
def _cell(field_block):
|
||||
"""A `table` cell dict for one `_field()` result -- the Resistance
|
||||
Factors table (see the module docstring's 2026-09-04 update) reuses
|
||||
`_field()` only for its `editor` / `key` / `choices` and the
|
||||
missing-backing-field `problems` entry it already files, never for the
|
||||
`field` block itself (a bare table cell has nowhere to put a label or a
|
||||
hint, so those are simply not repeated here)."""
|
||||
return {'editor': field_block['editor'], 'key': field_block['key'],
|
||||
'choices': field_block['choices']}
|
||||
|
||||
|
||||
def _group_or_sentence(values, keys, empty_text, build_filled):
|
||||
"""[blocks] -- a `sentence` when every key in `keys` is blank, else
|
||||
whatever `build_filled()` returns.
|
||||
|
||||
This is the "nothing here vs. never asked" split applied at group
|
||||
granularity: `keys` all being blank in `values` is a real, filled-in
|
||||
answer ("nothing entered"), not the absence of a form -- so it is shown
|
||||
as a plain sentence instead of a wall of empty boxes, the same way
|
||||
Eriksson's own printout shows a caption instead of blank inputs when
|
||||
there is nothing to report. `values` is read with `.get(...)`, never
|
||||
indexed, and never mutated.
|
||||
|
||||
`build_filled` is a zero-argument CALLABLE, not a plain list -- the
|
||||
filled-in branch typically calls `F(key)`, which files a `problems`
|
||||
entry for any key still missing from `panelanalysisform.FIELDS`, and
|
||||
that side effect must only happen for fields actually being shown. A
|
||||
plain list argument would be built eagerly by the caller before this
|
||||
function ever looks at `keys`, filing a phantom "missing field" problem
|
||||
for a field the empty-sentence branch never renders.
|
||||
"""
|
||||
for k in keys:
|
||||
v = values.get(k)
|
||||
if v is not None and str(v).strip():
|
||||
return build_filled()
|
||||
return [_sentence(empty_text)]
|
||||
|
||||
|
||||
# --------------------------------------------------------- load combinations
|
||||
#
|
||||
# Standard ACI 318-14 basic strength/service combinations -- public code
|
||||
# text, the same eight/ten rows any ACI-compliant tool would print, not this
|
||||
# job's data.
|
||||
#
|
||||
# THESE ARE NOW A FALLBACK, not the source. The live copy is
|
||||
# `panelanalysisform.LOAD_COMBO_ROWS`, because the factors became editable form
|
||||
# VALUES and a value needs a key, which is that module's business. These stay
|
||||
# because this file's whole discipline is that a sibling module mid-edit must
|
||||
# not take the page down: if the import fails, the table still prints the ACI
|
||||
# defaults (as plain text, with a named problem saying it cannot be edited)
|
||||
# instead of vanishing. Keep the two in step -- a host test compares them.
|
||||
_STRENGTH_COMBOS = (
|
||||
('1', '1.4', '0', '0', '0', '0', '1', '1.6'),
|
||||
('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'),
|
||||
('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'),
|
||||
('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'),
|
||||
('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'),
|
||||
('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'),
|
||||
('7', '0.9', '0', '0', '1', '0', '1', '1.6'),
|
||||
('8', '0.9', '0', '0', '0', '1', '1', '1.6'),
|
||||
)
|
||||
_SERVICE_COMBOS = (
|
||||
('1', '1', '0', '0', '0', '0', '0.75', '1'),
|
||||
('2', '1', '1', '0', '0', '0', '0.75', '1'),
|
||||
('3', '1', '0', '1', '0', '0', '0.75', '1'),
|
||||
('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'),
|
||||
('5', '1', '0', '0', '0.6', '0', '0.75', '1'),
|
||||
('6', '1', '0', '0', '0', '0.7', '0.75', '1'),
|
||||
('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'),
|
||||
('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'),
|
||||
('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'),
|
||||
('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'),
|
||||
)
|
||||
_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include']
|
||||
|
||||
|
||||
def _combo_table(name, values, problems):
|
||||
"""One EDITABLE load-combination table, as an `lc_table` block.
|
||||
|
||||
Not a plain `table` any more. The owner ruled (2026-09-03) that the load
|
||||
factors are the user's to change and that a row can be added, and a `table`
|
||||
cell is only ever a string or a tick box -- so this emits `lc_table`, whose
|
||||
factor cells each carry an `{'editor': 'num', 'key': ...}` the renderer
|
||||
turns into a live box, plus the `factors` list the renderer's "Add LC"
|
||||
button needs to build a new row's cells.
|
||||
|
||||
The FACTORS themselves come from `panelanalysisform.LOAD_COMBO_ROWS`, which
|
||||
is the one place that knows both the ACI default and the key it is stored
|
||||
under. The local tuples stay as a fallback for the case this file already
|
||||
handles everywhere else -- the sibling module mid-edit and unimportable --
|
||||
so a broken import degrades this page to the code defaults with no live
|
||||
keys rather than to no table at all.
|
||||
|
||||
`values` is accepted (and read only for the user's own added rows) rather
|
||||
than the row tuples the old signature took: the rows are no longer this
|
||||
file's to decide.
|
||||
"""
|
||||
values = values if isinstance(values, dict) else {}
|
||||
fallback = {'strength': _STRENGTH_COMBOS, 'service': _SERVICE_COMBOS}
|
||||
factors = list(_COMBO_HEADERS[1:-1])
|
||||
try:
|
||||
from vibe import panelanalysisform as paf
|
||||
factors = list(paf.LOAD_COMBO_FACTORS)
|
||||
row_defaults = dict((r[0], r) for r in paf.LOAD_COMBO_ROWS.get(name, ()))
|
||||
row_ids = paf.load_combo_row_ids(name, values)
|
||||
factor_key = paf.load_combo_factor_key
|
||||
include_key = paf.load_combo_include_key
|
||||
except Exception:
|
||||
problems.append(
|
||||
'could not read panelanalysisform for the {0} load-combination '
|
||||
'factors; the table below shows the ACI defaults as plain text '
|
||||
'and cannot be edited until that module loads.'.format(name))
|
||||
row_defaults = dict((r[0], r) for r in fallback.get(name, ()))
|
||||
row_ids = [r[0] for r in fallback.get(name, ())]
|
||||
|
||||
def factor_key(nm, rid, col):
|
||||
return 'load_combo.{0}.{1}.{2}'.format(nm, rid, col)
|
||||
|
||||
def include_key(nm, rid):
|
||||
return 'load_combo.{0}.{1}.include'.format(nm, rid)
|
||||
|
||||
table_rows = []
|
||||
for rid in row_ids:
|
||||
cells = [str(rid)]
|
||||
default = row_defaults.get(rid)
|
||||
for i, col in enumerate(factors):
|
||||
cells.append({'editor': 'num', 'width': 44,
|
||||
'key': factor_key(name, rid, col),
|
||||
'default': (default[i + 1] if default and
|
||||
i + 1 < len(default) else None)})
|
||||
cells.append({'editor': 'check', 'key': include_key(name, rid)})
|
||||
table_rows.append(cells)
|
||||
return {'kind': 'lc_table', 'name': name,
|
||||
'headers': list(_COMBO_HEADERS), 'header_groups': None,
|
||||
'rows': table_rows, 'factors': factors, 'row_key': None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- page
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Design Criteria section.
|
||||
|
||||
`values` is the form's current string-valued dict, read defensively --
|
||||
every lookup goes through `.get(...)` and a key this module expects but
|
||||
does not find is treated as blank, never as an error, because another
|
||||
agent is restructuring `panelanalysisform.FIELDS` at the same time this
|
||||
is being written. `values` is never mutated. `reads` is optional and, on
|
||||
this section, only used for the one-line "Panel <mark>" context caption
|
||||
at the top -- everything else on Design Criteria is job-level or
|
||||
material data, not something read off the framing element.
|
||||
|
||||
Never raises: every lookup that could fail (the `panelanalysisform`
|
||||
import, a missing dict key) degrades to a `problems` entry instead.
|
||||
"""
|
||||
values = values if isinstance(values, dict) else {}
|
||||
reads = reads if isinstance(reads, dict) else {}
|
||||
problems = []
|
||||
seen = set()
|
||||
existing = _existing_keys()
|
||||
if existing is None:
|
||||
problems.append(
|
||||
'could not read panelanalysisform.FIELDS to check which keys '
|
||||
'already exist (the module may be mid-edit); every field on '
|
||||
'this page is shown, and is reported as missing until this is '
|
||||
'confirmed.')
|
||||
|
||||
def F(key):
|
||||
return _field(key, problems, seen, existing)
|
||||
|
||||
blocks = []
|
||||
blocks.append(_heading('Design Criteria', 1))
|
||||
|
||||
mark = (reads.get('mark') or '').strip()
|
||||
design_number = (reads.get('design_number') or '').strip()
|
||||
if mark or design_number:
|
||||
blocks.append({'kind': 'kv', 'rows': [
|
||||
('Panel', mark or '(mark not read)'),
|
||||
('Design number', design_number or '(not read)'),
|
||||
]})
|
||||
|
||||
# ----------------------------------------------------- Project Information
|
||||
blocks.append(_heading('Project Information', 2))
|
||||
blocks.append(_cols([
|
||||
[F('project_name'), F('client_name'), F('project_description')],
|
||||
[F('project_location'), F('engineer_initials')],
|
||||
[F('project_date'), F('job_number'), F('design_spec')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ------------------------------------------------------ Material Properties
|
||||
blocks.append(_heading('Material Properties', 2))
|
||||
blocks.append(_heading('Concrete', 3))
|
||||
blocks.append(_cols([
|
||||
[F('fci_psi'), F('fc_psi')],
|
||||
[F('conc_eci_psi'), F('conc_ec_psi')],
|
||||
[F('unit_weight_pcf'), F('concrete_type')],
|
||||
]))
|
||||
blocks.append(_space(1))
|
||||
blocks.append(_cols([
|
||||
[_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')],
|
||||
[_heading('Mesh', 3), F('mesh_fy_ksi'), F('mesh_es_ksi')],
|
||||
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ---------------------------------------------------------- Prestress Loss
|
||||
#
|
||||
# Two columns, not the one wide column this used to be laid out in: a
|
||||
# single-column `_cols` hands `_two_col_row`'s star label a full page
|
||||
# width to stretch into, which is what the 2026-09-04 review (WPA655)
|
||||
# reported as the '%' symbol on `ps_loss_humidity_pct` "floating mid-row
|
||||
# far from its editor" -- the symbol and editor are still adjacent (see
|
||||
# `script.py`'s `_two_col_row`), but a page-wide row puts that pair a
|
||||
# long way from the label a reader's eye is anchored to. Splitting the
|
||||
# four fields into two narrower columns, the same way every other group
|
||||
# on this page already does, keeps the whole row -- label, symbol, and
|
||||
# editor together -- inside one eye span.
|
||||
blocks.append(_heading('Prestress Losses', 2))
|
||||
blocks.extend(_group_or_sentence(
|
||||
values, _PRESTRESS_LOSS_FIELDS,
|
||||
'No prestress loss assumptions entered for this panel.',
|
||||
lambda: [_cols([
|
||||
[F('ps_loss_method'), F('ps_loss_humidity_pct')],
|
||||
[F('ps_loss_release_hours'), F('ps_loss_erection_days')],
|
||||
])]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# -------------------------------------------------------------- Soil loads
|
||||
#
|
||||
# `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR
|
||||
# SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing
|
||||
# to degrade to an editor for here.
|
||||
problems.append(
|
||||
'soil loads are not collected anywhere in this form yet; Exterior '
|
||||
'Soil and Interior Soil always read as "no loads" until a later '
|
||||
'stage adds that input.')
|
||||
blocks.append(_cols([
|
||||
[_heading('Exterior Soil', 2),
|
||||
_sentence('Design contains no exterior soil loads.')],
|
||||
[_heading('Interior Soil', 2),
|
||||
_sentence('Design contains no interior soil loads.')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ----------------------------------------------------------- Load Combos
|
||||
blocks.append(_heading('Load Combinations', 1))
|
||||
blocks.append(_cols([
|
||||
[_heading('Strength Combinations', 2),
|
||||
_combo_table('strength', values, problems),
|
||||
F('design_criteria.min_earth_factor_strength')],
|
||||
[_heading('Service Combinations', 2),
|
||||
_combo_table('service', values, problems),
|
||||
F('design_criteria.min_earth_factor_service')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# --------------------------------------------------------------- Settings
|
||||
blocks.append(_heading('Settings', 1))
|
||||
col_a = [
|
||||
_heading('Deflection Limits', 2),
|
||||
F('design_criteria.deflection_add_bow'),
|
||||
F('design_criteria.deflection_additional_bow_in'),
|
||||
F('deflection_limit'),
|
||||
F('design_criteria.deflection_check_cantilever'),
|
||||
F('design_criteria.deflection_cantilever_limit'),
|
||||
_heading('Structural Analysis', 2),
|
||||
F('second_order'),
|
||||
F('cracking_assumption'),
|
||||
F('design_criteria.analysis_beta_d'),
|
||||
F('design_criteria.cracking_method'),
|
||||
F('design_criteria.cracking_limit_sqrt_fc'),
|
||||
F('design_criteria.rvalue_calc_enabled'),
|
||||
] + _group_or_sentence(
|
||||
values, ('design_criteria.rvalue_calc_enabled',),
|
||||
'R-value is not being calculated for this panel.', lambda: []) + [
|
||||
F('design_criteria.min_reinf_checked'),
|
||||
] + _group_or_sentence(
|
||||
values, ('design_criteria.min_reinf_checked',),
|
||||
'Minimum steel is not being checked for this panel.', lambda: [])
|
||||
|
||||
col_b = [
|
||||
_heading('Stress Options', 2),
|
||||
F('design_criteria.stress_failure_if_cracked'),
|
||||
F('design_criteria.stress_ignore_lateral_ecc'),
|
||||
F('design_criteria.stress_use_principal_axis'),
|
||||
_heading('In-Place Stress Limits', 2),
|
||||
F('inplace_tension_sqrt'),
|
||||
F('design_criteria.inplace_modulus_rupture_sqrt_fc'),
|
||||
F('inplace_comp_ratio'),
|
||||
_heading('Handling Stress Limits', 2),
|
||||
F('handling_tension_sqrt'),
|
||||
F('handling_comp_ratio'),
|
||||
_heading('Flexure Options', 2),
|
||||
F('design_criteria.flexure_method'),
|
||||
F('design_criteria.flexure_ignored_end_distance_in'),
|
||||
F('design_criteria.flexure_cracking_check'),
|
||||
]
|
||||
|
||||
col_c = [
|
||||
_heading('Resistance Factors', 2),
|
||||
# A `table`, not the `columns`-of-`field`s this used to be -- the
|
||||
# 2026-09-04 review (WPA655) found every phi editor rendered with
|
||||
# zero width. That old layout was two `columns` sub-columns
|
||||
# ("Developed" | "Transferring") of `field` blocks, nested inside
|
||||
# `col_c` itself one of three columns -- two levels of halving, each
|
||||
# `field` needing the renderer's fixed ~450px `_two_col_row` it never
|
||||
# had a fraction of. `_render_table` puts a cell's editor in an
|
||||
# ordinary (unconstrained) grid column instead, so there is always
|
||||
# room. See the module docstring's 2026-09-04 update for why `_cell`
|
||||
# still routes every key through `F()` first (it is what keeps the
|
||||
# existing "no backing field yet" `problems` bookkeeping honest).
|
||||
_table(
|
||||
[u'φ', 'Developed', 'Transferring'],
|
||||
[
|
||||
['Bending', _cell(F('phi_flexure')),
|
||||
_cell(F('design_criteria.phi_tension_transfer'))],
|
||||
['Compression', _cell(F('design_criteria.phi_compression')),
|
||||
'Not applicable once the strand has fully transferred.'],
|
||||
['Shear', _cell(F('phi_shear')),
|
||||
_cell(F('design_criteria.phi_shear_transfer'))],
|
||||
],
|
||||
),
|
||||
_heading('Development Multipliers', 2),
|
||||
F('dev_mult'),
|
||||
F('design_criteria.dev_mult_debonded'),
|
||||
F('design_criteria.dev_mult_cut'),
|
||||
F('design_criteria.dev_mult_mild'),
|
||||
_heading('Shear Options', 2),
|
||||
F('design_criteria.shear_treat_as_column'),
|
||||
_heading('Axial Options', 2),
|
||||
F('design_criteria.axial_ignore_tensile_failures'),
|
||||
F('design_criteria.axial_ignore_buckling_failures'),
|
||||
]
|
||||
|
||||
blocks.append(_cols([col_a, col_b, col_c]))
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,807 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Aggressive Panel Analysis defaults: fill every blank the tool can defend.
|
||||
|
||||
WHY THIS EXISTS. Owner, 2026-09-04: "目标是我按一下 WPA655 的 panel analysis,
|
||||
然后手动输入 loading, 剩下的它就能跑了" -- press the button, type the loading,
|
||||
everything else already has a number. The form opened that day with 141 of
|
||||
315 fields blank. His instruction was explicit that BLANK is the failure mode
|
||||
here, not a wrong-looking guess -- every field with a defensible default gets
|
||||
one, and every one of them names where it came from. Only true LOADING (floor
|
||||
loads, wind speed / pressure, seismic Cs, soil) is left for the user, because
|
||||
those are the numbers this tool cannot know and must not invent.
|
||||
|
||||
WHAT THIS MODULE OWNS. `panelanalysisform.prefill()` already fills what the
|
||||
model can read plus a small `PREFILL_DEFAULTS` table of generic precast
|
||||
constants (concrete type, rebar grade, mesh grade, the PCI loss-method
|
||||
seeds). This module is a SECOND, later pass over the same `values` dict --
|
||||
covering the much larger set of Design Criteria / Structural Model / Handling
|
||||
fields that table does not touch, most of which do not even have a home in
|
||||
`panelanalysisform.FIELDS` yet (they live in `pa_criteria._F` and inline in
|
||||
`pa_structural.page()` instead, under the `design_criteria.*` / `load_opt_*`
|
||||
/ `wind_*` / `temp_*` / `restraint_*` / `stage.<slug>.<opt>` naming this
|
||||
codebase already uses). `apply_defaults()` is meant to run AFTER `prefill()`
|
||||
in the button script, so the ordinary precedence rule holds without this
|
||||
module doing anything special for it: a value already sitting in `values` --
|
||||
a model read, a plant `panelanalysis.settings.*` pin, or a
|
||||
`PREFILL_DEFAULTS` seed -- always wins, and this module only ever fills what
|
||||
is STILL blank.
|
||||
|
||||
SOURCES CITED IN `why`. Three kinds, and every filled entry names one:
|
||||
|
||||
* "Eriksson Wall default (<page> page)" -- the value the owner's own
|
||||
Eriksson Wall runs show for this field on a fresh panel. This is the
|
||||
fallback for most of the Design Criteria / Handling settings below --
|
||||
generic precast practice, not one job's data.
|
||||
* an ACI 318-19 / ASCE 7-22 section number -- code text, not a guess.
|
||||
* "plant knowledge <dotted.path>" -- a real value read off the merged
|
||||
knowledge layers (`know`), which always outranks the generic fallback
|
||||
for the handful of fields a plant is expected to pin (the handling
|
||||
stage multipliers and strength, the sling angle, the strand rail
|
||||
datum, `top_in_form`, `stress_ignore_lateral_ecc`, the engineer's own
|
||||
initials). Nothing here reads the Revit API or a live document --
|
||||
`know` is just the already-merged dict the button hands in.
|
||||
|
||||
WHAT NEVER GETS A DEFAULT. `ps_loss_pct` (the method computes it),
|
||||
`ext_soil_loads` / `int_soil_loads` (loading), every raw `seismic_cs` /
|
||||
`seismic_zone_location` field (loading -- the owner ruled on this
|
||||
explicitly, 2026-09-02: "风的计算还没有你就留着空着,让用户输入"), and
|
||||
`engineer_initials` when no plant value names one -- a person's identity is
|
||||
not a default, the same rule CLAUDE.md states for client and job names.
|
||||
`conc_loads` (the old free-text field) is gone from the form entirely --
|
||||
concentrated loads are a row table now (`panelanalysisform.CONC_LOAD_TABLE`),
|
||||
never defaulted, same rule. ASCE wind (`wind_speed_vult`, `wind_risk_
|
||||
category`, `wind_exposure`, `wind_enclosure`, `wind_asce_version`,
|
||||
`wind_cc_chapter`, `wind_mwfrs_chapter`, `wind_mean_roof_height_ft`,
|
||||
`wind_effective_wind_area_ft2`, `wind_pressure_psf`, `wind_suction_psf`) is
|
||||
gone from this module too -- 2026-09-04 owner ruling moved ASCE site data
|
||||
out of this form into project config entirely (see
|
||||
`panelanalysisform`'s module docstring, Wind section); this module never
|
||||
defaulted the two real-loading fields among them anyway, and now has
|
||||
nothing left to default for the rest either.
|
||||
|
||||
NEVER RAISES. `defaults_for` / `apply_defaults` / `why_of` / `known_keys` /
|
||||
`unknown_keys` all degrade to an empty result rather than an exception --
|
||||
a malformed `values` (`None`, not a dict), a malformed `know` ('garbage',
|
||||
not a dict), or a sibling module mid-edit (an import failure in
|
||||
`panelanalysisform` / `pa_criteria` / `pa_structural`, all still being
|
||||
written the same day this file was) each narrow what gets filled instead of
|
||||
stopping the whole pass. `known_keys()` follows the same "could not judge
|
||||
narrows, never guesses" discipline CLAUDE.md's `knowledge.same_face()`
|
||||
example sets: a module that fails to import contributes no keys, not a
|
||||
guessed set.
|
||||
|
||||
`known_keys()` / `unknown_keys()` READ THE LIVE FORM, not a copy of its key
|
||||
list. `pa_structural.page()` has no flat field table the way
|
||||
`panelanalysisform.FIELDS` and `pa_criteria._F` do -- its field keys are
|
||||
scattered through one long `page()` function as inline `_field(...)` calls.
|
||||
Rather than hand-copy that list here (and drift the day someone edits it),
|
||||
`_pa_structural_blocks()` calls `pa_structural.page({}, {})` -- documented as
|
||||
never raising -- and walks the returned block tree for every `kind: 'field'`
|
||||
entry, the same shape the WPF renderer itself would see. That also recovers
|
||||
each field's `choices`, feeding the choice-membership check below.
|
||||
|
||||
CHOICE VOCABULARY. Where a key has a `choices` list anywhere in the form
|
||||
(`panelanalysisform.FIELD_CHOICES`, `pa_criteria._F`'s own choices column,
|
||||
`pa_structural.page()`'s inline `choices=[...]`, or the Handling stage
|
||||
matrix's per-column `choices` hint in `STAGE_OPTIONS`), the default picked
|
||||
below is one of those choices -- verified by
|
||||
`tests/test_pa_defaults.py`, not just asserted here. Two of the owner's
|
||||
literal example values did not survive that check against the real editors
|
||||
and were changed; see the module-level `_DECISIONS` note near the bottom of
|
||||
this file for both.
|
||||
|
||||
IRONPYTHON 2.7: no f-strings, `'{0}'.format(...)` throughout, this coding
|
||||
cookie, no `pathlib` / `dataclasses` / typing syntax. Imports nothing but the
|
||||
standard library at module scope; every `vibe.*` import is inside a function,
|
||||
wrapped in `try/except Exception`, exactly so a sibling module mid-edit can
|
||||
never take this one down.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------- key sources
|
||||
|
||||
def _panelanalysisform():
|
||||
try:
|
||||
from vibe import panelanalysisform as paf
|
||||
return paf
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pa_criteria():
|
||||
try:
|
||||
from vibe import pa_criteria as pc
|
||||
return pc
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pa_structural_blocks():
|
||||
"""[(key, editor, choices), ...] read off a live `pa_structural.page()`
|
||||
call -- see the module docstring's "READ THE LIVE FORM" note for why
|
||||
this walks the block tree instead of keeping a second copy of the field
|
||||
list. Never raises: an import failure or a `page()` that cannot run
|
||||
(both plausible while a sibling agent edits either file) returns [].
|
||||
"""
|
||||
try:
|
||||
from vibe import pa_structural as ps
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
blocks, _problems = ps.page({}, {})
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
|
||||
def walk(items):
|
||||
for b in items or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
kind = b.get('kind')
|
||||
if kind == 'field' and b.get('key'):
|
||||
out.append((b.get('key'), b.get('editor'), b.get('choices')))
|
||||
elif kind == 'columns':
|
||||
for col in (b.get('columns') or []):
|
||||
walk(col)
|
||||
elif kind == 'section':
|
||||
# A collapsed section (seismic / temperature / soil since
|
||||
# 2026-09-04) carries its fields in `blocks`; they are still
|
||||
# the form's keys.
|
||||
walk(b.get('blocks'))
|
||||
|
||||
walk(blocks)
|
||||
return out
|
||||
|
||||
|
||||
def known_keys():
|
||||
"""Every key the Panel Analysis form is known to carry today, gathered
|
||||
defensively from every section module this file knows how to ask:
|
||||
`panelanalysisform.FIELDS`, the Handling stage matrix
|
||||
(`stage_field_keys()`), `pa_criteria._F`, and a live read of
|
||||
`pa_structural.page()`'s field blocks. A module that fails to import
|
||||
only narrows this set -- see the module docstring.
|
||||
"""
|
||||
keys = set()
|
||||
paf = _panelanalysisform()
|
||||
if paf is not None:
|
||||
try:
|
||||
keys.update(f['key'] for f in paf.FIELDS)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
keys.update(paf.stage_field_keys())
|
||||
except Exception:
|
||||
pass
|
||||
pc = _pa_criteria()
|
||||
if pc is not None:
|
||||
try:
|
||||
keys.update(pc._F.keys())
|
||||
except Exception:
|
||||
pass
|
||||
for key, _editor, _choices in _pa_structural_blocks():
|
||||
keys.add(key)
|
||||
return keys
|
||||
|
||||
|
||||
def unknown_keys():
|
||||
"""The keys this module WOULD default that `known_keys()` does not (yet)
|
||||
recognise -- empty once the field-model agents have caught up. Never
|
||||
raises; a lookup failure inside `known_keys()` already degrades to a
|
||||
smaller set, so this can only ever over-report, never under-report, a
|
||||
drift.
|
||||
"""
|
||||
known = known_keys()
|
||||
out = []
|
||||
for key in _all_default_keys():
|
||||
if key not in known and key not in out:
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------- choice tables
|
||||
|
||||
def _choice_registry():
|
||||
"""key -> tuple(choices), unioned across every source that names one.
|
||||
|
||||
A key with no entry here is free text (or a key nothing declares a
|
||||
choice list for yet) -- `choices_for` returns None for it, and the
|
||||
default picked for it is not checked against anything.
|
||||
"""
|
||||
out = {}
|
||||
|
||||
def add(key, choices):
|
||||
if not choices:
|
||||
return
|
||||
cur = out.get(key, ())
|
||||
merged = tuple(cur) + tuple(c for c in choices if c not in cur)
|
||||
out[key] = merged
|
||||
|
||||
paf = _panelanalysisform()
|
||||
if paf is not None:
|
||||
try:
|
||||
for key, choices in paf.FIELD_CHOICES.items():
|
||||
add(key, choices)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for stage in paf.STAGES:
|
||||
for opt_key, _label, _sym, _help, _kind, _drives, choices \
|
||||
in paf.STAGE_OPTIONS:
|
||||
add(paf.stage_field_key(stage, opt_key), choices)
|
||||
except Exception:
|
||||
pass
|
||||
pc = _pa_criteria()
|
||||
if pc is not None:
|
||||
try:
|
||||
for key, spec in pc._F.items():
|
||||
choices = spec[5] if len(spec) > 5 else None
|
||||
add(key, choices)
|
||||
except Exception:
|
||||
pass
|
||||
for key, _editor, choices in _pa_structural_blocks():
|
||||
add(key, choices)
|
||||
return out
|
||||
|
||||
|
||||
def choices_for(key):
|
||||
"""The unioned choice vocabulary for `key`, or None for free text."""
|
||||
return _choice_registry().get(key) or None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _is_blank(value):
|
||||
if value is None:
|
||||
return True
|
||||
try:
|
||||
return value.strip() == ''
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def _num(text):
|
||||
"""A best-effort float from whatever sits in `values`, or None."""
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_num(value):
|
||||
"""A number as the shortest text the form shows -- '2' not '2.0'."""
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, bool):
|
||||
return 'Yes' if value else 'No'
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if f == int(f):
|
||||
return str(int(f))
|
||||
text = ('%.10f' % f).rstrip('0').rstrip('.')
|
||||
return text
|
||||
|
||||
|
||||
def _today_iso():
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
def _as_dict(value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------- static defaults
|
||||
#
|
||||
# key -> (value_text, why). Fixed values -- no plant lookup, no computation
|
||||
# from another field -- for every Design Criteria / Structural Model /
|
||||
# Handling field the owner named that does not depend on plant knowledge or
|
||||
# on another field's value. See `_DYNAMIC_DEFAULTS` and `_stage_dynamic_*`
|
||||
# below for the ones that do.
|
||||
_ERIKSSON = 'Eriksson Wall default'
|
||||
|
||||
|
||||
def _e(page):
|
||||
return '{0} ({1} page)'.format(_ERIKSSON, page)
|
||||
|
||||
|
||||
_STATIC_DEFAULTS = (
|
||||
('design_spec', 'ACI 318-19',
|
||||
'current governing code edition (ASCE 7-22 companion); the sample '
|
||||
'Eriksson runs printed ACI 318-14 because they were older files'),
|
||||
|
||||
# ---------------------------------------------------- Design Criteria
|
||||
('design_criteria.deflection_add_bow', 'No', _e('Settings')),
|
||||
('design_criteria.deflection_additional_bow_in', '0', _e('Settings')),
|
||||
('design_criteria.deflection_check_cantilever', 'No', _e('Settings')),
|
||||
('design_criteria.deflection_cantilever_limit', 'L/75', _e('Settings')),
|
||||
('design_criteria.analysis_beta_d', 'Non-Sway',
|
||||
"matches the field's own choice vocabulary (Non-Sway / Sway) -- "
|
||||
"Non-Sway is " + _e('Settings')),
|
||||
('design_criteria.cracking_method', 'Mast Method', _e('Settings')),
|
||||
('design_criteria.cracking_limit_sqrt_fc', '7.5', _e('Settings')),
|
||||
('design_criteria.rvalue_calc_enabled', 'No', _e('Settings')),
|
||||
('design_criteria.min_reinf_checked', 'No', _e('Settings')),
|
||||
('design_criteria.stress_failure_if_cracked', 'No', _e('Settings')),
|
||||
('design_criteria.stress_use_principal_axis', 'No', _e('Settings')),
|
||||
('design_criteria.inplace_modulus_rupture_sqrt_fc', '12',
|
||||
u"Eriksson's own \"transition\" tension limit, 12 x sqrt(f'c)"),
|
||||
('design_criteria.flexure_method', 'Whitney Stress Block',
|
||||
_e('Settings')),
|
||||
('design_criteria.flexure_ignored_end_distance_in', '0',
|
||||
_e('Settings')),
|
||||
('design_criteria.flexure_cracking_check', 'None', _e('Settings')),
|
||||
('design_criteria.phi_compression', '0.65', "ACI 318-19 Sec. 21.2"),
|
||||
('design_criteria.phi_tension_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
|
||||
('design_criteria.phi_shear_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
|
||||
('design_criteria.dev_mult_debonded', '2', _e('Settings')),
|
||||
('design_criteria.dev_mult_cut', '1.3', _e('Settings')),
|
||||
('design_criteria.dev_mult_mild', '1.3', _e('Settings')),
|
||||
('dev_mult', '1.3', _e('Settings') + ' -- strand, normal'),
|
||||
('design_criteria.shear_treat_as_column', 'No', _e('Settings')),
|
||||
('design_criteria.axial_ignore_tensile_failures', 'No', _e('Settings')),
|
||||
('design_criteria.axial_ignore_buckling_failures', 'No', _e('Settings')),
|
||||
('design_criteria.min_earth_factor_strength', '0', _e('Settings')),
|
||||
('design_criteria.min_earth_factor_service', '0', _e('Settings')),
|
||||
('second_order', 'Yes', _e('Settings') + ' -- P-Delta'),
|
||||
('deflection_limit', 'L/150', _e('Settings')),
|
||||
|
||||
# ------------------------------------------------------- Load Options
|
||||
('self_weight_factor', '1', _e('Settings')),
|
||||
('load_opt_selfweight_ecc_in', '0', _e('Settings')),
|
||||
('load_opt_offset_from', 'Deflection Section',
|
||||
'Eriksson calls this the Loading Datum; the sample runs measure '
|
||||
'point-load offsets from the Deflection Section'),
|
||||
('load_opt_pattern_live', 'No',
|
||||
_e('Settings') + ' -- Pattern Live Loads for Flexure is off in the '
|
||||
'sample runs'),
|
||||
|
||||
# -------------------------------------------------------- Temperature
|
||||
('temp_ext_f', '75', _e('Settings')),
|
||||
('temp_int_f', '75', _e('Settings')),
|
||||
('temp_elasticity_mult', '0.5', _e('Settings')),
|
||||
('temp_alpha_t', '5.5',
|
||||
u'5.5 × 10⁻⁶ /°F (ACI 209 / PCI), entered as the '
|
||||
u'×10⁻⁶ mantissa the field asks for'),
|
||||
|
||||
# Wind is GONE from this module -- 2026-09-04 owner ruling moved every
|
||||
# ASCE site-data field (Vult, risk/exposure/enclosure, code version,
|
||||
# C&C/MWFRS chapter, mean roof height, effective wind area) out of this
|
||||
# form into project config; see `panelanalysisform`'s module docstring.
|
||||
# `wind_mode` (the one wind field left) defaults on the form side, in
|
||||
# `panelanalysisform.prefill` itself, not here.
|
||||
|
||||
# ---------------------------------------------------------- Restraints
|
||||
|
||||
# ------------------------------------------------------------ Seismic
|
||||
# 'seismic_apply_to' picks WHAT the (still-blank, user-typed) seismic
|
||||
# coefficient multiplies -- not a magnitude itself, so it is not the
|
||||
# loading the owner ruled must stay blank. Its editor carries an
|
||||
# explicit 'None' choice now, matched here rather than left blank.
|
||||
('seismic_apply_to', 'None',
|
||||
"matches the field's own choice vocabulary (None / Out-of-plane / "
|
||||
'In-plane / Both); ' + _e('Structural Model') + ' -- no seismic '
|
||||
'multiplier applied in the sample runs'),
|
||||
|
||||
# ------------------------------------------------- Prestressing Strands
|
||||
('strand_rail_offset_in', '0', _e('Reinforcement')),
|
||||
|
||||
# -------------------------------------------------------------- Extents
|
||||
('left_miter', 'No', _e('Concrete Extents')),
|
||||
('right_miter', 'No', _e('Concrete Extents')),
|
||||
)
|
||||
|
||||
|
||||
def _all_static_keys():
|
||||
return [key for key, _v, _w in _STATIC_DEFAULTS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------- dynamic values
|
||||
#
|
||||
# key -> function(values, know) -> (value_text, why) | None. None means
|
||||
# "this module cannot defend a default here right now" -- the key stays
|
||||
# blank, never a fabricated fallback. See the module docstring for the
|
||||
# plant-knowledge paths each of these reads.
|
||||
|
||||
def _dyn_project_date(values, know):
|
||||
return _today_iso(), "today's date, the same convention design_date uses"
|
||||
|
||||
|
||||
def _dyn_engineer_initials(values, know):
|
||||
pa = _as_dict(_as_dict(know).get('panelanalysis'))
|
||||
value = pa.get('engineer_initials')
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
return text, 'plant knowledge panelanalysis.engineer_initials'
|
||||
|
||||
|
||||
def _dyn_stress_ignore_lateral_ecc(values, know):
|
||||
pa = _as_dict(_as_dict(know).get('panelanalysis'))
|
||||
settings = _as_dict(pa.get('settings'))
|
||||
value = settings.get('stress_ignore_lateral_ecc')
|
||||
if value is not None:
|
||||
text = str(value).strip().lower()
|
||||
yn = 'No' if text in ('', 'no', 'none', 'false', '0', 'off') \
|
||||
else 'Yes'
|
||||
return yn, ('plant knowledge '
|
||||
'panelanalysis.settings.stress_ignore_lateral_ecc')
|
||||
return 'No', _e('Settings')
|
||||
|
||||
|
||||
def _dyn_top_in_form(values, know):
|
||||
pa = _as_dict(_as_dict(know).get('panelanalysis'))
|
||||
value = pa.get('top_in_form')
|
||||
if value is not None:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
for choice in ('Interior', 'Exterior'):
|
||||
if text.lower() == choice.lower():
|
||||
text = choice
|
||||
break
|
||||
return text, 'plant knowledge panelanalysis.top_in_form'
|
||||
return 'Interior', _e('Concrete Extents')
|
||||
|
||||
|
||||
_RAIL_LABELS = {
|
||||
'left_rail': 'Left edge',
|
||||
'right_rail': 'Right edge',
|
||||
'centerline': 'Centerline',
|
||||
}
|
||||
|
||||
|
||||
def _dyn_strand_rail_location(values, know):
|
||||
rails = _as_dict(_as_dict(know).get('rails'))
|
||||
datum = rails.get('strand_datum')
|
||||
if datum is not None:
|
||||
label = _RAIL_LABELS.get(str(datum).strip().lower())
|
||||
if label:
|
||||
return label, 'plant knowledge rails.strand_datum'
|
||||
return 'Left edge', _e('Reinforcement')
|
||||
|
||||
|
||||
_DYNAMIC_DEFAULTS = (
|
||||
('project_date', _dyn_project_date),
|
||||
('engineer_initials', _dyn_engineer_initials),
|
||||
('design_criteria.stress_ignore_lateral_ecc',
|
||||
_dyn_stress_ignore_lateral_ecc),
|
||||
('top_in_form', _dyn_top_in_form),
|
||||
('strand_rail_location', _dyn_strand_rail_location),
|
||||
# `wind_effective_wind_area_ft2` / `wind_mean_roof_height_ft` are GONE --
|
||||
# see the module docstring's Wind note; both fields left the form.
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------- handling stages
|
||||
#
|
||||
# `panelanalysisform.stage_field_key(stage, opt)` builds
|
||||
# 'stage.<slug>.<opt>' from the stage NAME, not the slug -- but this module
|
||||
# hardcodes the slugs too (matching `_stage_slug`'s
|
||||
# `name.lower().replace(' ', '_')` exactly) rather than importing the form
|
||||
# at module scope, so a sibling agent mid-edit on `panelanalysisform.py`
|
||||
# cannot take the whole default table down with it. `known_keys()` still
|
||||
# checks these against the live `stage_field_keys()` output, so any drift
|
||||
# between the hardcoded slug and the real one shows up as an `unknown_keys()`
|
||||
# entry rather than silently mis-keying the form.
|
||||
_STAGES = ('Stripping', 'Transportation', 'Primary Erection',
|
||||
'Secondary Erection')
|
||||
_STAGE_SLUGS = {
|
||||
'Stripping': 'stripping',
|
||||
'Transportation': 'transportation',
|
||||
'Primary Erection': 'primary_erection',
|
||||
'Secondary Erection': 'secondary_erection',
|
||||
}
|
||||
_STAGE_PLANT_NAME = {
|
||||
'Stripping': 'stripping',
|
||||
'Transportation': 'transportation',
|
||||
'Primary Erection': 'erection',
|
||||
'Secondary Erection': None,
|
||||
}
|
||||
_STAGE_FALLBACK_MULTIPLIER = {
|
||||
'Stripping': 1.4,
|
||||
'Transportation': 1.5,
|
||||
'Primary Erection': 1.4,
|
||||
}
|
||||
_STAGE_FALLBACK_STRENGTH_LABEL = {
|
||||
'Stripping': 'Initial',
|
||||
'Transportation': 'In-Place',
|
||||
'Primary Erection': 'In-Place',
|
||||
}
|
||||
_STAGE_FALLBACK_MAX_TILT = {
|
||||
'Stripping': 0,
|
||||
'Transportation': 0,
|
||||
'Primary Erection': 90,
|
||||
}
|
||||
_STRENGTH_LABELS = {'fci': 'Initial', 'fc': 'In-Place'}
|
||||
|
||||
|
||||
def _stage_key(stage, opt):
|
||||
return 'stage.{0}.{1}'.format(_STAGE_SLUGS[stage], opt)
|
||||
|
||||
|
||||
def _plant_stage(know, plant_name):
|
||||
if plant_name is None:
|
||||
return None
|
||||
handling = _as_dict(_as_dict(know).get('handling'))
|
||||
stages = handling.get('stages')
|
||||
if not isinstance(stages, list):
|
||||
return None
|
||||
for entry in stages:
|
||||
if isinstance(entry, dict) and entry.get('name') == plant_name:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _plant_sling_angle(know):
|
||||
handling = _as_dict(_as_dict(know).get('handling'))
|
||||
value = handling.get('sling_angle_deg')
|
||||
return value if value is not None else None
|
||||
|
||||
|
||||
# Static (non-plant-dependent) cells, one set per active stage -- generated
|
||||
# once into `_STATIC_DEFAULTS`-shaped tuples so they go through the exact
|
||||
# same blank-check / why_of machinery as everything else. Only
|
||||
# `load_multiplier`, `concrete_strength` and `sling_angle` are plant-aware
|
||||
# (see `_stage_dynamic_defaults` below); everything else about a stage is
|
||||
# the same Eriksson-sample answer regardless of plant.
|
||||
def _stage_static_defaults():
|
||||
out = []
|
||||
for stage in _STAGES:
|
||||
if stage == 'Secondary Erection':
|
||||
out.append((
|
||||
_stage_key(stage, 'include'), 'No',
|
||||
_e('Handling') + ' -- secondary erection is not run for '
|
||||
'this panel unless entered'))
|
||||
continue
|
||||
out.append((_stage_key(stage, 'include'), 'Yes', _e('Handling')))
|
||||
out.append((_stage_key(stage, 'lift_type'), 'Static Lift',
|
||||
_e('Handling')))
|
||||
stress_limits = 'Handling' if stage == 'Stripping' else 'In-Place'
|
||||
out.append((_stage_key(stage, 'stress_limits'), stress_limits,
|
||||
_e('Handling')))
|
||||
out.append((_stage_key(stage, 'rigging'), 'Auto', _e('Handling')))
|
||||
out.append((_stage_key(stage, 'ground_reaction'), 'No',
|
||||
_e('Handling')))
|
||||
out.append((_stage_key(stage, 'min_tilt_deg'), '0', _e('Handling')))
|
||||
out.append((_stage_key(stage, 'max_tilt_deg'),
|
||||
_fmt_num(_STAGE_FALLBACK_MAX_TILT[stage]),
|
||||
_e('Handling')))
|
||||
out.append((_stage_key(stage, 'top_crane_carries'), 'No',
|
||||
_e('Handling')))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
_STAGE_STATIC_DEFAULTS = _stage_static_defaults()
|
||||
|
||||
|
||||
def _stage_dynamic_defaults(values, know):
|
||||
out = []
|
||||
sling = _plant_sling_angle(know)
|
||||
if sling is not None:
|
||||
sling_text = _fmt_num(sling)
|
||||
sling_why = 'plant knowledge handling.sling_angle_deg'
|
||||
else:
|
||||
sling_text = '60'
|
||||
sling_why = _e('Handling')
|
||||
for stage in _STAGES:
|
||||
if stage == 'Secondary Erection':
|
||||
continue
|
||||
plant_name = _STAGE_PLANT_NAME.get(stage)
|
||||
pstage = _plant_stage(know, plant_name)
|
||||
|
||||
multiplier = None
|
||||
mult_why = _e('Handling')
|
||||
if pstage is not None and pstage.get('multiplier') is not None:
|
||||
multiplier = pstage.get('multiplier')
|
||||
mult_why = 'plant knowledge handling.stages'
|
||||
if multiplier is None:
|
||||
multiplier = _STAGE_FALLBACK_MULTIPLIER[stage]
|
||||
out.append((_stage_key(stage, 'load_multiplier'),
|
||||
_fmt_num(multiplier), mult_why))
|
||||
|
||||
strength_label = _STAGE_FALLBACK_STRENGTH_LABEL[stage]
|
||||
strength_why = _e('Handling')
|
||||
if pstage is not None and pstage.get('strength') in \
|
||||
_STRENGTH_LABELS:
|
||||
strength_label = _STRENGTH_LABELS[pstage.get('strength')]
|
||||
strength_why = 'plant knowledge handling.stages'
|
||||
out.append((_stage_key(stage, 'concrete_strength'), strength_label,
|
||||
strength_why))
|
||||
|
||||
out.append((_stage_key(stage, 'sling_angle'), sling_text,
|
||||
sling_why))
|
||||
return out
|
||||
|
||||
|
||||
def _stage_dynamic_keys():
|
||||
keys = []
|
||||
for stage in _STAGES:
|
||||
if stage == 'Secondary Erection':
|
||||
continue
|
||||
keys.append(_stage_key(stage, 'load_multiplier'))
|
||||
keys.append(_stage_key(stage, 'concrete_strength'))
|
||||
keys.append(_stage_key(stage, 'sling_angle'))
|
||||
return keys
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the WHY map
|
||||
|
||||
_WHY = {}
|
||||
for _key, _value, _why in _STATIC_DEFAULTS:
|
||||
_WHY[_key] = _why
|
||||
for _key, _value, _why in _STAGE_STATIC_DEFAULTS:
|
||||
_WHY[_key] = _why
|
||||
del _key, _value, _why
|
||||
|
||||
_WHY['project_date'] = ("today's date, the same convention design_date "
|
||||
'uses')
|
||||
_WHY['engineer_initials'] = (
|
||||
'plant knowledge panelanalysis.engineer_initials, when the plant names '
|
||||
'one -- a person\'s identity is never guessed, so this key is left '
|
||||
'blank without a plant value')
|
||||
_WHY['design_criteria.stress_ignore_lateral_ecc'] = (
|
||||
'plant knowledge panelanalysis.settings.stress_ignore_lateral_ecc when '
|
||||
'set, else ' + _e('Settings'))
|
||||
_WHY['top_in_form'] = ('plant knowledge panelanalysis.top_in_form when '
|
||||
'set, else ' + _e('Concrete Extents'))
|
||||
_WHY['strand_rail_location'] = ('plant knowledge rails.strand_datum when '
|
||||
'set, else ' + _e('Reinforcement'))
|
||||
for _stage in _STAGES:
|
||||
if _stage == 'Secondary Erection':
|
||||
continue
|
||||
_WHY[_stage_key(_stage, 'load_multiplier')] = (
|
||||
'plant knowledge handling.stages when a matching stage names a '
|
||||
'multiplier, else ' + _e('Handling'))
|
||||
_WHY[_stage_key(_stage, 'concrete_strength')] = (
|
||||
"plant knowledge handling.stages' strength field (fci -> Initial, "
|
||||
'fc -> In-Place) when present, else ' + _e('Handling'))
|
||||
_WHY[_stage_key(_stage, 'sling_angle')] = (
|
||||
'plant knowledge handling.sling_angle_deg when set, else ' +
|
||||
_e('Handling'))
|
||||
del _stage
|
||||
|
||||
|
||||
def why_of(key):
|
||||
"""The source sentence for `key`'s default, or None if this module does
|
||||
not know how to default that key. Independent of any particular
|
||||
`values` / `know` -- see the module docstring for why: for a
|
||||
plant-aware key this names the RULE ("plant knowledge X when set, else
|
||||
Y"), not necessarily which branch a specific call took.
|
||||
"""
|
||||
try:
|
||||
return _WHY.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _all_default_keys():
|
||||
keys = list(_all_static_keys())
|
||||
keys.extend(key for key, _v, _w in _STAGE_STATIC_DEFAULTS)
|
||||
keys.extend(key for key, _fn in _DYNAMIC_DEFAULTS)
|
||||
keys.extend(_stage_dynamic_keys())
|
||||
return keys
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the API
|
||||
|
||||
def _compute(values, know):
|
||||
out = []
|
||||
seen = set()
|
||||
|
||||
def emit(key, value_text, why):
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
if not _is_blank(values.get(key)):
|
||||
return
|
||||
out.append((key, value_text, why))
|
||||
|
||||
for key, value_text, why in _STATIC_DEFAULTS:
|
||||
emit(key, value_text, why)
|
||||
for key, value_text, why in _STAGE_STATIC_DEFAULTS:
|
||||
emit(key, value_text, why)
|
||||
|
||||
for key, fn in _DYNAMIC_DEFAULTS:
|
||||
try:
|
||||
result = fn(values, know)
|
||||
except Exception:
|
||||
result = None
|
||||
if result is None:
|
||||
continue
|
||||
value_text, why = result
|
||||
emit(key, value_text, why)
|
||||
|
||||
try:
|
||||
stage_dyn = _stage_dynamic_defaults(values, know)
|
||||
except Exception:
|
||||
stage_dyn = []
|
||||
for key, value_text, why in stage_dyn:
|
||||
emit(key, value_text, why)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def defaults_for(values, know=None):
|
||||
"""[(key, value_text, why), ...] for every key whose current value in
|
||||
`values` is blank ('' / None / missing) and that this module knows a
|
||||
default for. `values` and `know` are read only, never mutated. Never
|
||||
raises -- a `values` that is not a dict, or a `know` that is not a
|
||||
dict, both degrade to []/an empty knowledge tree rather than an error.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return []
|
||||
try:
|
||||
return _compute(values, know if isinstance(know, dict) else {})
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def apply_defaults(values, know=None):
|
||||
"""Fills the blanks `defaults_for` finds, IN PLACE on `values`, and
|
||||
returns the same [(key, value_text, why), ...] it applied. A value
|
||||
that is no longer blank by the time this writes it (nothing here
|
||||
should cause that, but the check costs nothing) is left untouched --
|
||||
this never overwrites a real value. Never raises: `values` that is not
|
||||
a (mutable) dict returns [] and nothing is written.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return []
|
||||
try:
|
||||
found = defaults_for(values, know)
|
||||
except Exception:
|
||||
return []
|
||||
applied = []
|
||||
for key, value_text, why in found:
|
||||
try:
|
||||
if _is_blank(values.get(key)):
|
||||
values[key] = value_text
|
||||
applied.append((key, value_text, why))
|
||||
except Exception:
|
||||
continue
|
||||
return applied
|
||||
|
||||
|
||||
# --------------------------------------------------------------- _DECISIONS
|
||||
#
|
||||
# `design_criteria.analysis_beta_d` and `load_opt_offset_from` were checked
|
||||
# against `pa_criteria`'s and `pa_structural`'s real editors while this file
|
||||
# was being written and, at that point, neither editor's choice list
|
||||
# actually contained the owner's own example value (a numeric-only editor
|
||||
# for the first, `['Panel CG', 'Other']` for the second). Both editors were
|
||||
# given real choice lists by another agent, concurrently, before this file's
|
||||
# tests were finalised -- `design_criteria.analysis_beta_d` now offers
|
||||
# `('Non-Sway', 'Sway')` and `load_opt_offset_from` now offers
|
||||
# `('Deflection Section', 'Top in Form', 'Bottom in Form')` -- and both
|
||||
# match the owner's original values exactly, so both are encoded as he
|
||||
# specified. Left as a note because `choices_for()` reads the LIVE editors
|
||||
# on every call: if either drifts again, `tests/test_pa_defaults.py::
|
||||
# test_every_default_with_choices_is_a_valid_choice` fails and names the
|
||||
# mismatch rather than this comment going stale silently.
|
||||
#
|
||||
# dev_mult / second_order Both already carry a DIFFERENT generic default
|
||||
# in `panelanalysisform.PREFILL_DEFAULTS`
|
||||
# ('1' and 'No', committed 2026-09-03) that runs
|
||||
# unconditionally in `prefill()`, before this
|
||||
# module's `apply_defaults()` would ever see the
|
||||
# key -- so under the current call order neither
|
||||
# of this module's defaults (1.3, 'Yes') can ever
|
||||
# actually fire; `values[key]` is already
|
||||
# non-blank by the time `apply_defaults` looks at
|
||||
# it. Encoded as the owner specified anyway
|
||||
# (2026-09-04 postdates the 2026-09-03 seed), but
|
||||
# this is a live policy conflict between the two
|
||||
# tables, not a bug in either -- flagged for
|
||||
# whoever wires `apply_defaults()` into `prefill()`
|
||||
# to resolve by call order or by retiring the
|
||||
# older seed.
|
||||