Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something ran and gave a wrong or crashing answer
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
Before filing: is this a WRONG answer, or a REFUSAL to answer?
|
||||
|
||||
WillTools refuses rather than guesses, by design -- a "NOT JUDGED" result
|
||||
with a stated reason is often the tool working CORRECTLY (an unmapped
|
||||
parameter, a missing knowledge layer, a check that legitimately has nothing
|
||||
to say about this piece). If the tool told you clearly why it would not
|
||||
answer, that is very likely not this template -- it's the tool doing what it
|
||||
is supposed to. File that as a "refused, and I think it shouldn't have"
|
||||
report instead (see the Question template), because that is knowledge-layer
|
||||
work, not a code fix.
|
||||
|
||||
This template is for: it crashed, it gave two different answers to the same
|
||||
input, or it silently produced a wrong number with no stated reason.
|
||||
-->
|
||||
|
||||
## What happened
|
||||
|
||||
<!-- Paste the exact error, verbatim, in a code block. Do not summarize it or
|
||||
retype it from memory -- the exact text (including any traceback) is what
|
||||
gets this fixed fast. -->
|
||||
|
||||
```
|
||||
paste it here
|
||||
```
|
||||
|
||||
## What you expected instead
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Environment
|
||||
|
||||
Run this and paste its ENTIRE output below, unedited -- it is the single
|
||||
most diagnostic thing you can attach. It is plain text with no color codes,
|
||||
so it pastes cleanly into this box:
|
||||
|
||||
```
|
||||
python tools/setup_machine.py --check
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>setup_machine.py --check output</summary>
|
||||
|
||||
```
|
||||
paste it here
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- Windows version:
|
||||
- `python --version`:
|
||||
- Revit version (if this involves a ribbon button):
|
||||
- pyRevit version (if this involves a ribbon button):
|
||||
|
||||
## Which button or command
|
||||
|
||||
<!-- e.g. "Ticket QC" on the ribbon, or `python -m panellift ...` -->
|
||||
|
||||
## No client or job data, please
|
||||
|
||||
Screenshots, pasted output, and job files should carry no real client name,
|
||||
job name, or job number. If a screenshot or a job file shows one, use a
|
||||
placeholder (`sample_client`, `sample_job`) or ask the owner how to share it
|
||||
privately instead of pasting it here.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ask the owner directly
|
||||
url: https://github.com/will123song/pyrevit-dev/discussions
|
||||
about: Not sure it's a bug, or want to talk through knowledge layers first?
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Something was unclear, or you're not sure what a result means
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## What you were trying to do
|
||||
|
||||
## What the report or the ribbon said
|
||||
|
||||
<!-- Paste the relevant text, or attach a screenshot with no client/job
|
||||
names visible (see the note in the bug report template). -->
|
||||
|
||||
## What was unclear about it
|
||||
|
||||
<!-- Was it a refusal you think should have been an answer? A wording you
|
||||
could not parse? A row you did not expect to be blank? Say which. -->
|
||||
@@ -0,0 +1,39 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install
|
||||
# requirements.txt is the single source of truth for host-side deps.
|
||||
# PyYAML fixes collection (ticketpage._read_yaml falls back to it off
|
||||
# IronPython; without it eight test files die before running). pymupdf
|
||||
# is required for tools/ticket_delta.py's tests.
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: panellift tests
|
||||
run: python -m pytest panellift -q -p no:cacheprovider
|
||||
|
||||
- name: vibe tests (the Revit-free half of the extension)
|
||||
run: python -m pytest WillTools.extension -q -p no:cacheprovider
|
||||
|
||||
- name: Syntax smoke (extension + tools + panellift)
|
||||
run: python -m compileall -q WillTools.extension tools panellift/src
|
||||
|
||||
- name: Scrub worktree with the committed generic sample terms
|
||||
# Real client names never leave the owner's machine: this
|
||||
# distribution ships no .scrub-terms at all, so CI runs the generic
|
||||
# .scrub-terms.example instead -- the same fallback a colleague's own
|
||||
# clone uses locally with no real term list.
|
||||
run: python tools/scrub.py --worktree --terms .scrub-terms.example
|
||||
@@ -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,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,305 @@
|
||||
# Installing WillTools
|
||||
|
||||
Precast engineering tooling: eleven buttons across three ribbon panels — four
|
||||
finished tools on **Tools**, five set-up commands behind the **Config**
|
||||
pulldown, two unfinished ones quarantined behind **DesignHub**.
|
||||
`README.md` has the table of what each one does.
|
||||
|
||||
Developed and exercised on **Revit 2025.4 · pyRevit 6.5.3 · IronPython 2.7.12**.
|
||||
Everything else in this file is about what to check when one of those differs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| | Needed for | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Revit** 2021 or newer | everything | see [§6](#6-a-different-revit-version) before assuming a version works |
|
||||
| **pyRevit** 4.8 or newer | everything | <https://github.com/pyrevitlabs/pyRevit/releases> |
|
||||
| **Python 3.9+** on the host | `tools/*.py`, `panellift` | not needed to use the ribbon buttons themselves |
|
||||
|
||||
**No button that does engineering work needs the network or a local model
|
||||
server.** Design Extract reads a design packet in-process with a
|
||||
regex/geometry parser and never leaves Revit.
|
||||
|
||||
### The Windows Store Python trap
|
||||
|
||||
If `python` at a terminal prints something like *"Python was not found; run
|
||||
without arguments to install from the Microsoft Store"* and then exits
|
||||
non-zero, that is not really Python — it is a stub Windows ships in
|
||||
`WindowsApps` that only exists to point you at the Store. Every command in
|
||||
this file, and `tools/setup_machine.py` itself, will fail in confusing ways
|
||||
if that stub is what `python` resolves to.
|
||||
|
||||
Fix: install Python from <https://www.python.org/downloads/> (check "Add
|
||||
python.exe to PATH" during setup), then open a **new** terminal and confirm
|
||||
with `python --version` — it should print a real version number, not a
|
||||
Store prompt. If both a real install and the Store stub are on `PATH`, the
|
||||
Store one usually wins unless you reorder `PATH` or use the `py` launcher
|
||||
instead (`py -3 tools/setup_machine.py`).
|
||||
|
||||
## 2. Install
|
||||
|
||||
1. **Clone or unzip anywhere you can write to.** A checkout under your user
|
||||
folder is easiest. The path is not hardcoded anywhere; the buttons find
|
||||
the folder relative to themselves.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/will123song/pyrevit-dev.git
|
||||
cd pyrevit-dev
|
||||
```
|
||||
|
||||
2. **Run the setup script.** It installs the host-side Python dependencies
|
||||
(`requirements.txt`: PyYAML, pytest, pymupdf) and then *proves* the
|
||||
install rather than just reporting exit codes — it runs `panellift`
|
||||
against a published worked example and checks the computed stress
|
||||
against the number the article prints.
|
||||
|
||||
```bash
|
||||
python tools/setup_machine.py
|
||||
```
|
||||
|
||||
Read the output top to bottom. `PASS` and `SKIP` lines need nothing from
|
||||
you; a `SKIP` is a deliberate non-answer (this distribution ships no
|
||||
knowledge layers, so the checks that need one stand down cleanly — see
|
||||
`knowledge/README.md`), not a problem. A `FAIL` line names what broke and
|
||||
what to try — fix the first one and re-run; failures often cascade from
|
||||
one root cause. Re-run any time with `--check` to just re-verify without
|
||||
changing anything:
|
||||
|
||||
```bash
|
||||
python tools/setup_machine.py --check
|
||||
```
|
||||
|
||||
3. **Register the folder with pyRevit.** pyRevit tab → *Settings* →
|
||||
*Custom Extension Directories* → add the folder that **contains**
|
||||
`WillTools.extension` (the cloned folder itself, not the `.extension`
|
||||
folder inside it) → *Save Settings*.
|
||||
|
||||
Equivalent to this in `%APPDATA%\pyRevit\pyRevit_config.ini`:
|
||||
|
||||
```ini
|
||||
[core]
|
||||
userextensions = ["C:\\path\\to\\pyrevit-dev"]
|
||||
```
|
||||
|
||||
4. **Restart Revit.** Not "pyRevit → Reload" — see [§7](#7-the-one-rule).
|
||||
|
||||
A **WillTools** tab appears with three panels, left to right:
|
||||
|
||||
| Panel | |
|
||||
| --- | --- |
|
||||
| **Tools** | four large buttons: Reinforce, Panel Lift, Ticket QC, Snapshot. |
|
||||
| **Setup** | one button, **Config**, with a dropdown arrow: Project Config, Design Extract, Self Check, Job Recon, Takeoff. |
|
||||
| **Design** | one button, **DesignHub**, with a dropdown arrow. Everything behind it is unfinished and says so — its rows are marked `(WIP)`. |
|
||||
|
||||
## 3. Check the install
|
||||
|
||||
Open a model and press **Self Check**. It probes every assumption these tools
|
||||
make — Revit version, pyRevit version, Python engine, routes, library imports,
|
||||
unit conversion, whether the CLI is findable, where the data folder resolved
|
||||
to, whether a published checklist is available, and whether this model
|
||||
actually follows the naming conventions the checks depend on — and prints
|
||||
each as OK / WARN / FAIL.
|
||||
|
||||
Read the FAILs top-down; they cascade. A WARN never blocks a run, and on a
|
||||
knowledge-less install several rows are *expected* to read WARN — that is
|
||||
the "checks stand down" state, not a broken install.
|
||||
|
||||
## 4. Try it with no Revit at all
|
||||
|
||||
```bash
|
||||
python -m panellift examples/example-panel.job.json -o report.html
|
||||
```
|
||||
|
||||
That runs the PCI Chapter 8 handling check against a published worked
|
||||
example (the ASPIRE Winter 2019 article) and writes an HTML report. It needs
|
||||
no Revit, no model, and no knowledge layer — a good first thing to run right
|
||||
after `tools/setup_machine.py` to confirm the arithmetic reaches you intact.
|
||||
|
||||
## 5. Using it on a real job
|
||||
|
||||
Per model, in order:
|
||||
|
||||
1. **Extract Design** — pick the design summaries, one extraction terminal
|
||||
opens per design, each writes `specs/<DESIGN>.json`.
|
||||
2. **Read the spec.** It is JSON on purpose. Fix anything wrong and fill in
|
||||
`source.reviewed_by`. Nothing downstream questions the spec again.
|
||||
3. **Ticket QC** — checks every piece of those designs against the spec.
|
||||
**Needs a knowledge layer with the published checklist** — see below;
|
||||
without one it refuses to open with a one-line reason.
|
||||
4. Edit the `knowledge.yaml` files as you learn the job — see the layers
|
||||
below for which one. They are re-read on every run — **no restart needed
|
||||
for knowledge or spec edits.**
|
||||
|
||||
Everything lands in `_ticketqc/` **beside the model file**:
|
||||
|
||||
```
|
||||
<model folder>/_ticketqc/
|
||||
evidence/<DESIGN>.json raw scrape, no interpretation
|
||||
specs/<DESIGN>.json the reviewable spec <-- read this
|
||||
knowledge.yaml your hand-written rules
|
||||
log/<DESIGN>.txt what the extractor printed
|
||||
```
|
||||
|
||||
If the model is not saved, or its folder is read-only, it falls back to
|
||||
`%APPDATA%\pyrevit-ticketqc\<model title>\`. Self Check tells you which.
|
||||
|
||||
### The knowledge layers, and why this distribution has none of them
|
||||
|
||||
Knowledge is not one file. It is read in layers, each overriding the one
|
||||
before it key by key, and the split is by **how long the fact stays true**:
|
||||
|
||||
| layer | where | what belongs there |
|
||||
| --- | --- | --- |
|
||||
| defaults | `lib/vibe/knowledge.py` — in this repo | what the code assumes when nobody has said |
|
||||
| catalogue | `knowledge/catalogue.yaml` — **not shipped** | published hardware ratings, plus the master shop-ticket checklist |
|
||||
| **plant** | `%APPDATA%\WillTools\knowledge.yaml` — **not shipped** | naming conventions, casting-bed rails, **parameter names**, which catalogue item each part number is, rigging mode and dunnage position |
|
||||
| **job** | `<model folder>/_ticketqc/knowledge.yaml` — **not shipped** | tolerances, and anything where this job departs from the plant |
|
||||
| **project config** | wherever you put it — see below | the whole configuration, pinned to one job |
|
||||
|
||||
This distribution ships **none of the last four**, deliberately — see
|
||||
[`knowledge/README.md`](knowledge/README.md) and
|
||||
[`knowledge/catalogue.yaml.template`](knowledge/catalogue.yaml.template) for
|
||||
the shape of what a real layer looks like, with every value blanked out.
|
||||
Everything on **Tools** and **Setup** except Ticket QC runs without one,
|
||||
reading only `knowledge.DEFAULTS`; Self Check reports every plant-specific
|
||||
parameter role as unmapped until real layers are wired in. **Ask the owner**
|
||||
for real layers when you have a job to run for real — see the README's
|
||||
"What to ask the owner for."
|
||||
|
||||
Copying a job file forward to the next job should be nearly empty work — if
|
||||
you find yourself copying a lot, it belongs in the plant layer. Set
|
||||
`WILLTOOLS_KNOWLEDGE` to a full path to put the plant layer on a share so a
|
||||
whole office reads one file. (`tools/setup_machine.py` deliberately leaves
|
||||
this variable unset on a knowledge-less install — see its own comments if
|
||||
you are wiring one in by hand rather than through the script.)
|
||||
|
||||
Handling decisions are plant knowledge too: `handling.stages[].rig_mode`
|
||||
(`rolling` or `static`, blank inherits the global mode) and
|
||||
`handling.dunnage.mode` (`lifter_rows` or `fraction`, with `fraction` such as
|
||||
`0.2` for supports at 0.2L from each end). A plant that has not set dunnage
|
||||
still gets transportation as a lift, and the report labels it as approximate.
|
||||
|
||||
Self Check prints which layers it found, and lists any parameter role still
|
||||
unmapped.
|
||||
|
||||
### Pinning a configuration to a job
|
||||
|
||||
The **Project Config** button writes every layer above, merged, into one file
|
||||
wherever you choose — the job folder on the share, usually, next to the rest
|
||||
of the job's paperwork. From then on the model runs on that file, and it is
|
||||
the last word: edit it and the next run uses what you wrote.
|
||||
|
||||
A one-line pointer at `<model folder>/_ticketqc/active-config.txt` records
|
||||
where the file went. Press the button again to refresh the file from the
|
||||
layers underneath, or to drop back to them.
|
||||
|
||||
Two things to know before you use it:
|
||||
|
||||
- **It freezes the plant layer.** The merged copy in the file contains the
|
||||
plant's settings as values of its own, so while the file is live, fixing
|
||||
something in the plant file no longer reaches this job. That is the point of
|
||||
pinning, and it is also how a plant-wide correction quietly fails to arrive.
|
||||
- **Do not move or delete the file without clearing the pointer.** Every
|
||||
button refuses to run rather than falling back, because a run on the layered
|
||||
files when you meant the pinned ones looks entirely normal and is not the
|
||||
configuration you set.
|
||||
|
||||
A new model has no pointer, so it starts from the layered files and gets its
|
||||
own configuration the first time the button is pressed there.
|
||||
|
||||
### Nothing is hard-coded, including public facts
|
||||
|
||||
The obvious things stay out of the code: client names, job names, parameter
|
||||
names. Less obviously, **so do facts that are perfectly public.** A lifting
|
||||
insert's rated working load is printed in the manufacturer's catalogue and is
|
||||
no secret — but written into a `.py` file it becomes a number nobody can
|
||||
correct without a release, and it will be wrong eventually.
|
||||
|
||||
So a rating is a lookup, and it takes two hops, because a plant models its own
|
||||
part number rather than the manufacturer's designation:
|
||||
|
||||
```
|
||||
family name in the model
|
||||
-> lifters.catalogue_alias plant layer: P000 -> SAMPLE_ANCHOR_4T
|
||||
-> catalogue.inserts product data: SAMPLE_ANCHOR_4T -> 8000 lb
|
||||
```
|
||||
|
||||
Each hop fails differently and is fixed in a different place, so the check
|
||||
says which one is missing rather than reporting a bare "unknown". The same
|
||||
shape applies to anything else with a published number behind a local name.
|
||||
|
||||
## 6. A different Revit version
|
||||
|
||||
The code is written against the stable parts of the API and guards the two
|
||||
places Autodesk moved the floor:
|
||||
|
||||
- **`UnitTypeId`** (Revit 2021+) with a `DisplayUnitType` fallback for 2020 and
|
||||
older, resolved once at import so an old host cannot break the whole library.
|
||||
- **`ElementId.IntegerValue`** → **`.Value`** (Revit 2024+), read defensively
|
||||
at every site.
|
||||
|
||||
Also version-proofed: the `/exec` snippet namespace skips API types that a
|
||||
given release does not have, rather than failing to register.
|
||||
|
||||
**Not verified anywhere but 2025.** If you move to 2023 or 2026, press Self
|
||||
Check first — it reports the host version as a WARN and exercises the imports
|
||||
and a unit round-trip, which is where a version problem shows up first.
|
||||
|
||||
### A different Python engine
|
||||
|
||||
pyRevit chooses the interpreter. This extension is written in the intersection
|
||||
of Python 2 and 3 — no f-strings, no `print` statement, no implicit integer
|
||||
division, no `pathlib`, and `unicode`/`basestring` resolved in
|
||||
`lib/vibe/compat.py` — so IronPython 2.7, IronPython 3 and CPython 3 should all
|
||||
load it. Only IronPython 2.7 has actually been run in anger; Self Check warns
|
||||
on anything else.
|
||||
|
||||
## 7. The one rule
|
||||
|
||||
**Never use pyRevit → Reload. Restart Revit.**
|
||||
|
||||
pyRevit's routes handler is owned by the engine that first loaded it. A reload
|
||||
tears that engine down and leaves the handler pointing at the corpse; the next
|
||||
bridge request runs against a dead engine and takes Revit down with it —
|
||||
an unhandled exception off the main thread, which Revit cannot catch.
|
||||
|
||||
There is no reload endpoint, `rvt.py` refuses to run after it sees two loads in
|
||||
one Revit process, and a restart picks up new buttons just as well.
|
||||
|
||||
Restart Revit after: installing, adding or renaming a button, or adding an icon.
|
||||
No restart needed after: editing a `script.py`, a spec, or `knowledge.yaml`.
|
||||
|
||||
## 8. What is in this distribution
|
||||
|
||||
```
|
||||
pyrevit-dev/
|
||||
├── INSTALL.md this file
|
||||
├── README.md what the tool is, what needs knowledge
|
||||
├── CONTRIBUTING.md tests, the no-client-data rule, PRs
|
||||
├── examples/example-panel.job.json a published worked example, try this first
|
||||
├── docs/ reference material and design docs
|
||||
├── knowledge/README.md why this folder is otherwise empty
|
||||
├── knowledge/catalogue.yaml.template the shape of the layer that isn't here
|
||||
├── tools/setup_machine.py run this first
|
||||
├── tools/rvt.py bridge client (host CPython 3)
|
||||
├── panellift/ the handling-check engine, its own test suite
|
||||
└── WillTools.extension/
|
||||
├── startup.py registers the bridge routes
|
||||
├── lib/vibe/ helpers, compat, designspec, knowledge,
|
||||
│ ticketqc, ticketchecks
|
||||
└── WillTools.tab/ the ribbon, three panels left to right:
|
||||
├── Tools.panel/ Reinforce · Panel Lift · Ticket QC ·
|
||||
│ Snapshot — the four finished tools, all
|
||||
│ large buttons
|
||||
├── Setup.panel/ Config.pulldown/ — Project Config ·
|
||||
│ Design Extract · Self Check · Job Recon ·
|
||||
│ Takeoff
|
||||
└── Design.panel/ DesignHub.pulldown/ — Gravity (WIP) ·
|
||||
Lateral (WIP); nothing finished lives here
|
||||
```
|
||||
|
||||
No model data or knowledge ships with it. `_ticketqc/` folders stay with
|
||||
their models; knowledge layers come from the owner, on request — see the
|
||||
README's "What to ask the owner for."
|
||||
@@ -0,0 +1,163 @@
|
||||
# WillTools
|
||||
|
||||
Precast engineering tooling for Revit: shop-ticket QC against the design
|
||||
summary, and PCI Chapter 8 handling calculations. Built for one precast
|
||||
plant's workflow and shared here so a colleague can try it against their own.
|
||||
|
||||
Two halves, deliberately kept apart. `WillTools.extension` runs inside
|
||||
Revit, on the model. `panellift` is plain CPython outside Revit — the same
|
||||
handling arithmetic, testable in seconds with no Revit licence at all.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/will123song/pyrevit-dev.git
|
||||
cd pyrevit-dev
|
||||
python tools/setup_machine.py
|
||||
```
|
||||
|
||||
That installs the host-side Python dependencies and proves the install with
|
||||
real answers — not just exit codes: it runs `panellift` against a published
|
||||
worked example and checks the arithmetic against the number the article
|
||||
prints. Read [INSTALL.md](INSTALL.md) for what to do with the output, and
|
||||
for pointing pyRevit at the extension so the ribbon shows up in Revit.
|
||||
|
||||
Try the handling calculator immediately, no Revit needed:
|
||||
|
||||
```bash
|
||||
python -m panellift examples/example-panel.job.json -o report.html
|
||||
```
|
||||
|
||||
## What works without knowledge layers, and what stands down
|
||||
|
||||
This distribution ships **no knowledge** — not a plant's parameter names,
|
||||
not its casting-bed conventions, not even the published hardware catalogue.
|
||||
See [`knowledge/README.md`](knowledge/README.md) for why and what the shape
|
||||
of a real layer looks like.
|
||||
|
||||
Runs fully, with no knowledge at all:
|
||||
|
||||
- `panellift`'s handling checks against a job file — the example above, or
|
||||
one you build by hand.
|
||||
- The ribbon itself, and **Self Check**, which tells you exactly what is and
|
||||
is not configured on this machine.
|
||||
- `python tools/setup_machine.py --check` — reports the no-knowledge state
|
||||
as a **pass**, not a failure. A knowledge-less trial install is an
|
||||
expected, first-class state here, not a broken one.
|
||||
|
||||
Stands down cleanly, with a stated reason rather than a wrong or silent
|
||||
answer: anything that needs a plant's parameter mapping, the published
|
||||
hardware catalogue, or the master shop-ticket checklist — most visibly,
|
||||
**Ticket QC**, which refuses to open with a one-line reason rather than
|
||||
rendering a report that looks like every ticket passed. Ask the owner for
|
||||
real knowledge layers once you are ready to point this at an actual job.
|
||||
|
||||
## `WillTools.extension` — inside Revit
|
||||
|
||||
A pyRevit extension. Runs on IronPython 2.7 on Revit's UI thread, so it does
|
||||
the things that need the model and nothing that needs testing.
|
||||
|
||||
One tab, **WillTools**, four panels. The four tools that are finished are the
|
||||
four large buttons on **Tools**; everything else is one click down behind a
|
||||
pulldown, which is what keeps them apart.
|
||||
|
||||
| Panel | Button | |
|
||||
| --- | --- | --- |
|
||||
| Tools | **Reinforce** | Suggests reinforcing for a design from what comparable designs already carry. |
|
||||
| | **Panel Lift** | PCI Chapter 8 handling check on the piece, from the insert positions the model already holds. |
|
||||
| | **Ticket QC** | Checks every shop ticket of a design against its spec — f'c, f'ci, I.T., strand count/grade/diameter, W.W.F., unit weight, pattern legend, size envelope. Needs a knowledge layer; see above. |
|
||||
| | **Snapshot** | Takes a timestamped detached copy of the model at close-out. |
|
||||
| Setup | **Config** ▾ | A pulldown. The commands you run to get a job ready or to look at it sideways, rather than the four you press all day. |
|
||||
| | ↳ Project Config | Writes the project config layer: every setting that is the engineer's to make, in one file, wherever they want it. |
|
||||
| | ↳ Design Extract | Scrapes a design packet — every sheet whose number reduces to the design, and every view placed on them — reads it with the regex/geometry parser in-process, writes a reviewable `spec.json`. No AI call, no subprocess, nothing that leaves Revit. Run once per design. |
|
||||
| | ↳ Self Check | Probes everything the tools assume about this machine, this Revit and this model. Run it first on a new computer. |
|
||||
| | ↳ Job Recon | Reconciles the issued ticket set against the whole job — the question no per-ticket check can answer, because a ticket that is not there cannot be checked. |
|
||||
| | ↳ Takeoff | What the job contains, per piece and in total: hardware by part number, reinforcing by bar mark. |
|
||||
| Design | **DesignHub** ▾ | A pulldown, and a quarantine: both rows below are buttons-and-docs only. |
|
||||
| | ↳ Gravity (WIP) | Gravity load chasing. Unfinished; today the button writes nothing at all. |
|
||||
| | ↳ Lateral (WIP) | Lateral extraction. Unfinished; today it writes a JSON model, two CSVs, and a RISA draft that is deliberately not a real RISA input yet. |
|
||||
|
||||
Anything half-built lives behind the **Design** panel's pulldown and carries
|
||||
`(WIP)` in its name, so nothing unfinished ever sits beside a tool that works.
|
||||
|
||||
The design intent lands on disk as JSON that a human reads and corrects before
|
||||
anything trusts it. Repeated checking is then pure arithmetic — no model, no
|
||||
judgement, same answer every time.
|
||||
|
||||
See [INSTALL.md](INSTALL.md) to deploy it, including on a different Revit
|
||||
version.
|
||||
|
||||
## `panellift` — outside Revit
|
||||
|
||||
PCI Design Handbook Chapter 8 handling checks: stripping and yard handling of
|
||||
flat panels, from the insert positions the model already holds.
|
||||
|
||||
Plain CPython, **no dependencies**, and no Revit import anywhere. That is the
|
||||
point — a calculation whose output reaches a plant has to be verifiable, and
|
||||
verifiable means a test suite that runs in seconds without a licence.
|
||||
|
||||
It reproduces every published number in the ASPIRE Winter 2019 worked examples
|
||||
(which reprint PCI Table 8.3.1 and Figure 8.3.2), and it *derives* PCI's
|
||||
0.0214 moment coefficient from the beam solution rather than quoting it:
|
||||
|
||||
```bash
|
||||
cd panellift && python -m pytest
|
||||
```
|
||||
|
||||
**Run it rather than trusting a number printed here** — a suite grows every
|
||||
day and a README does not.
|
||||
|
||||
See [panellift/PLAN.md](panellift/PLAN.md) for what is built, what is next,
|
||||
and the idealisations that are known to be idealisations.
|
||||
|
||||
## Filing feedback
|
||||
|
||||
Please do — that is what this trial is for. Two templates when you open an
|
||||
issue: **Bug report** for something that crashed or gave a wrong answer,
|
||||
**Question** for something that was unclear.
|
||||
|
||||
One thing worth knowing before you file: **this tool refuses rather than
|
||||
guesses, by design.** A row that reads "NOT JUDGED" with a stated reason —
|
||||
an unmapped parameter, a missing knowledge layer, a check that legitimately
|
||||
has nothing to say about this piece — is very often the tool working
|
||||
*correctly*, not a bug. The bug report template asks you to tell these two
|
||||
apart:
|
||||
|
||||
- **Wrong answer** — it computed something, and the something is wrong, or
|
||||
it crashed. That is a code bug; file the Bug report template, and paste
|
||||
`python tools/setup_machine.py --check`'s full output — it is the single
|
||||
most diagnostic thing you can attach.
|
||||
- **Refused, and I think it shouldn't have** — it declined to answer and you
|
||||
believe it had enough information to. That is usually knowledge-layer
|
||||
work (a parameter role that needs mapping, a rating that needs
|
||||
publishing) rather than a code fix — file it as a Question, and say what
|
||||
you expected it to know.
|
||||
|
||||
**No client or job data in an issue, a PR, or a screenshot — ever.** See
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) for the placeholder convention this
|
||||
project uses everywhere instead.
|
||||
|
||||
### What to ask the owner for
|
||||
|
||||
This distribution ships no knowledge on purpose. To point it at a real job
|
||||
you will want, from the owner directly (not filed as an issue — these are
|
||||
not code, and they identify a real plant):
|
||||
|
||||
- a **plant knowledge layer** — your office's parameter names, casting-bed
|
||||
conventions, and rigging defaults;
|
||||
- the **published hardware catalogue** — manufacturer ratings for lifting
|
||||
inserts and connectors, plus the master shop-ticket checklist Ticket QC
|
||||
reads;
|
||||
- sample decks or a walkthrough, if you want a guided first run rather than
|
||||
starting from the example in `examples/`.
|
||||
|
||||
## Working on this
|
||||
|
||||
- **Never use pyRevit → Reload. Restart Revit.** A reload leaves the routes
|
||||
handler pointing at a dead engine and the next bridge request kills Revit.
|
||||
- IronPython 2.7 syntax under `WillTools.extension/`; modern Python is fine in
|
||||
`tools/` and `panellift/`.
|
||||
- Any change to a model needs a Transaction. Internal units are decimal feet.
|
||||
- Scope every `FilteredElementCollector`. An unfiltered one crashed Revit.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
|
||||
@@ -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,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Extract the lateral-resisting members and write an analysis input file.
|
||||
#
|
||||
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
|
||||
# when somebody has typed the marker into its identity description -- by
|
||||
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
|
||||
# Nothing here infers it from geometry, category or name.
|
||||
#
|
||||
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
|
||||
# the output files go beside the model, never into the repository.
|
||||
#
|
||||
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
|
||||
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
|
||||
# loaded by accident. See vibe.latrisa for what is established about the RISA
|
||||
# format and what is not.
|
||||
#
|
||||
# The selection idiom follows Ticket QC and Panel Lift Check: what is
|
||||
# preselected is what is checked; nothing preselected scans the configured
|
||||
# categories across the model. The difference from both is that this tool
|
||||
# never asks the user to pick pieces from a list -- the marker IS the pick,
|
||||
# and offering a second way to choose would let the two disagree.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Lateral (WIP)'
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, knowledge, latextract, latmodel, latrisa
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Lateral Extract'
|
||||
|
||||
|
||||
def die(message):
|
||||
"""The user has not given us something yet. Not a refusal to extract."""
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
def not_run(message):
|
||||
"""'Could not judge' -- a RESULT, and it must survive on the report.
|
||||
|
||||
The distinction this whole tool turns on: NOT RUN is not the same as
|
||||
'ran and found nothing marked'. NOT RUN means the question could not be
|
||||
asked at all, so the absence of marked members says nothing. A run that
|
||||
asked and found nothing gets the ordinary report, with a nothing-found
|
||||
banner and no files written.
|
||||
"""
|
||||
output.print_md('# Lateral Extract — NOT RUN')
|
||||
output.print_md('**NOT RUN** — the extraction did not run.')
|
||||
output.print_md('> {0}'.format(message))
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ context
|
||||
know = knowledge.load(doc)
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
forms.alert(problem, title=TITLE, exitscript=True)
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
scope = ('the {0} element(s) you selected'.format(len(selection))
|
||||
if selection else 'the whole model, in the configured categories')
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
|
||||
pb.update_progress(0, 2)
|
||||
model, found, why = latextract.gather(doc, selection=selection or None,
|
||||
know=know)
|
||||
pb.update_progress(2, 2)
|
||||
|
||||
cats = knowledge.get(know, 'lateral.categories', None) \
|
||||
or latextract.DEFAULT_CATEGORIES
|
||||
|
||||
if why == latextract.NOT_RUN_UNMAPPED:
|
||||
not_run(
|
||||
'The `parameters.{0}` role is not mapped, so this does not know '
|
||||
'which parameter holds the identity description the marker is '
|
||||
'typed into.\n\n'
|
||||
'It will not guess a name. A guessed name would work on the '
|
||||
'template it was guessed from and silently find nothing on every '
|
||||
'other one, and "found nothing" is indistinguishable from "nothing '
|
||||
'is marked" once it is on the screen.\n\n'
|
||||
'Map it in the plant knowledge file and run again. Self Check lists '
|
||||
'which layers are in play.'.format(latextract.DESC_ROLE))
|
||||
|
||||
if why == latextract.NOT_RUN_ABSENT:
|
||||
# A literal role path, not a variable, so knowledge_drift can see it.
|
||||
names = knowledge.param_names(know, 'member.identity_description')
|
||||
not_run(
|
||||
'The `parameters.{0}` role is mapped to **{1}**, but not one '
|
||||
'candidate element actually carries that parameter.\n\n'
|
||||
'That is not the same as "nothing is marked". The field is not '
|
||||
'there, so nothing could have been marked, and this tool has no '
|
||||
'opinion of its own about which members resist lateral load.\n\n'
|
||||
'Scope searched: {2}. Categories: `{3}`.\n\n'
|
||||
'Either add the parameter to these families, or point the role at '
|
||||
'whatever field this office really uses.'
|
||||
.format(latextract.DESC_ROLE, ', '.join(names), scope,
|
||||
', '.join(cats)))
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
output.print_md('# Lateral extraction')
|
||||
output.print_md(
|
||||
'Model **{0}** · 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]))
|
||||
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.')
|
||||
script.exit()
|
||||
|
||||
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
|
||||
written = []
|
||||
for name, text in latrisa.file_set(model, stem):
|
||||
path = os.path.join(start_dir, name)
|
||||
try:
|
||||
f = io.open(path, 'w', encoding='utf-8')
|
||||
try:
|
||||
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
|
||||
finally:
|
||||
f.close()
|
||||
written.append(path)
|
||||
except Exception as ex:
|
||||
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
|
||||
|
||||
output.print_md('## Files written')
|
||||
for path in written:
|
||||
output.print_md('- `{0}`'.format(path))
|
||||
output.print_md(
|
||||
'> The `.json` and the two `.csv` files are complete statements of what '
|
||||
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
|
||||
'file -- its section keywords follow RISA\'s published Appendix D but '
|
||||
'the field order inside each record is unverified, so it is named so '
|
||||
'that it cannot be opened into RISA by accident. Read its header before '
|
||||
'renaming it.')
|
||||
if not latmodel.is_analysable(model):
|
||||
output.print_md(
|
||||
'> **This model would not analyse correctly as written**, and that '
|
||||
'is on purpose: it has unresolved sections, free ends, no supports '
|
||||
'and no releases. Fix those in the analysis package, not here.')
|
||||
@@ -0,0 +1,17 @@
|
||||
title: Panel Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration, same as its siblings in this pulldown. This button
|
||||
# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section
|
||||
# per selected panel, pre-filled from the model where readable and blank
|
||||
# otherwise, gated so a panel cannot run until its required fields (incl. >=2
|
||||
# supports) are filled -- then feeds the confirmed inputs to the Stage-0
|
||||
# self-weight solve. The in-place / wind / service engine behind the form is a
|
||||
# later stage. See script.py's own comments for exactly what it does and does
|
||||
# not do.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,17 @@
|
||||
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
|
||||
# why a container may carry a `title:`.
|
||||
title: DesignHub
|
||||
|
||||
# All three rows are half-built and all three say so in their own titles.
|
||||
# Keep the (WIP) suffixes until the tool behind one actually runs: the arrow
|
||||
# hides them from the ribbon, but a row that opens is still a row somebody
|
||||
# clicks. PanelAnalysis differs from its two siblings in one way worth
|
||||
# naming: it DOES run, end to end, and opens a real page -- what is
|
||||
# unfinished is the analysis pipeline behind the page it opens, not the
|
||||
# button itself. See that button's own script.py.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- GravityLoadChase
|
||||
- LateralExtract
|
||||
- PanelAnalysis
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,11 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Design").
|
||||
#
|
||||
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
|
||||
# standing instruction is that unfinished tools must never sit on the same
|
||||
# panel as the polished ones; gravity-load chasing and lateral extraction
|
||||
# are buttons-and-docs only until he provides worked examples, so they live
|
||||
# one click down, behind DesignHub, on a panel of their own.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- DesignHub
|
||||
@@ -0,0 +1,8 @@
|
||||
title: Design Extract
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,583 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Turn a design summary into a reviewable spec file.
|
||||
#
|
||||
# THE PASS, mechanical, in-process: scrapes the summary drafting view and its
|
||||
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
|
||||
# no subprocess, nothing that leaves this process. What the parser could not
|
||||
# place lands verbatim in the spec's 'unresolved' list rather than being
|
||||
# dropped, which is what makes a narrow rule set honest instead of lossy.
|
||||
# Read-only as far as Revit is concerned (ExportImage writes no model data).
|
||||
#
|
||||
# Every value this button writes is machine-read and UNVERIFIED. Check it
|
||||
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
|
||||
# is trusted to have used the right numbers.
|
||||
#
|
||||
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
|
||||
# -----------------------------------------------------------
|
||||
# The specs written here are what Ticket QC's design-intent comparison
|
||||
# enforces, so a design extracted here enters the scoped path. Until
|
||||
# 2026-08-31 this button offered EVERY design in the model with no mention of
|
||||
# the scope at all -- a side door: an out-of-scope design could be extracted,
|
||||
# reach Ticket QC, and be compared against, without ever being labelled.
|
||||
#
|
||||
# It is not a door that should be shut. The engineer may have a reason to
|
||||
# extract somebody else's design -- to read it, to compare against it, to
|
||||
# check a shared edge -- and a picker that hid designs would be exactly the
|
||||
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
|
||||
# nothing is blocked. Instead:
|
||||
#
|
||||
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
|
||||
# choosing, before anything is written;
|
||||
# * the report carries the scope line every other report carries, and,
|
||||
# when one was actually extracted, the annotation saying the values in
|
||||
# it rest on knowledge nobody validated for that kind of piece;
|
||||
# * the fold opens ITSELF when that happened, by the standing display rule.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Design Extract'
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
|
||||
ViewSheet)
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, ticketqc, knowledge, specsave
|
||||
from vibe import foldreport as fold
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
if not doc.PathName:
|
||||
forms.alert('Save the model first - the spec files live beside it.',
|
||||
exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------- pick work
|
||||
know = knowledge.load(doc)
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=know)
|
||||
existing = designspec.list_specs(doc)
|
||||
|
||||
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
|
||||
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
|
||||
|
||||
# The declared scope, off the job knowledge, read the same way Job Recon,
|
||||
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
|
||||
# note at the top of this file.
|
||||
the_scope = scopelib.of(know)
|
||||
|
||||
|
||||
def out_of_scope(design):
|
||||
"""True when this design number is declared to be somebody else's.
|
||||
|
||||
Through `as_mark`, so a design key that does not read as a mark at all
|
||||
is 'could not tell' and is never labelled as another engineer's.
|
||||
"""
|
||||
return not the_scope.includes(scopelib.as_mark(design))
|
||||
|
||||
|
||||
def sheets_by_folder():
|
||||
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
|
||||
organization, so this picker is grouped the way the browser tree is.
|
||||
|
||||
A flat list of every drafting view is unusable on a real job -- there are
|
||||
hundreds, and their names are not the design numbers anyway.
|
||||
"""
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
out = {}
|
||||
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if sheet.IsTemplate:
|
||||
continue
|
||||
number = (sheet.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
|
||||
continue
|
||||
folder = ''
|
||||
if org is not None:
|
||||
try:
|
||||
folder = ' / '.join(
|
||||
[item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
folder = ''
|
||||
out.setdefault(folder or '(no folder)', []).append(sheet)
|
||||
return out
|
||||
|
||||
|
||||
def packets(sheets):
|
||||
"""{design key: [ViewSheet, ...]} in page order.
|
||||
|
||||
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
|
||||
pages of design PNL1, and extracting any of them means extracting the
|
||||
packet.
|
||||
"""
|
||||
out = {}
|
||||
for sheet in sheets:
|
||||
key = designspec.design_key(sheet.SheetNumber, know)
|
||||
if key:
|
||||
out.setdefault(key, []).append(sheet)
|
||||
for key in out:
|
||||
out[key].sort(key=lambda s: (s.SheetNumber or ''))
|
||||
return out
|
||||
|
||||
|
||||
DESIGNS = {} # row label -> design key, so nothing has to be split
|
||||
|
||||
|
||||
def row(design, sheets=None):
|
||||
bits = []
|
||||
if sheets:
|
||||
cover = sheets[0]
|
||||
bits.append(cover.Name)
|
||||
if len(sheets) > 1:
|
||||
bits.append('{0} sheets ({1})'.format(
|
||||
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
|
||||
else:
|
||||
bits.append('sheet {0}'.format(cover.SheetNumber))
|
||||
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
|
||||
else 'NO pieces')
|
||||
if design in existing:
|
||||
bits.append('spec exists - will be overwritten')
|
||||
# At the point of choosing, and in capitals: this is the one moment the
|
||||
# engineer can decide he did not mean to extract somebody else's design.
|
||||
# It never removes the row.
|
||||
if out_of_scope(design):
|
||||
bits.append('OUTSIDE THE DECLARED SCOPE')
|
||||
label = '{0} - {1}'.format(design, ', '.join(bits))
|
||||
DESIGNS[label] = design
|
||||
return label
|
||||
|
||||
|
||||
groups = OrderedDict()
|
||||
|
||||
by_folder = sheets_by_folder()
|
||||
for folder in sorted(by_folder):
|
||||
found = packets(by_folder[folder])
|
||||
if found:
|
||||
groups['Sheets / ' + folder] = [row(key, found[key])
|
||||
for key in sorted(found)]
|
||||
|
||||
# Designs the pieces claim that no sheet in the design folder accounts for.
|
||||
# These would silently go unchecked, so they get their own group rather than
|
||||
# being left off the list.
|
||||
accounted = set()
|
||||
for folder in by_folder:
|
||||
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
|
||||
accounted.update(packets(by_folder[folder]).keys())
|
||||
orphans = [d for d in sorted(grouped) if d not in accounted]
|
||||
if orphans:
|
||||
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
|
||||
|
||||
if existing:
|
||||
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
|
||||
if leftover:
|
||||
groups['Specs already on disk'] = leftover
|
||||
|
||||
if not groups:
|
||||
forms.alert('No design numbers found in this model.', exitscript=True)
|
||||
|
||||
kwargs = {
|
||||
'title': 'Extract which design summaries?',
|
||||
'group_selector_title': 'Browser folder',
|
||||
'multiselect': True,
|
||||
'button_name': 'Extract',
|
||||
'width': 900,
|
||||
'height': 650,
|
||||
}
|
||||
preferred = 'Sheets / ' + DESIGN_FOLDER
|
||||
if DESIGN_FOLDER and preferred in groups:
|
||||
kwargs['default_group'] = preferred
|
||||
|
||||
picked = forms.SelectFromList.show(groups, **kwargs)
|
||||
if not picked:
|
||||
script.exit()
|
||||
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
|
||||
|
||||
# What was OFFERED and left unpicked is not the scope's business -- the
|
||||
# engineer chose. What matters is what he DID pick that the declaration says
|
||||
# is somebody else's, because those specs are about to feed Ticket QC.
|
||||
picked_out = [d for d in designs if out_of_scope(d)]
|
||||
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
|
||||
scope_annotation = the_scope.annotation(picked_out, unit='design')
|
||||
|
||||
# ------------------------------------------------------------- gather + go
|
||||
output.print_md('# Extract design summaries')
|
||||
output.print_md('Model **{0}**'.format(doc.Title))
|
||||
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
|
||||
output.print_md(
|
||||
'**Every value below is machine-read and unverified.** A regex either '
|
||||
'matched a rule or it did not -- there is no judgement in between. What '
|
||||
"matched is filled in; every line of the summary that didn't is listed "
|
||||
'verbatim in the spec\'s `unresolved` section instead of being silently '
|
||||
'dropped -- and, from this press on, on this page as well. Read those, '
|
||||
'then check the spec in `{0}` against the drawing before Ticket QC or '
|
||||
'Panel Lift Check are trusted to have used the right numbers.'
|
||||
.format(designspec.sub_dir(doc, 'specs')))
|
||||
|
||||
# ---- the declared scope. Not folded, and above the results rather than
|
||||
# below them, because a spec is written to disk by the time this page exists
|
||||
# -- the reader is being told what he already has, and Ticket QC will pick it
|
||||
# up whether or not he scrolls.
|
||||
output.print_md('> ' + scope_line)
|
||||
if scope_annotation:
|
||||
output.print_md('> **' + scope_annotation + '**')
|
||||
for _note in the_scope.declaration_lines():
|
||||
output.print_md('_{0}_'.format(_note))
|
||||
|
||||
def _per_plant_layer_exists():
|
||||
"""True when this machine already has at least one per-plant folder.
|
||||
|
||||
Answers a different question from `knowledge.plant_id(doc)`: that asks
|
||||
which plant THIS job names; this asks whether the per-plant migration
|
||||
has already happened on THIS MACHINE at all, which is what decides
|
||||
whether the legacy machine-wide path is still a safe place to drop a
|
||||
starter.
|
||||
|
||||
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
|
||||
-- writing the machine-wide starter there is exactly what this button has
|
||||
always done, and it is still right: a fresh install needs something to
|
||||
edit. But once a machine has even one real per-plant folder, the
|
||||
machine-wide file at `<root>/knowledge.yaml` is what
|
||||
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
|
||||
(`plantpath.py`), so writing a blank template there would give a job
|
||||
that simply forgot to set `job.plant` a silent, empty plant layer
|
||||
instead of a loud "no plant declared." That is the exact trap this fix
|
||||
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
|
||||
migrated machine the safe move is to write nothing and say so.
|
||||
"""
|
||||
root = designspec.plant_root()
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return False
|
||||
for name in names:
|
||||
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `write_plant_starter()` with no plant argument always targets the
|
||||
# machine-wide `<root>/knowledge.yaml` -- the exact file
|
||||
# `plant_knowledge_path(None)` returns as the fallback for any job that
|
||||
# omits `job.plant`. Calling it unconditionally, as this button used to,
|
||||
# recreated a blank template there on every press, which any no-plant job
|
||||
# would then silently read as its entire plant layer. `knowledge.plant_id`
|
||||
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
|
||||
# before the ordinary merge runs (see its docstring), so it is read the
|
||||
# same way here rather than re-derived.
|
||||
plant = knowledge.plant_id(doc)
|
||||
|
||||
if plant:
|
||||
# A named plant gets its own folder. `write_plant_starter` already
|
||||
# refuses to overwrite a file that exists (knowledge.py:6601 --
|
||||
# `if os.path.isfile(path) and not overwrite: return path, False`), so
|
||||
# calling it on every press is safe: it only ever fills a gap, never
|
||||
# clobbers a real plant layer.
|
||||
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
elif _per_plant_layer_exists():
|
||||
# No plant named, but this machine has already been migrated to
|
||||
# per-plant folders -- writing at the legacy path would recreate the
|
||||
# exact decoy this fix removes. Report the gap instead of filling it
|
||||
# with a blank file that looks like an answer.
|
||||
output.print_md(
|
||||
'Plant standards: **no `job.plant` set**, and this machine already '
|
||||
'has per-plant folders under `{0}` -- nothing written at the '
|
||||
'legacy machine-wide path, because that path is what a no-plant '
|
||||
'job reads as its plant layer. Set `job.plant` in this job\'s '
|
||||
'knowledge file.'.format(designspec.plant_root()))
|
||||
else:
|
||||
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
|
||||
# so the machine-wide file is still the only plant layer this install
|
||||
# could possibly mean, exactly as it was before per-plant folders
|
||||
# existed. Safe to seed, and `write_plant_starter` still refuses to
|
||||
# overwrite if one is somehow already there.
|
||||
ppath, pcreated = knowledge.write_plant_starter()
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
|
||||
kpath, created = knowledge.write_starter(doc)
|
||||
if created:
|
||||
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
|
||||
|
||||
written, skipped = [], []
|
||||
for design in designs:
|
||||
try:
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
if not evidence.get('summary_views'):
|
||||
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
|
||||
'sheets carry no views'.format(
|
||||
DESIGN_FOLDER or 'any folder', design)))
|
||||
continue
|
||||
designspec.write_json(designspec.evidence_path(doc, design), evidence)
|
||||
notes = sum([len(v.get('text_notes') or [])
|
||||
for v in evidence.get('summary_views') or []])
|
||||
|
||||
try:
|
||||
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
|
||||
if spec is None:
|
||||
skipped.append((design, 'no summary view to read'))
|
||||
continue
|
||||
path = designspec.save_spec(doc, design, spec)
|
||||
# The spec itself is kept, not just its counts: what is unresolved is
|
||||
# listed by name below, and the copy the engineer asks for is written
|
||||
# from the file this returned.
|
||||
written.append({'design': design, 'notes': notes, 'path': path,
|
||||
'spec': spec})
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
# ------------------------------------------------------- where the copy goes
|
||||
#
|
||||
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
|
||||
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
|
||||
# 路径。用户也可以打开看到底是什么东西unresolved。"
|
||||
#
|
||||
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
|
||||
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
|
||||
# else, so a spec saved only into a folder the engineer picked would look
|
||||
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
|
||||
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
|
||||
# read.
|
||||
#
|
||||
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
|
||||
# on cancel, which is right there: nothing has been calculated yet, so a
|
||||
# cancel costs nothing. Here the extraction has already run and the files are
|
||||
# already on disk, so exiting would throw away work that only another press
|
||||
# could get back. Cancel therefore means "no second copy" and nothing else --
|
||||
# the results still print, pointing at the canonical file.
|
||||
copies = {}
|
||||
copy_problems = []
|
||||
in_place = []
|
||||
cancelled_save = False
|
||||
|
||||
if written:
|
||||
designs_written = [w['design'] for w in written]
|
||||
# Seeded with wherever the last extraction was saved; the job's data
|
||||
# folder when there is no such memory. Both are real places on this
|
||||
# machine -- a dialog opening on a path that does not exist is how a file
|
||||
# lands somewhere nobody meant. NOT the `specs` folder itself: the
|
||||
# default name is the name the canonical file already has, so opening
|
||||
# there would greet a single-design run with "PNL1.yaml already exists,
|
||||
# replace it?" -- a question about the file it just wrote.
|
||||
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
|
||||
or designspec.root_dir(doc))
|
||||
chosen = forms.save_file(
|
||||
file_ext='yaml', init_dir=start_dir or '',
|
||||
default_name=specsave.default_name(designs_written),
|
||||
title='Save the design extraction as')
|
||||
if not chosen:
|
||||
cancelled_save = True
|
||||
else:
|
||||
by_design = dict([(w['design'], w['path']) for w in written])
|
||||
for design, target in specsave.targets(chosen, designs_written):
|
||||
# Saving onto the file itself. The dialog opens on the folder the
|
||||
# specs already live in, so this is an ordinary thing to do by
|
||||
# accident -- and rewriting a file with its own contents is at
|
||||
# best pointless. Say so instead of claiming a copy that is the
|
||||
# same path twice.
|
||||
if specsave.same_file(by_design[design], target):
|
||||
in_place.append(design)
|
||||
continue
|
||||
try:
|
||||
# Copied as text rather than re-rendered from the spec, so the
|
||||
# copy is character-for-character the file Ticket QC reads. A
|
||||
# second rendering could drift from the first and the two
|
||||
# would look like the same extraction.
|
||||
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
|
||||
body = fh.read()
|
||||
with io.open(target, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
copies[design] = target
|
||||
except Exception as ex:
|
||||
copy_problems.append('{0}: {1}'.format(design, ex))
|
||||
specsave.remember_dir(designspec.root_dir(doc),
|
||||
os.path.dirname(chosen))
|
||||
|
||||
if written:
|
||||
output.print_md('## Written')
|
||||
output.print_table(
|
||||
[[w['design'], str(w['notes']),
|
||||
str(len(specsave.unresolved_entries(w['spec']))),
|
||||
os.path.basename(w['path'])] for w in written],
|
||||
columns=['design', 'text notes scraped', 'unresolved notes',
|
||||
'spec file'])
|
||||
|
||||
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
|
||||
# is a dead link in this panel -- backslashes and no scheme, so it renders
|
||||
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
|
||||
# stays the real path, because that is what gets copied into Explorer,
|
||||
# and only the href gets the URL form. `specsave.link` is that pattern.
|
||||
for w in written:
|
||||
line = '- **{0}** {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.')
|
||||
|
||||
# ------------------------------------------- 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. This button always '
|
||||
'writes `regex` (the mechanical reader). Nothing branches on the '
|
||||
'value; it is shown so a spec written by one reader is not mistaken '
|
||||
'for a spec written by another.')
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Job Recon
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
|
||||
# the tool changed while it was out: only its ribbon entry was ever parked,
|
||||
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reconcile the issued ticket set against the whole job.
|
||||
#
|
||||
# Every other check on this panel judges one ticket at a time, and no amount of
|
||||
# per-ticket checking can find a ticket that is not there. This button asks the
|
||||
# question about the SET: every piece printed on something, everything printed
|
||||
# about a piece that exists, and the counts reconciling mark by mark.
|
||||
#
|
||||
# It reads. It never writes, and it opens no transaction.
|
||||
#
|
||||
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
|
||||
# across this tab: the answer is the headline and is never folded; everything
|
||||
# else follows it, collapsed; a section that carries a problem opens itself.
|
||||
# The reader's contract is that if nothing below the headline is open, nothing
|
||||
# needs him.
|
||||
#
|
||||
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
|
||||
# for, and a count per finding -- not the findings themselves. On a job with
|
||||
# four hundred uncovered pieces the lists are thousands of lines and the
|
||||
# verdict is invisible inside them. So:
|
||||
#
|
||||
# * the headline is the totals and the finding counts, printed through
|
||||
# pyRevit's own markdown and tables;
|
||||
# * every non-empty finding is a section below it, OPEN, because there is no
|
||||
# finding in this reconciliation that is merely interesting;
|
||||
# * coverage by design, the page-set inventory, the categories swept, the
|
||||
# checks that found nothing and the timings are folded;
|
||||
# * and the two sections that say what this run could NOT see -- out of
|
||||
# scope, and blind spots -- are separate, always, and carry their counts
|
||||
# on their headers with the fold shut. A blind spot that is a REFUSAL
|
||||
# (nothing reconciled) opens itself and is repeated in the headline,
|
||||
# because a refusal reported quietly reads as a clean job.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
|
||||
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
|
||||
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
|
||||
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
|
||||
# problem in it renders open as plain HTML and needs no script at all; and the
|
||||
# whole report, nothing folded and nothing capped, is written to
|
||||
# `last-report.txt` beside the model's other job data on every press, with the
|
||||
# page saying where. A fold that will not open therefore costs a file open,
|
||||
# never a lost sentence. All of that lives in `vibe.foldreport`.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Job Recon'
|
||||
|
||||
import os
|
||||
|
||||
from pyrevit import revit, script
|
||||
|
||||
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
|
||||
from vibe import foldreport as fold
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
REQUIRED = ('member.control_number', 'sheet.control_numbers')
|
||||
|
||||
# The cap on each list ON THE PAGE. The file written beside the model has no
|
||||
# cap at all -- a page is read, a file is searched.
|
||||
PAGE_LIMIT = 60
|
||||
|
||||
HOW_TO_MAP = """
|
||||
This reconciliation needs roles the older checks never used. Add them to the
|
||||
plant knowledge file (the layer that describes the office TEMPLATE), naming
|
||||
whatever your title block and your pieces actually call these things:
|
||||
|
||||
parameters:
|
||||
member:
|
||||
control_number: <the piece's control number>
|
||||
ticket_created: <optional -- the model's own 'a ticket exists' flag>
|
||||
ticket_detailed: <optional>
|
||||
sheet:
|
||||
total_required: <the quantity the title block states>
|
||||
sheet_count: <how many pages the ticket says it has>
|
||||
# either list the control-number slots explicitly ...
|
||||
control_numbers: [<slot 1>, <slot 2>, ...]
|
||||
|
||||
nomenclature:
|
||||
# ... or, for numbered slots, match them with one expression:
|
||||
control_slot_re: '^<PREFIX>_\\d+$'
|
||||
|
||||
Until then this button refuses rather than reporting a job it cannot see.
|
||||
"""
|
||||
|
||||
|
||||
def write_full(recon, full_path):
|
||||
"""The whole report, unfolded, beside the model. Returns True if written.
|
||||
|
||||
Written BEFORE anything is printed, so the file exists even if the
|
||||
printing throws. `as_text` first and encode second: under IronPython 2.7
|
||||
the report is a byte string and a mark carrying a non-ASCII character
|
||||
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
"""
|
||||
payload = compat.as_text(
|
||||
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
for line in problems:
|
||||
output.print_md('**' + line + '**')
|
||||
return
|
||||
|
||||
names = knowledge.param_names(know, 'member.control_number')
|
||||
slots = (knowledge.param_names(know, 'sheet.control_numbers')
|
||||
or knowledge.get(know, 'nomenclature.control_slot_re', None))
|
||||
if not names or not slots:
|
||||
output.print_md('## Job Recon cannot run')
|
||||
output.print_md('Nothing was reconciled, and that is **not** a clean '
|
||||
'result -- it is a refusal.')
|
||||
print(HOW_TO_MAP)
|
||||
return
|
||||
|
||||
recon = jobreconscan.run(doc, know=know)
|
||||
|
||||
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
|
||||
'last-report.txt')
|
||||
written = False
|
||||
try:
|
||||
written = write_full(recon, full_path)
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold '
|
||||
'headers.'.format(exc))
|
||||
|
||||
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
|
||||
full_path=full_path if written else None)
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if not tail:
|
||||
return
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one.')
|
||||
if written:
|
||||
output.print_md('The whole report, nothing folded and nothing capped, '
|
||||
'is in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Project Config"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SizeToContent="Height"
|
||||
Width="880" MaxHeight="940" MinHeight="380"
|
||||
ResizeMode="CanResize"
|
||||
Background="#f6f7f9">
|
||||
|
||||
<!--
|
||||
THE WINDOW IS CHROME ONLY. Every field is built in `script.py` from
|
||||
`vibe.configform`, because the field list is data: it depends on which
|
||||
build of `configmodel` this Revit session is holding, on which stages
|
||||
the job has, and on how many checklist items are muted. A XAML
|
||||
DataTemplate would have had to know all three.
|
||||
|
||||
What lives here is what does not change: the four module boxes, the
|
||||
reason line every job-layer write needs, and the four buttons. Every
|
||||
x:Name below is looked up by name in `script.py`, and every Click
|
||||
handler names a method on the window class. `tests/test_configform.py`
|
||||
parses this file and that one together and fails if the two have
|
||||
drifted, which is the only check available for a window that cannot be
|
||||
opened without Revit.
|
||||
|
||||
EVERY MODULE IS AN Expander, NOT A GroupBox, AND STARTS COLLAPSED. The
|
||||
owner's ruling, 2026-09-01, verbatim in `script.py`'s own comment (kept
|
||||
out of THIS file because the no-Chinese-in-the-XAML test below scans
|
||||
every byte of it, comments included, and not only the strings a build
|
||||
would render): every section folds, and folded is the default; open
|
||||
the one you came to change, adjust it, done. `IsExpanded="False"` here
|
||||
is the fallback for the very first time this window opens in a
|
||||
session; `script.py` overrides it per module from
|
||||
`vibe.configform.remembered_expanded`, which is the ONLY thing that
|
||||
may reopen one on its own, since a module the owner left open a minute
|
||||
ago staying open is a convenience, and a module opening because it
|
||||
carries a value is not this window's call to make silently, which is
|
||||
why the header text says so instead (see `configform.is_modified` /
|
||||
`modified_count`, and `script.py`'s use of them building each
|
||||
Header).
|
||||
|
||||
No double hyphen anywhere in this comment: XML forbids it inside a
|
||||
comment, and the test above catches it as a parse error rather than as
|
||||
a window that will not load.
|
||||
-->
|
||||
|
||||
<Window.Resources>
|
||||
<!-- No FontWeight or Foreground here: a GroupBox passes both down to
|
||||
its CONTENT, so setting them to style the header would make every
|
||||
label and every note in the box bold as well. The rows set their
|
||||
own weight and colour in script.py. -->
|
||||
<Style TargetType="{x:Type GroupBox}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
</Style>
|
||||
<!-- The four modules are Expanders now, not GroupBoxes; see the
|
||||
note above this Window's own opening comment. Same border,
|
||||
same spacing, so folding cost nothing visually except the
|
||||
fold itself. -->
|
||||
<Style TargetType="{x:Type Expander}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Background" Value="White"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type Button}">
|
||||
<Setter Property="Padding" Value="10,3,10,3"/>
|
||||
<Setter Property="MinWidth" Value="76"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Padding" Value="3,2,3,2"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Margin="14">
|
||||
|
||||
<!-- The sentence that used to be cut off by a 36-pixel Label. It
|
||||
wraps here, because there is room here. -->
|
||||
<TextBlock x:Name="header"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
FontWeight="Bold"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,4"
|
||||
Text="Project Config"/>
|
||||
<TextBlock x:Name="subheader"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#5a6b7d"
|
||||
Margin="0,0,0,10"
|
||||
Text=""/>
|
||||
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
Background="#eef1f5"
|
||||
BorderBrush="#c9d2dd"
|
||||
BorderThickness="1"
|
||||
Padding="8"
|
||||
Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="status"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,6"
|
||||
Text="Nothing changed yet."/>
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
VerticalAlignment="Center"
|
||||
Width="150"
|
||||
Text="Why (written into the file)"/>
|
||||
<TextBox x:Name="reason_box" Height="24"/>
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="save_button"
|
||||
Content="Save to the job layer"
|
||||
MinWidth="150"
|
||||
Click="save_click"/>
|
||||
<Button x:Name="cancel_button"
|
||||
Content="Close"
|
||||
Click="cancel_click"/>
|
||||
<Button x:Name="openfile_button"
|
||||
Content="Open the job file"
|
||||
MinWidth="130"
|
||||
Click="openfile_click"/>
|
||||
<Button x:Name="fullview_button"
|
||||
Content="Full view"
|
||||
Click="fullview_click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel>
|
||||
<Expander x:Name="group_file_path" Header="File paths"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_file_path"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_lifting" Header="Lifting and handling"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_lifting"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_ticketqc" Header="Ticket QC"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_ticketqc"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_job_knowledge" Header="Job knowledge"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_job_knowledge"/>
|
||||
</Expander>
|
||||
<Border Background="#fff8e6"
|
||||
BorderBrush="#e0c98a"
|
||||
BorderThickness="1"
|
||||
Padding="8">
|
||||
<TextBlock x:Name="rest_line"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#6b5a20"
|
||||
Text="Everything else: edit the config file directly."/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,29 @@
|
||||
title: Project Config
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom. Second-hottest button on this ribbon --
|
||||
# 14 commits to configmodel/configpage/configform/configdump/knowledge in the
|
||||
# seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# "Project Config" rather than plain "Config": the button one level up is
|
||||
# already called Config, and a Config row inside a Config button reads like
|
||||
# a mistake even though the two name tables never collide.
|
||||
|
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,322 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Report whether this machine, this Revit and this model can run the QC tools.
|
||||
#
|
||||
# Written for the moment the extension lands on a computer it was not developed
|
||||
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
|
||||
# so a failure names itself instead of arriving as a traceback from four frames
|
||||
# deep in a checking routine.
|
||||
#
|
||||
# Reads nothing but metadata and does not touch the model.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Self Check'
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pyrevit import revit, script, HOST_APP
|
||||
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
doc = revit.doc
|
||||
rows = []
|
||||
|
||||
|
||||
def report(area, item, state, detail):
|
||||
rows.append([area, item, state, detail])
|
||||
|
||||
|
||||
def probe(area, item, fn, missing='FAIL'):
|
||||
"""Run one probe. An exception is a finding, never a crash."""
|
||||
try:
|
||||
state, detail = fn()
|
||||
report(area, item, state or missing, detail)
|
||||
except Exception as ex:
|
||||
report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- host
|
||||
|
||||
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
|
||||
|
||||
|
||||
def _revit():
|
||||
app = HOST_APP.app
|
||||
version = app.VersionNumber
|
||||
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
|
||||
if version == DEVELOPED_ON['revit']:
|
||||
return 'OK', detail
|
||||
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
|
||||
DEVELOPED_ON['revit'])
|
||||
|
||||
|
||||
def _pyrevit():
|
||||
from pyrevit import versionmgr
|
||||
version = versionmgr.get_pyrevit_version().get_formatted()
|
||||
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
|
||||
return state, version
|
||||
|
||||
|
||||
def _engine():
|
||||
detail = sys.version.split('\n')[0]
|
||||
major = sys.version_info[0]
|
||||
if major == 2:
|
||||
return 'OK', detail
|
||||
# The code is written for both, but only Python 2 has been exercised.
|
||||
return 'WARN', detail + ' (only Python 2 has been run in anger)'
|
||||
|
||||
|
||||
def _routes():
|
||||
from pyrevit.userconfig import user_config
|
||||
if not user_config.routes_server:
|
||||
return 'WARN', ('routes server off - out-of-process tools will not '
|
||||
'connect. The buttons themselves do not need it.')
|
||||
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
|
||||
user_config.routes_port)
|
||||
|
||||
|
||||
probe('host', 'Revit', _revit)
|
||||
probe('host', 'pyRevit', _pyrevit)
|
||||
probe('host', 'python engine', _engine)
|
||||
probe('host', 'routes bridge', _routes, missing='WARN')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- library
|
||||
|
||||
def _import_all():
|
||||
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
|
||||
ticketqc, ticketchecks)
|
||||
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
|
||||
'ticketchecks'
|
||||
|
||||
|
||||
def _units():
|
||||
from vibe import ft, mm
|
||||
round_trip = mm(ft(1000.0))
|
||||
if abs(round_trip - 1000.0) > 1e-6:
|
||||
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
|
||||
return 'OK', '1000 mm round-trips exactly'
|
||||
|
||||
|
||||
def _checklist():
|
||||
from vibe import ticketpage
|
||||
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
|
||||
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
|
||||
return 'OK', '{0} row(s) from {1}'.format(
|
||||
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
|
||||
|
||||
|
||||
probe('library', 'modules import', _import_all)
|
||||
probe('library', 'unit conversion', _units)
|
||||
probe('library', 'published checklist', _checklist, missing='WARN')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- model
|
||||
|
||||
def _saved():
|
||||
if not doc.PathName:
|
||||
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
|
||||
return 'OK', doc.PathName
|
||||
|
||||
|
||||
def _data_dir():
|
||||
from vibe import designspec
|
||||
return 'OK', designspec.root_dir(doc)
|
||||
|
||||
|
||||
def _knowledge():
|
||||
"""Every knowledge layer, in the order load() merges them.
|
||||
|
||||
Which layers exist decides what the tool actually believes, so the answer
|
||||
is the list, not a yes/no: naming conventions that should be plant-wide
|
||||
sitting only in a project file is a real and invisible mistake.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
layers = knowledge.sources(doc)
|
||||
detail = ' | '.join(
|
||||
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
|
||||
for label, path, ok in layers])
|
||||
|
||||
# A project config that has gone missing is worse than one never set: the
|
||||
# run looks entirely normal and uses a configuration nobody chose.
|
||||
gone = [path for label, path, ok in layers
|
||||
if label == 'project config' and not ok]
|
||||
if gone:
|
||||
return 'FAIL', ('the project config this model runs on is not there: '
|
||||
'{0} || {1}'.format(gone[0], detail))
|
||||
if not any(ok for _label, _p, ok in layers):
|
||||
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _knowledge_parses():
|
||||
"""Read every layer file that IS there, exactly the way load() reads it.
|
||||
|
||||
The layer list above says which files exist. It does not say whether any
|
||||
of them can be READ, and those are different questions with the same
|
||||
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
|
||||
mapping raises `Duplicate key` and it returns nothing -- and a layer that
|
||||
fails to load looks exactly like a layer that agrees with the defaults.
|
||||
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
|
||||
layer, every parameter role came back None, the sweep enumerated 0
|
||||
assemblies, and every report rendered normally.
|
||||
|
||||
So this re-reads each file through the same reader and prints ok or the
|
||||
parser's own message. `knowledge.config_problems()` reports the same
|
||||
failure from the merge itself; this is the early warning, on the layer
|
||||
list, before a button is pressed. `python tools/knowledgelint.py` is the
|
||||
same check on the host, where the edit is made.
|
||||
"""
|
||||
from pyrevit.coreutils import yaml as pyyaml
|
||||
from vibe import knowledge
|
||||
|
||||
notes = []
|
||||
bad = []
|
||||
for label, path, ok in knowledge.sources(doc):
|
||||
if not ok:
|
||||
notes.append('{0}: absent'.format(label))
|
||||
continue
|
||||
try:
|
||||
pyyaml.load_as_dict(path)
|
||||
notes.append('{0}: ok'.format(label))
|
||||
except Exception as ex:
|
||||
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
|
||||
label, path, type(ex).__name__, ex))
|
||||
if bad:
|
||||
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
|
||||
'was used, and the run fell back to the layers under '
|
||||
'it. Fix the file, then run '
|
||||
'tools/knowledgelint.py to confirm.')
|
||||
if not notes:
|
||||
return 'WARN', 'no knowledge layer to read yet'
|
||||
return 'OK', ' | '.join(notes)
|
||||
|
||||
|
||||
def _specs():
|
||||
from vibe import designspec
|
||||
specs = designspec.list_specs(doc)
|
||||
if not specs:
|
||||
return 'WARN', 'none yet - run Extract Design'
|
||||
unreviewed = [d for d, s in specs.items()
|
||||
if not (s.get('source') or {}).get('reviewed_by')]
|
||||
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
|
||||
if unreviewed:
|
||||
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _scope():
|
||||
"""What the job declares this engineer is answerable for.
|
||||
|
||||
Never a FAIL. An undeclared scope is a legitimate state -- it means every
|
||||
button judges every piece -- and it is reported as such rather than as a
|
||||
misconfiguration, because 'judge everything' is the honest default and
|
||||
the only wrong answer here would be a scope the tool chose for itself.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
from vibe import scope as scopelib
|
||||
the_scope = scopelib.of(knowledge.load(doc))
|
||||
if not the_scope.is_declared:
|
||||
return 'WARN', ('no scope declared -- every button judges every '
|
||||
'piece on the job, including work whose plant '
|
||||
'knowledge has never been validated. Set '
|
||||
'scope.mark_prefixes in the job knowledge file to '
|
||||
'be told what is being taken on trust.')
|
||||
detail = the_scope.label()
|
||||
if the_scope.declared_by:
|
||||
detail += ' (declared by {0})'.format(the_scope.declared_by)
|
||||
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
|
||||
|
||||
|
||||
probe('model', 'saved', _saved)
|
||||
probe('model', 'declared scope', _scope, missing='WARN')
|
||||
probe('model', 'data folder', _data_dir)
|
||||
probe('model', 'knowledge layers', _knowledge, missing='WARN')
|
||||
probe('model', 'knowledge layers parse', _knowledge_parses)
|
||||
probe('model', 'design specs', _specs, missing='WARN')
|
||||
|
||||
|
||||
# --------------------------------------------------- project conventions
|
||||
# These are the assumptions most likely to be wrong on a different job: they
|
||||
# are naming and parameter conventions, not Revit API.
|
||||
|
||||
def _mapping():
|
||||
"""Which parameter roles this office has told us the names for.
|
||||
|
||||
Nothing in the code knows what a panel's length parameter is called, so
|
||||
an unmapped role is not a detail -- it is a check that will not run. It
|
||||
reports before the convention probes below, because they depend on it.
|
||||
"""
|
||||
from vibe import knowledge, designspec
|
||||
know = knowledge.load(doc)
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
total = len(knowledge.PARAMETER_ROLES)
|
||||
if missing:
|
||||
# The template is the plant's, so that is where the names belong; a
|
||||
# project file may still override one.
|
||||
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
|
||||
len(missing), total, ', '.join(missing),
|
||||
designspec.plant_knowledge_path(),
|
||||
'; {0} declared not applicable: {1}'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
if not_applicable else '')
|
||||
mapped = ['{0}={1}'.format(r.split('.')[-1],
|
||||
knowledge.param_names(know, r)[0])
|
||||
for r in knowledge.PARAMETER_ROLES
|
||||
if knowledge.param_names(know, r)]
|
||||
extra = ''
|
||||
if not_applicable:
|
||||
extra = '; {0} declared not applicable at this plant ({1})'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
|
||||
|
||||
|
||||
def _designs():
|
||||
from vibe import ticketqc
|
||||
from vibe import knowledge
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
|
||||
if not grouped:
|
||||
return 'FAIL', ('no assembly carries a design number - either the '
|
||||
'panel.design_number role is mapped to the wrong '
|
||||
'parameter, or this model does not group pieces so')
|
||||
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
|
||||
return 'OK', '{0} designs, {1} pieces: {2}'.format(
|
||||
len(grouped), sum([len(v) for v in grouped.values()]),
|
||||
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
|
||||
|
||||
|
||||
def _summaries():
|
||||
from vibe import ticketqc
|
||||
summaries, sheets = ticketqc.collect_context(doc)
|
||||
if not summaries:
|
||||
return 'FAIL', ('no drafting view is named after a design number - '
|
||||
'Extract Design has nothing to read')
|
||||
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
|
||||
len(sheets))
|
||||
|
||||
|
||||
probe('conventions', 'parameter role mapping', _mapping)
|
||||
probe('conventions', 'design number groups assemblies', _designs)
|
||||
probe('conventions', 'design summary views', _summaries)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ verdict
|
||||
|
||||
output.print_md('# Self check')
|
||||
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
|
||||
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
|
||||
|
||||
fails = len([r for r in rows if r[2] == 'FAIL'])
|
||||
warns = len([r for r in rows if r[2] == 'WARN'])
|
||||
|
||||
if fails:
|
||||
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
|
||||
output.print_md('A **FAIL** means a QC button will not work here. Start '
|
||||
'with the first one - they cascade.')
|
||||
elif warns:
|
||||
output.print_md('## Ready, with {0} warning(s)'.format(warns))
|
||||
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
|
||||
else:
|
||||
output.print_md('## Everything checks out')
|
||||
@@ -0,0 +1,10 @@
|
||||
title: Takeoff
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,184 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# The takeoff, for the whole job: what it contains, per piece and in total.
|
||||
#
|
||||
# The counting lives in `vibe.takeoffcore` (classification and both totals),
|
||||
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
|
||||
# (the two renderings), all three Revit-free and unit-tested on host CPython.
|
||||
# `vibe.takeoffgather` is the only part that touches a document. This file is
|
||||
# the button and nothing more.
|
||||
#
|
||||
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
|
||||
# reports what a model contains; it has no business changing it.
|
||||
#
|
||||
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
|
||||
# -----------------------------------------------------------
|
||||
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
|
||||
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
|
||||
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
|
||||
# takeoff of the model as it was, and the report says when the index was built
|
||||
# so a reader who has been editing can see the stamp predates his edits. A
|
||||
# button that silently spends ten seconds rebuilding is a button that sometimes
|
||||
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
|
||||
# and it has the progress bar for it.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
|
||||
# ordering is done once for the job, and a per-piece takeoff is already in
|
||||
# the by-piece CSV this writes.
|
||||
# 2. It multiplies by control number, and prints both figures. Per-mark and
|
||||
# per-job answer different questions and a reader will want to check one
|
||||
# against the other, so neither is hidden behind the other.
|
||||
# 3. The multiplier comes from the control numbers on framing elements, via
|
||||
# the job reconciliation's own gatherer -- not from assembly names, which
|
||||
# were measured through the bridge and found to be 755 unique names for 755
|
||||
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
|
||||
# printed as a refusal at the top of the multiplier section, never as a job
|
||||
# where every mark happens to be one piece.
|
||||
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
|
||||
# that looks like a takeoff of a job with nothing in it is the worst
|
||||
# possible output, so it says the index has not been built and names the
|
||||
# button that builds it.
|
||||
# 5. The reports go beside the model, never into the repository. They name
|
||||
# marks, part numbers and quantities for one client's job.
|
||||
#
|
||||
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
|
||||
# --------------------------------------------------
|
||||
# The owner's standing rule for the whole tab, and it lives in
|
||||
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
|
||||
# first and never folded; everything else follows FOLDED; a section with a
|
||||
# problem in it opens itself and says on its header why. The contract that
|
||||
# buys is "if nothing below the headline is open, nothing needs me", and it
|
||||
# is worth more than any one section being visible.
|
||||
#
|
||||
# Here the headline is the ORDER -- hardware by part number, reinforcing by
|
||||
# bar mark, both figures on every line, with what multiplied them written
|
||||
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
|
||||
# tallies fold. The refusal does not: an unmapped control-number role means
|
||||
# the model was never asked how many pieces a mark is, every per-job figure
|
||||
# is an undercount, and that renders OPEN as plain HTML.
|
||||
#
|
||||
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
|
||||
# ---------------------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
|
||||
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
|
||||
# `<details>` does not exist there and fails SILENTLY. So the fold is an
|
||||
# inline `onclick` plus `style.display`, and it has not been exercised inside
|
||||
# Revit by the agent that wrote it. Two things make that survivable:
|
||||
# a section with a problem renders open with no script at all, and the whole
|
||||
# report, nothing folded, is the `.md` written beside the model every press
|
||||
# -- which this page names. A dead toggle costs a file open, never a
|
||||
# sentence.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Takeoff'
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import takeoffgather as gather
|
||||
from vibe import takeoffreport as report
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Takeoff'
|
||||
|
||||
|
||||
def not_judged(headline, message):
|
||||
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
|
||||
a dialog that vanishes. It must never read as 'judged and found
|
||||
nothing'."""
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
output.print_md('## {0}'.format(headline))
|
||||
output.print_md(message)
|
||||
script.exit()
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
|
||||
|
||||
takeoff, counts, extras = gather.run(doc, know=know)
|
||||
|
||||
coverage = takeoff.get('coverage') or {}
|
||||
if not coverage.get('indexed_marks'):
|
||||
not_judged(
|
||||
'The reinforcing index has not been built for this model',
|
||||
'A takeoff is built from that index and there is nothing in it '
|
||||
'yet. This is not a job with no hardware in it -- it is a '
|
||||
'question nobody has asked the model.\n\n'
|
||||
'Run **Reinforcing Suggestion** once; it builds the index. Then '
|
||||
'press this again.')
|
||||
|
||||
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
|
||||
|
||||
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
|
||||
# printed -- the file then exists even if the printing throws, and the
|
||||
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
|
||||
# every section in full, folded or not, so it can never say less than
|
||||
# this page does.
|
||||
paths, write_error = {}, None
|
||||
try:
|
||||
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
|
||||
except Exception as exc:
|
||||
write_error = exc
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded, and the one line that says whether any of
|
||||
# it needs him. That line IS the contract; without it a page of shut
|
||||
# headers looks the same whether or not something is wrong.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} section(s) below opened themselves — '
|
||||
'they have something in them that needs you.** The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every section is folded; click '
|
||||
'a header to open one.')
|
||||
if paths.get('.md'):
|
||||
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
|
||||
.format(paths['.md']))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
output.print_md('---')
|
||||
if write_error is not None:
|
||||
# The page is still complete, but only while the toggle works -- so
|
||||
# this says which, rather than the old flat report's promise that
|
||||
# what is on the page is all of it.
|
||||
output.print_md(
|
||||
'**The report could not be written to disk:** {0}. Every section '
|
||||
'is still on this page, but the folded ones need the click to '
|
||||
'open; there is no file copy of them this press.'.format(
|
||||
write_error))
|
||||
else:
|
||||
output.print_md('Written beside the model:')
|
||||
for suffix in sorted(paths):
|
||||
output.print_md('- `{0}`'.format(paths[suffix]))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'_Read only. Nothing was written to the model. Index built '
|
||||
'{0}._'.format(coverage.get('built_at') or 'never'))
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
# A `.pulldown` is a large button with a downward arrow: one icon on the
|
||||
# panel, and every item below opens as a row when it is clicked
|
||||
# (`components.PullDownButtonGroup`, drawn by
|
||||
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
|
||||
# keep their own name table, so the "Project Config" row inside this button
|
||||
# does not collide with the button itself.
|
||||
#
|
||||
# The pulldown's own label. Unlike a stack, a pulldown container does read a
|
||||
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
|
||||
# from it, and `_produce_ui_pulldown` passes that straight to
|
||||
# `create_pulldown_button`.
|
||||
title: Config
|
||||
|
||||
# Row order, top to bottom. Rows are text beside a 16px icon and the
|
||||
# dropdown is as wide as it needs to be, so these titles are free to say
|
||||
# what they mean -- unlike the four large buttons on Tools, which are
|
||||
# rationed to ~9 characters a line.
|
||||
#
|
||||
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
|
||||
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
|
||||
# 2026-09-02, at the owner's request, they are parked again: pulled out of
|
||||
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
|
||||
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
|
||||
# the two lines -- the owner will decide later how to handle them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ProjectConfig
|
||||
- ExtractDesign
|
||||
- SelfCheck
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Setup").
|
||||
#
|
||||
# One pulldown, on purpose. These are the commands you run to get a job
|
||||
# ready or to look at it sideways -- not the four you press all day. They
|
||||
# were taking two stack slots and a whole panel between them; behind one
|
||||
# arrow they take none.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Config
|
||||
@@ -0,0 +1,24 @@
|
||||
title: "Panel\nLift"
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to `liftexport` -- the Revit half of the
|
||||
# panellift boundary -- is visible without a Revit restart. It trades press
|
||||
# latency (engine construction instead of engine reuse) for restart-freedom.
|
||||
# The CPython half already needs no restart; this is for the half that does.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,910 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Handling check for one assembly: stripping, transportation, erection.
|
||||
#
|
||||
# What the selection says decides how this runs. One assembly preselected --
|
||||
# or any member of one -- is checked straight away. Several preselected run
|
||||
# as a background batch on exactly those pieces. Nothing preselected asks:
|
||||
# one piece, or a batch across the assemblies the job's DECLARED SCOPE says
|
||||
# are this engineer's (`vibe.scope`). The batch list is the TOOL choosing, so
|
||||
# it is scoped and says what it left off; a preselected batch is the USER
|
||||
# choosing, so it is never filtered.
|
||||
#
|
||||
# In the single-panel flow the piece is measured out of the model, you pick the
|
||||
# inserts that carry each stage by clicking them ON the piece, you say where the
|
||||
# report goes, and the calculation runs on CPython -- see vibe.liftexport for why
|
||||
# it is not in here.
|
||||
#
|
||||
# The only thing it writes to the model is a temporary isolate of the piece, in
|
||||
# whatever view you are already in, so that you are clicking inserts on the piece
|
||||
# rather than hunting them in a view of the whole building. It writes that twice:
|
||||
# on, and off again. Nothing else is changed -- including the inserts this report
|
||||
# recommends moving.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Panel\nLift'
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import (AssemblyInstance, ElementId,
|
||||
OverrideGraphicSettings, Reference,
|
||||
TemporaryViewMode)
|
||||
from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType
|
||||
from Autodesk.Revit.Exceptions import OperationCanceledException
|
||||
from System.Collections.Generic import List
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import (batchrun, configmodel, knowledge, liftexport, ticketqc,
|
||||
designspec, tx)
|
||||
from vibe import scope as scopelib
|
||||
from vibe.compat import as_text
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
|
||||
def die(message, title='Panel Lift Check'):
|
||||
"""Usage error, not a calculation refusal (Claude 08-28).
|
||||
|
||||
die() is ONLY for 'the user has not given us something yet' -- no
|
||||
assembly selected, nothing isolated, nothing in scope to offer. A
|
||||
refusal to CALCULATE (missing prestress / weight gate / no stages /
|
||||
no Python / no concrete strength) must go through not_run(), which
|
||||
leaves a row in the report saying what was not done and why. The
|
||||
report must survive the dialog.
|
||||
"""
|
||||
forms.alert(message, title=title, exitscript=True)
|
||||
|
||||
|
||||
def not_run(message, title='Panel Lift Check'):
|
||||
"""'Could not be calculated' is a RESULT, not a failure of the piece
|
||||
(Claude 08-28: exit code non-zero != the panel failed). Print a
|
||||
NOT RUN row with the reason that tells the user what to do next,
|
||||
then stop. The five-word discipline from Ticket QC applies here too:
|
||||
the tool must be able to say what it did not do.
|
||||
"""
|
||||
output.print_md('# Panel Lift Check — NOT RUN')
|
||||
output.print_md('**NOT RUN** — the check did not run for this piece.')
|
||||
output.print_md('> {0}'.format(message))
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- background batch engine
|
||||
from System.Threading import Thread, ThreadStart
|
||||
|
||||
|
||||
def _design_of(asm, groups):
|
||||
"""The design key an assembly belongs to, or None if it is in none."""
|
||||
for key, group in groups.items():
|
||||
for member in group:
|
||||
try:
|
||||
if member.Id == asm.Id:
|
||||
return key
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _run_batch(preselected=None):
|
||||
"""Run the full check on many panels in the background.
|
||||
|
||||
Called with no argument, the user picks from the assemblies inside the
|
||||
declared scope, and the page says how many were left off and of what
|
||||
kind. Given a selection -- several assemblies highlighted before the
|
||||
button was pressed -- those ARE the batch, whatever their marks: nobody
|
||||
is asked which pieces they meant and nothing is dropped.
|
||||
"""
|
||||
know = knowledge.load(doc)
|
||||
_summaries, sheets = ticketqc.collect_context(doc)
|
||||
groups = ticketqc.assemblies_by_design(doc, know) or {}
|
||||
|
||||
if preselected:
|
||||
seen = set()
|
||||
items = []
|
||||
for asm in preselected:
|
||||
try:
|
||||
asm_key = asm.Id.IntegerValue
|
||||
except Exception:
|
||||
asm_key = None
|
||||
if asm_key is None:
|
||||
try:
|
||||
asm_key = asm.Id.Value
|
||||
except Exception:
|
||||
continue
|
||||
if asm_key in seen:
|
||||
continue
|
||||
seen.add(asm_key)
|
||||
items.append([str(asm.Name), asm, _design_of(asm, groups)])
|
||||
items.sort(key=lambda r: (str(r[2]), r[0]))
|
||||
else:
|
||||
# Nobody preselected anything, so the TOOL is choosing what to offer
|
||||
# -- and it offers what the job's DECLARED SCOPE says is this
|
||||
# engineer's. What it left off the list is printed, by kind, rather
|
||||
# than quietly missing. A PRESELECTED batch (the branch above) is
|
||||
# never filtered: the selection is the request.
|
||||
the_scope = scopelib.of(know)
|
||||
items, left_alone = [], []
|
||||
for design, asm_list in groups.items():
|
||||
for asm in asm_list:
|
||||
name = str(asm.Name)
|
||||
(items if the_scope.includes(name)
|
||||
else left_alone).append([name, asm, design])
|
||||
items.sort(key=lambda r: (r[2], r[0]))
|
||||
scope_line = the_scope.line([r[0] for r in left_alone],
|
||||
total=len(items) + len(left_alone),
|
||||
unit='piece')
|
||||
if not items:
|
||||
die('There is nothing to offer for a batch.\n\n{0}'
|
||||
.format(scope_line))
|
||||
|
||||
names = [r[0] for r in items]
|
||||
output.print_md('> ' + scope_line)
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True,
|
||||
title='Batch - choose pieces (Ctrl/Shift to multi-select)')
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
items = [r for r in items if r[0] in picked_set]
|
||||
if not items:
|
||||
script.exit()
|
||||
|
||||
chosen = items
|
||||
|
||||
start_dir = knowledge.get(know, 'handling.report_dir') or \
|
||||
designspec.sub_dir(doc, 'handling')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
python_exe = liftexport.find_python(know)
|
||||
if not python_exe:
|
||||
not_run('CPython 3.9 or newer was not found.\n\nSet '
|
||||
'handling.python_exe in the knowledge file to the full '
|
||||
'path of python.exe.')
|
||||
|
||||
compute = []
|
||||
total = len(chosen)
|
||||
with forms.ProgressBar(title='Batch: measuring {0} panels...'.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, (name, asm, design) in enumerate(chosen):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
job, problems = liftexport.export(doc, asm, know, sheets,
|
||||
design, batch=True)
|
||||
if job is None:
|
||||
continue
|
||||
stages, _rows, stage_problems = liftexport.default_stages(job, know)
|
||||
problems = list(problems or []) + list(stage_problems or [])
|
||||
missing = [s['name'] for s in stages
|
||||
if s.get('enabled', True) and not s.get('fc_psi')]
|
||||
if problems or missing:
|
||||
continue
|
||||
job['stages'] = stages
|
||||
job_path = os.path.join(start_dir or '',
|
||||
'{0}.job.json'.format(name))
|
||||
blob = json.dumps(liftexport.strip_private(job), indent=1,
|
||||
sort_keys=True)
|
||||
with io.open(job_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(blob))
|
||||
compute.append([name, job_path])
|
||||
pb.update_progress(len(chosen), total)
|
||||
|
||||
if not compute:
|
||||
not_run('Nothing was measured for batch (all refused or '
|
||||
'cancelled).')
|
||||
|
||||
status_path = os.path.join(start_dir or '', 'batch-status.txt')
|
||||
|
||||
# The thread that runs the batch outlives this command. pyRevit tears
|
||||
# the command module's scope down when the command returns, so a thread
|
||||
# that looks ANY module global up from that scope -- `io`, `os`,
|
||||
# `liftexport` -- raises UnboundNameException, on a NON-MAIN thread,
|
||||
# which Revit cannot catch: the whole process dies (measured
|
||||
# 2026-08-27). The worker therefore lives in vibe.batchrun, whose module
|
||||
# scope is the session's, not the command's, and every name it needs is
|
||||
# bound as a default parameter at definition time. `run_one` does the
|
||||
# same for the two names that are this file's to pass on.
|
||||
#
|
||||
# recommend=False, and this is the whole reason a batch is usable.
|
||||
#
|
||||
# The position search is 93% of a run -- 12.6 s of a 13.6 s median piece,
|
||||
# 49 s on the worst. Measured through this exact path on the 184-piece
|
||||
# reference sweep: 38 minutes with the search on, 2.5 minutes without,
|
||||
# and about half a minute without it and eight at a time. The verdicts
|
||||
# are untouched -- 111 OK / 59 WARN / 13 FAIL / 1 refused either way --
|
||||
# because every stage is still analysed and every check still runs. A
|
||||
# batch is a triage pass: WHICH pieces need looking at. "Then where?" is
|
||||
# a question about ONE piece, and the single-piece run answers it in
|
||||
# thirteen seconds.
|
||||
#
|
||||
# The reports say so on the page. A batch of reports whose Recommended
|
||||
# positions section had quietly vanished would read, 184 times over, as
|
||||
# 184 pieces with nothing to recommend.
|
||||
def run_one(name, job_path, out_path, lx=liftexport, px=python_exe):
|
||||
lx.run(px, job_path, out_path, recommend=False)
|
||||
|
||||
# Several at once. Each job is already an isolated CPython subprocess
|
||||
# writing its own files, so this is a scheduling change, not a numerical
|
||||
# one -- and two cores are left alone so Revit stays usable while it runs.
|
||||
at_once = batchrun.worker_count()
|
||||
worker = batchrun.make_worker(compute, status_path, run_one,
|
||||
workers=at_once)
|
||||
|
||||
Thread(ThreadStart(worker)).Start()
|
||||
|
||||
output.print_md('**Batch started in the background.**')
|
||||
# The scope, on the page, every time. A batch of the pieces this engineer
|
||||
# owns and a batch of everything in the model look identical once they
|
||||
# are running.
|
||||
_scope = scopelib.of(know)
|
||||
_outside = [name for name, _asm, _d in chosen if not _scope.includes(name)]
|
||||
output.print_md('> ' + _scope.line([], unit='piece'))
|
||||
if _outside:
|
||||
output.print_md('> ' + (_scope.annotation(_outside, unit='piece') or ''))
|
||||
output.print_md('Panels were measured on the UI thread; each report is '
|
||||
'now computed in the background ({0} CPython subprocesses '
|
||||
'at a time, two cores left free), so Revit stays usable.'
|
||||
.format(at_once))
|
||||
# Said here as well as in every report. Somebody who starts a batch and
|
||||
# then opens one of its reports looking for the move list has to find out
|
||||
# why it is not there, and the place they are looking at the moment they
|
||||
# wonder is this page.
|
||||
output.print_md('> **Insert positions are not searched in a batch.** That '
|
||||
'search is 93% of a run — it is what made a sweep of '
|
||||
'this model take 38 minutes instead of half a minute. '
|
||||
'Every stage is still '
|
||||
'analysed and every check still runs, so the verdicts are '
|
||||
'the whole answer to *which pieces need looking at*. Run a '
|
||||
'piece on its own to be told **where** its inserts should '
|
||||
'go; each report says so where the positions would be.')
|
||||
output.print_md('Reports + job files go to:\n\n `{0}`'.format(start_dir))
|
||||
output.print_md('Progress is written to:\n\n `{0}`'.format(status_path))
|
||||
script.close()
|
||||
|
||||
|
||||
# -------------------------------------------------- mode: batch / assembly
|
||||
# The selection decides. One assembly preselected (any member of one
|
||||
# counts) runs straight through as the single-panel check. Several
|
||||
# preselected run as a background batch on exactly what is highlighted.
|
||||
# Nothing preselected asks.
|
||||
#
|
||||
# Deliberately off the preselection rather than a list: the piece is the
|
||||
# subject of the whole run, and picking it from several hundred assemblies
|
||||
# is worse than clicking it in the model.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
assemblies = owners
|
||||
|
||||
if len(assemblies) > 1:
|
||||
_run_batch(assemblies)
|
||||
script.exit()
|
||||
|
||||
if not assemblies:
|
||||
_modes = ['Batch (many pieces)', 'Assembly (one piece)']
|
||||
_mode = forms.SelectFromList.show(_modes, multiselect=False,
|
||||
title='Panel Lift Check')
|
||||
if not _mode:
|
||||
script.exit()
|
||||
if _mode.startswith('Batch'):
|
||||
_run_batch()
|
||||
script.exit()
|
||||
die('Select the assembly you want checked, then run this again.\n\n'
|
||||
'Selecting any member of it works too.')
|
||||
|
||||
asm = assemblies[0]
|
||||
|
||||
# ------------------------------------------------------------------ read it
|
||||
|
||||
know = knowledge.load(doc)
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
not_run(problem)
|
||||
if not (knowledge.get(know, 'handling.lifter_groups', {}) or {}):
|
||||
not_run('No handling.lifter_groups in the knowledge files.\n\n'
|
||||
'The check needs to know which family is a face lifter and '
|
||||
'which is an edge anchor. Add them to\n\n{0}\n\nand run again.'
|
||||
.format(designspec.plant_knowledge_path()))
|
||||
|
||||
with forms.ProgressBar(title='Measuring {0}...'.format(asm.Name)) as pb:
|
||||
pb.update_progress(0, 3)
|
||||
_summaries, sheets = ticketqc.collect_context(doc)
|
||||
design = None
|
||||
for key, group in ticketqc.assemblies_by_design(doc, know).items():
|
||||
if any(a.Id == asm.Id for a in group):
|
||||
design = key
|
||||
break
|
||||
pb.update_progress(1, 3)
|
||||
job, problems = liftexport.export(doc, asm, know, sheets, design)
|
||||
pb.update_progress(3, 3)
|
||||
|
||||
if job is None:
|
||||
not_run('\n\n'.join(problems) +
|
||||
'\n\nFix the cause above and run again.')
|
||||
|
||||
panel = job['panel']
|
||||
|
||||
# Before anything is calculated: does the geometry we read weigh what the
|
||||
# model says it does? A blockout read wrong is silent, and it makes the
|
||||
# section modulus wrong in the direction that says a panel is fine.
|
||||
ok, message, _numbers = liftexport.weight_check(job, know)
|
||||
if not ok:
|
||||
not_run(message + '\n\nFix the weight mismatch and run again.',
|
||||
title='Panel Lift Check - {0}'.format(panel['mark']))
|
||||
|
||||
stages, _rows, stage_problems = liftexport.default_stages(job, know)
|
||||
if not stages:
|
||||
not_run('No handling.stages in the knowledge files, so there is nothing to '
|
||||
'check the piece through.')
|
||||
if not [s for s in stages if s.get('enabled', True)]:
|
||||
# Some stages are configured and every one of them is switched off. That
|
||||
# is a different fact from "no stages", and it belongs here rather than as
|
||||
# a JobError out of the calculation, where it would arrive as a stderr
|
||||
# line in a dialog about a job file the engineer never opened.
|
||||
not_run('Every handling stage is switched off in the configuration '
|
||||
'({0}), so there is nothing to check. Enable at least one stage '
|
||||
'in Project Config.'
|
||||
.format(', '.join(s['name'] for s in stages)))
|
||||
if stage_problems:
|
||||
not_run('\n\n'.join(stage_problems),
|
||||
title='Panel Lift Check - plant dunnage setting')
|
||||
|
||||
# The audit used to refuse the run. It was guarding an automatic pick nobody
|
||||
# could see; now every insert is clicked on the piece, so a bad default costs
|
||||
# a few clicks rather than a wrong answer. It is still worth saying out loud,
|
||||
# because it means the highlighted defaults are not to be trusted here.
|
||||
audit = liftexport.stage_audit(stages)
|
||||
|
||||
# --------------------------------------------------------- confirm the picks
|
||||
# The inserts for each stage are picked IN THE MODEL, one stage at a time.
|
||||
#
|
||||
# This used to be a checklist of rows. It was wrong twice over: a row is the
|
||||
# tool's guess about which inserts share a hook, and the label named one family
|
||||
# when the row held two -- which is how one edge anchor and two face lifters an
|
||||
# inch apart read as three edge anchors, and a whole support line went missing.
|
||||
#
|
||||
# Nobody can audit a checkbox that says 5'-8". Everybody can audit three
|
||||
# inserts lit up on the piece in front of them. The knowledge rules still
|
||||
# choose the defaults, so the usual answer is still one click; they just no
|
||||
# longer get the last word, and they no longer get it silently.
|
||||
|
||||
by_mark = dict((l['mark'], l) for l in panel['lifters'])
|
||||
insert_ids = set(int(m) for m in by_mark)
|
||||
|
||||
|
||||
class OnlyTheseInserts(ISelectionFilter):
|
||||
"""Only the lifting inserts of this piece can be clicked.
|
||||
|
||||
Without it the panel itself, its rebar and its neighbours are all pickable,
|
||||
and a stray click becomes a support line the piece does not have.
|
||||
"""
|
||||
|
||||
def AllowElement(self, element):
|
||||
try:
|
||||
return ticketqc.eid(element) in insert_ids
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def AllowReference(self, reference, point):
|
||||
return False
|
||||
|
||||
|
||||
pick_filter = OnlyTheseInserts()
|
||||
|
||||
|
||||
def elements_of(marks):
|
||||
found = []
|
||||
for mark in marks:
|
||||
try:
|
||||
el = doc.GetElement(ElementId(int(mark)))
|
||||
except Exception:
|
||||
el = None
|
||||
if el is not None:
|
||||
found.append(el)
|
||||
return found
|
||||
|
||||
|
||||
def zoom_state():
|
||||
"""The active view's zoom, so it can be put back after the fit."""
|
||||
try:
|
||||
for uiview in uidoc.GetOpenUIViews():
|
||||
if uiview.ViewId == uidoc.ActiveView.Id:
|
||||
corners = uiview.GetZoomCorners()
|
||||
return (uiview, corners[0], corners[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def id_int(element_id):
|
||||
try:
|
||||
return element_id.IntegerValue
|
||||
except Exception:
|
||||
return element_id.Value
|
||||
|
||||
|
||||
def show_the_piece():
|
||||
"""Isolate the piece in the view the user is already in, and see into it.
|
||||
|
||||
Their view, not one this picks for them and not a new one: the view on
|
||||
screen is the view they chose to be in.
|
||||
|
||||
Isolating is not enough on its own. An insert is cast INSIDE the concrete,
|
||||
so in any shaded view the panel is standing in front of every one of them
|
||||
-- you can still click them, because the filter passes the click through
|
||||
to the insert, but the highlight that says which one you got is painted
|
||||
behind six inches of opaque concrete. The pick works and shows you
|
||||
nothing. So everything that is not an insert goes 85% transparent, which
|
||||
leaves the panel and its openings as an outline to place them against.
|
||||
|
||||
A view template can lock the display style -- wireframe is not available
|
||||
to us here -- but per-element overrides go through it, which is why this
|
||||
is done insert by insert rather than by changing how the view draws.
|
||||
|
||||
Those two things are all this button writes to the model, and it writes
|
||||
them twice: on, and off again. Everything else here reads.
|
||||
|
||||
Returns the callable that puts the view back.
|
||||
"""
|
||||
view = uidoc.ActiveView
|
||||
ids = List[ElementId]()
|
||||
ids.Add(asm.Id)
|
||||
others = []
|
||||
for member in asm.GetMemberIds():
|
||||
ids.Add(member)
|
||||
if id_int(member) not in insert_ids:
|
||||
others.append(member)
|
||||
|
||||
ghost = OverrideGraphicSettings()
|
||||
ghost.SetSurfaceTransparency(85)
|
||||
try:
|
||||
with tx('Show assembly'):
|
||||
view.IsolateElementsTemporary(ids)
|
||||
for member in others:
|
||||
try:
|
||||
view.SetElementOverrides(member, ghost)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
die('This view cannot isolate the piece, so there is nothing to pick '
|
||||
'on. Schedules and sheets have no geometry to hide.\n\nOpen a view '
|
||||
'that shows {0} and run the check again.'.format(asm.Name))
|
||||
|
||||
zoom = zoom_state()
|
||||
# ZoomToFit and not ShowElements: with everything else hidden the fit IS
|
||||
# the piece, and ShowElements can raise a modal dialog when it disagrees
|
||||
# about whether a view shows something.
|
||||
if zoom is not None:
|
||||
try:
|
||||
zoom[0].ZoomToFit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def restore():
|
||||
plain = OverrideGraphicSettings()
|
||||
try:
|
||||
with tx('Show assembly'):
|
||||
for member in others:
|
||||
try:
|
||||
view.SetElementOverrides(member, plain)
|
||||
except Exception:
|
||||
pass
|
||||
view.DisableTemporaryViewMode(
|
||||
TemporaryViewMode.TemporaryHideIsolate)
|
||||
except Exception:
|
||||
pass
|
||||
if zoom is not None:
|
||||
try:
|
||||
zoom[0].ZoomAndCenterRectangle(zoom[1], zoom[2])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return restore
|
||||
|
||||
|
||||
def pick_inserts(stage, preset):
|
||||
"""Click the inserts for one stage. None if the user pressed Esc."""
|
||||
pre = List[Reference]()
|
||||
ids = List[ElementId]()
|
||||
for el in elements_of(preset):
|
||||
try:
|
||||
pre.Add(Reference(el))
|
||||
ids.Add(el.Id)
|
||||
except Exception:
|
||||
pass
|
||||
# Said twice on purpose. PickObjects takes the pre-selected references and
|
||||
# returns them, but does not reliably paint them, and a default the user
|
||||
# cannot see is a default they cannot accept. Setting the selection first
|
||||
# puts them on the screen in the ordinary blue.
|
||||
try:
|
||||
uidoc.Selection.SetElementIds(ids)
|
||||
uidoc.RefreshActiveView()
|
||||
except Exception:
|
||||
pass
|
||||
prompt = ('{0} of {1}: select the inserts that carry the piece, then '
|
||||
'click Finish'.format(stage['name'].upper(), panel['mark']))
|
||||
try:
|
||||
try:
|
||||
refs = uidoc.Selection.PickObjects(
|
||||
ObjectType.Element, pick_filter, prompt, pre)
|
||||
except TypeError: # older binding: no pre-selected overload
|
||||
refs = uidoc.Selection.PickObjects(
|
||||
ObjectType.Element, pick_filter, prompt)
|
||||
except OperationCanceledException:
|
||||
return None
|
||||
picked = []
|
||||
for ref in refs:
|
||||
el = doc.GetElement(ref.ElementId)
|
||||
if el is None:
|
||||
continue
|
||||
mark = str(ticketqc.eid(el))
|
||||
if mark in by_mark and mark not in picked:
|
||||
picked.append(mark)
|
||||
return picked
|
||||
|
||||
|
||||
def tally(marks):
|
||||
"""Families counted, and the stations they stand at, in order."""
|
||||
counts = {}
|
||||
stations = []
|
||||
for mark in sorted(marks, key=lambda m: by_mark[m]['x']):
|
||||
lifter = by_mark[mark]
|
||||
counts[lifter['family']] = counts.get(lifter['family'], 0) + 1
|
||||
where = ticketqc.fmt_feet(lifter['x'])
|
||||
if where not in stations:
|
||||
stations.append(where)
|
||||
return (', '.join('{0} x{1}'.format(f, c)
|
||||
for f, c in sorted(counts.items())),
|
||||
', '.join(stations))
|
||||
|
||||
|
||||
put_it_back = show_the_piece()
|
||||
|
||||
support_note = ''
|
||||
supported = [s for s in stages
|
||||
if s.get('supports') and s.get('enabled', True)]
|
||||
if supported:
|
||||
support_note = ('\n\nNot picked, read from plant knowledge: ' +
|
||||
'; '.join(
|
||||
'{0} on dunnage at {1}'.format(
|
||||
s['name'],
|
||||
', '.join(ticketqc.fmt_feet(x)
|
||||
for x in s['supports']))
|
||||
for s in supported))
|
||||
|
||||
forms.alert(
|
||||
'You will be asked to select the inserts for each stage in turn, on the '
|
||||
'piece itself:\n\n {0}\n\nWhat the rules expect is highlighted '
|
||||
'already. Ctrl-click adds, Shift-click removes, then Finish on the '
|
||||
'options bar. Esc cancels the whole run.{1}{2}'
|
||||
.format(' -> '.join(
|
||||
s['name'] if s.get('enabled', True)
|
||||
else '{0} (not analysed)'.format(s['name']) for s in stages),
|
||||
'' if not audit else
|
||||
'\n\nThe defaults need a look on this piece:\n\n ' +
|
||||
'\n '.join(audit),
|
||||
support_note),
|
||||
title='{0} - inserts'.format(panel['mark']))
|
||||
|
||||
chosen = []
|
||||
try:
|
||||
for stage in stages:
|
||||
# A stage switched off in the plant config is not analysed, so there
|
||||
# is nothing to pick for it. It still travels into the job file, and
|
||||
# panellift still prints it -- as the sentence "not performed", in
|
||||
# its place among the stages that ran. Asking for inserts here would
|
||||
# be asking the engineer to configure a check nobody is running.
|
||||
if stage.get('enabled', True) is False:
|
||||
chosen.append(stage)
|
||||
continue
|
||||
if stage.get('supports'):
|
||||
chosen.append(stage)
|
||||
continue
|
||||
preset = list(stage['lifters'])
|
||||
while True:
|
||||
marks = pick_inserts(stage, preset)
|
||||
if marks is None:
|
||||
script.exit()
|
||||
if len(marks) < 2:
|
||||
forms.alert('{0} needs at least two inserts to be a pick, '
|
||||
'and {1} were selected.'
|
||||
.format(stage['name'], len(marks)),
|
||||
title='Panel Lift Check')
|
||||
preset = marks or preset
|
||||
continue
|
||||
if len(marks) < len(stage['lifters']):
|
||||
families, stations = tally(marks)
|
||||
# Fewer inserts than the rules expected is a real pick and
|
||||
# often the right one -- a crane that cannot reach the top
|
||||
# row, an insert cast over. It is worth one question because
|
||||
# a support line dropped by accident is invisible in the
|
||||
# result: the report is complete, the diagrams are drawn, and
|
||||
# the piece was checked on a pick it does not have.
|
||||
if not forms.alert(
|
||||
'{0}: {1} inserts selected where the rules expected '
|
||||
'{2}.\n\n {3}\n at {4}\n\nUse these?'
|
||||
.format(stage['name'], len(marks),
|
||||
len(stage['lifters']), families, stations),
|
||||
title='{0} - {1}'.format(panel['mark'],
|
||||
stage['name']),
|
||||
ok=False, yes=True, no=True):
|
||||
preset = marks
|
||||
continue
|
||||
stage['lifters'] = marks
|
||||
chosen.append(stage)
|
||||
break
|
||||
finally:
|
||||
# Esc out of a pick raises through here. Leaving a temporary isolate
|
||||
# switched on in somebody's view because they changed their mind is not
|
||||
# something they should have to work out how to undo.
|
||||
if put_it_back is not None:
|
||||
put_it_back()
|
||||
|
||||
job['stages'] = chosen
|
||||
|
||||
missing = [s['name'] for s in chosen
|
||||
if s.get('enabled', True) and not s.get('fc_psi')]
|
||||
if missing:
|
||||
not_run('No concrete strength for: {0}.\n\nThe design summary for '
|
||||
'{1} did not give an f\'c or f\'ci that these stages could '
|
||||
'use.'.format(', '.join(missing), design or 'this piece'))
|
||||
|
||||
# ------------------------------------------------------------ where it goes
|
||||
|
||||
start_dir = knowledge.get(know, 'handling.report_dir') or \
|
||||
designspec.sub_dir(doc, 'handling')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
|
||||
out_path = forms.save_file(
|
||||
file_ext='html', init_dir=start_dir or '',
|
||||
default_name='{0}.html'.format(panel['mark']),
|
||||
title='Save the handling report as')
|
||||
if not out_path:
|
||||
script.exit()
|
||||
|
||||
# ------------------------------------------------------------------- run it
|
||||
|
||||
python_exe = liftexport.find_python(know)
|
||||
if not python_exe:
|
||||
not_run('CPython 3.9 or newer was not found.\n\n'
|
||||
'The calculation runs outside Revit so it can be tested '
|
||||
'without one. Install Python, or set handling.python_exe in '
|
||||
'the knowledge file to the full path of python.exe.')
|
||||
|
||||
# The job file is kept beside the report on purpose. It is the whole input to
|
||||
# the calculation, so a number anybody disputes can be re-run, by hand, on a
|
||||
# machine with no Revit on it.
|
||||
job_path = os.path.splitext(out_path)[0] + '.job.json'
|
||||
blob = json.dumps(liftexport.strip_private(job), indent=1, sort_keys=True)
|
||||
with io.open(job_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(blob))
|
||||
|
||||
# Whether this run is willing to spend the 13 seconds. ON by default and ON
|
||||
# after any failure to read the file, exactly like expensive_geometry: an
|
||||
# unreadable options file must never quietly take half the report away.
|
||||
search_positions = True
|
||||
try:
|
||||
search_positions = bool(configmodel.load_run_options(
|
||||
designspec.root_dir(doc))['recommend_positions'])
|
||||
except Exception:
|
||||
search_positions = True
|
||||
|
||||
# The position search is the long part and it can genuinely run for minutes on
|
||||
# a piece with many movable rows. It runs on Revit's UI thread, so without a
|
||||
# progress bar being ticked the window is simply frozen, which nobody can tell
|
||||
# apart from a crash -- and a run that looks crashed gets killed.
|
||||
#
|
||||
# The title tells the truth about which of the two it is doing. A bar that says
|
||||
# "searching insert positions" on a run that is not searching them is the same
|
||||
# lie as a report section that quietly disappears.
|
||||
with forms.ProgressBar(title='{0}: {1}... {{value}}s'.format(
|
||||
panel['mark'],
|
||||
'searching insert positions' if search_positions
|
||||
else 'checking the piece (position search off)'),
|
||||
cancellable=True) as pb:
|
||||
cancelled = []
|
||||
|
||||
def tick(elapsed_ms):
|
||||
if pb.cancelled:
|
||||
cancelled.append(True)
|
||||
pb.update_progress(elapsed_ms // 1000, max(60, elapsed_ms // 1000))
|
||||
|
||||
pb.update_progress(0, 60)
|
||||
code, summary, stderr = liftexport.run(python_exe, job_path, out_path,
|
||||
on_tick=tick,
|
||||
recommend=search_positions)
|
||||
|
||||
if code == 2 or summary is None:
|
||||
not_run('The calculation could not run.\n\n{0}\n\n'
|
||||
'Job file kept at:\n{1}'.format(stderr or 'no error text',
|
||||
job_path))
|
||||
# exit 1 is a RESULT (a stage failed its check), not a crash -- it is
|
||||
# shown as FAIL in the table below, which is 'calculated and failed',
|
||||
# distinct from the NOT RUN above.
|
||||
|
||||
if knowledge.get(know, 'handling.open_report', True):
|
||||
liftexport.open_file(out_path)
|
||||
|
||||
# -------------------------------------------------------------------- report
|
||||
|
||||
output.print_md('# {0} — handling'.format(panel['mark']))
|
||||
# The scope, on the single-piece report too. This piece was SELECTED, so it
|
||||
# was never a candidate for being set aside -- but if it is outside the
|
||||
# declared scope the reader is told that the numbers rest on knowledge nobody
|
||||
# validated for that kind of piece.
|
||||
_scope = scopelib.of(know)
|
||||
output.print_md('> ' + _scope.line([], unit='piece'))
|
||||
if not _scope.includes(panel['mark']):
|
||||
output.print_md('> ' + (_scope.annotation([panel['mark']], unit='piece')
|
||||
or ''))
|
||||
output.print_md('Design **{0}** · {1} × {2} × {3}" '
|
||||
'· {4} lb'
|
||||
.format(panel.get('design_number') or '-',
|
||||
ticketqc.fmt_feet(panel['length_ft']),
|
||||
ticketqc.fmt_feet(panel['width_ft']),
|
||||
panel['thickness_in'],
|
||||
'{0:,.0f}'.format(panel['weight_lb'] or 0)))
|
||||
# A bare Windows path is not a URL: with backslashes and no scheme a
|
||||
# browser will not navigate to it, so the link rendered but did nothing.
|
||||
# The text stays the real path -- that is what the engineer copies -- and
|
||||
# only the href gets the file:/// form.
|
||||
_href = 'file:///' + out_path.replace('\\', '/')
|
||||
output.print_md('**{0}** [{1}]({2})'.format(summary['verdict'],
|
||||
out_path, _href))
|
||||
|
||||
# The verdict above is the answer for the stages that RAN. If one was
|
||||
# switched off, saying only "OK" here claims the piece, and a reader who
|
||||
# takes the button's one bold word and closes the window has been told the
|
||||
# panel is fine when a whole handling condition was never looked at.
|
||||
_not_run = summary.get('not_run') or []
|
||||
if _not_run:
|
||||
output.print_md(
|
||||
'> **{0} of {1} stages were not analysed** — {2}. The verdict '
|
||||
'above covers the stages that were.'
|
||||
.format(len(_not_run), len(summary['stages']),
|
||||
'; '.join('{0}{1}'.format(
|
||||
s['name'], ' ({0})'.format(s['reason'])
|
||||
if s.get('reason') else '') for s in _not_run)))
|
||||
|
||||
# WHICH END IS UP, when the ruled mechanism did not answer it.
|
||||
#
|
||||
# `knowledge.up_end_from_origin` is the project's flagship declared-coverage
|
||||
# refusal, and `liftexport.erected_orientation` falls back to global elevation
|
||||
# when it declines. The fallback is ALLOWED -- global elevation is the sound
|
||||
# mechanism for a piece erected on end, which is the case the rule hands over
|
||||
# -- but it may never be silent, or it is a guess wearing the refusal's
|
||||
# credibility. The key is written only when the fallback answered, so this
|
||||
# block never fires on a piece the rule settled.
|
||||
_basis = summary.get('basis')
|
||||
if _basis:
|
||||
output.print_md(
|
||||
'> **Which end is up came from a fallback, not the rule.** Read from '
|
||||
'{0}. {1}<br>What it decides: {2}. Stripping and transportation are '
|
||||
'analysed flat and are unchanged by it.'
|
||||
.format(_basis.get('basis'), _basis.get('why') or '',
|
||||
_basis.get('affects') or 'the erected stage'))
|
||||
|
||||
for problem in problems:
|
||||
output.print_md('> {0}'.format(problem))
|
||||
|
||||
|
||||
def _stage_row(s):
|
||||
"""One row of the summary table. A stage that was not analysed gets
|
||||
words across the numeric columns, not dashes: a dash in a column of
|
||||
numbers is read as a number that came out small."""
|
||||
if not s.get('analysed', True):
|
||||
return [s['name'], '-', '-', 'not analysed', '-', '-', 'NOT RUN']
|
||||
return [s['name'], '{0:.2f}'.format(s['multiplier']),
|
||||
'{0:,.0f}'.format(s['fc_psi']),
|
||||
'-' if s['peak_tension_psi'] is None
|
||||
else '{0:,.0f}'.format(s['peak_tension_psi']),
|
||||
'{0:,.0f}'.format(s['allowable_psi']),
|
||||
'-' if s['ratio'] is None else '{0:.0%}'.format(s['ratio']),
|
||||
s['verdict']]
|
||||
|
||||
|
||||
output.print_table(
|
||||
[_stage_row(s) for s in summary['stages']],
|
||||
columns=['stage', 'mult', "f'c", 'peak psi', 'allowable', 'of allow',
|
||||
'result'])
|
||||
|
||||
# What was actually picked, in writing. The numbers above mean nothing without
|
||||
# it, and it is the one part of the run that came from a human.
|
||||
#
|
||||
# A stage carried on dunnage was not picked and has no 'lifters' key at all --
|
||||
# it is dropped rather than emptied, because panellift refuses a stage that
|
||||
# gives both a support line and an insert. So it gets its own row saying where
|
||||
# it sat, rather than an empty one implying nobody chose anything.
|
||||
output.print_md('## Inserts picked')
|
||||
rows = []
|
||||
for s in chosen:
|
||||
if s.get('enabled', True) is False:
|
||||
rows.append([s['name'], '-', 'not analysed - no pick was made', '-'])
|
||||
elif s.get('supports'):
|
||||
rows.append([s['name'], '-', 'on dunnage (plant knowledge)',
|
||||
', '.join(ticketqc.fmt_feet(x) for x in s['supports'])])
|
||||
else:
|
||||
marks = s.get('lifters') or []
|
||||
rows.append([s['name'], str(len(marks))] + list(tally(marks)))
|
||||
output.print_table(rows, columns=['stage', 'n', 'inserts', 'at'])
|
||||
|
||||
rec = summary.get('recommendation') or {}
|
||||
if rec.get('searched') is False:
|
||||
# `searched` is written by panellift ONLY when the search was skipped, so
|
||||
# `is False` is the whole test and a summary from a build that predates
|
||||
# the flag reads as "it ran" -- which it did. Printed under the heading it
|
||||
# would have filled, not dropped: a missing section reads as a piece with
|
||||
# nothing to recommend, and this piece was never asked.
|
||||
output.print_md('## Recommended insert positions')
|
||||
output.print_md(
|
||||
'**NOT COMPUTED** — the position search is switched off for '
|
||||
'this run (`recommend_positions` in Project Config → run-time '
|
||||
'options). Every stage above was checked on the inserts exactly as '
|
||||
'drawn and the verdict stands; what is missing is only where they '
|
||||
'should go instead. Switch it back on and run the piece again to get '
|
||||
'it.')
|
||||
elif rec.get('note') and not rec.get('moves'):
|
||||
output.print_md('> {0}'.format(rec['note']))
|
||||
elif rec.get('moves'):
|
||||
output.print_md('## Recommended insert positions')
|
||||
if rec.get('worth_moving'):
|
||||
output.print_md(
|
||||
'Moving them takes the governing stage from **{0:.0%}** of '
|
||||
'allowable to **{1:.0%}**.'.format(rec['as_built_ratio'],
|
||||
rec['recommended_ratio']))
|
||||
else:
|
||||
output.print_md('The inserts are already where the search would put '
|
||||
'them.')
|
||||
output.print_table(
|
||||
[[m['mark'], m['family'],
|
||||
ticketqc.fmt_feet(m['from_x_ft']), ticketqc.fmt_feet(m['from_y_ft']),
|
||||
ticketqc.fmt_feet(m['to_x_ft']), ticketqc.fmt_feet(m['to_y_ft']),
|
||||
'-' if abs(m['dx_in']) < 0.005 else '{0:+.2f}'.format(m['dx_in']),
|
||||
'-' if abs(m['dy_in']) < 0.005 else '{0:+.2f}'.format(m['dy_in']),
|
||||
'pinned' if m['pinned'] else ('move' if m['moved'] else '')]
|
||||
for m in rec['moves']],
|
||||
columns=['insert', 'family', 'x', 'y', 'to x', 'to y',
|
||||
'dx in', 'dy in', ''])
|
||||
for row in rec.get('rows') or []:
|
||||
if row.get('note'):
|
||||
output.print_md('- {0}: {1}'.format(
|
||||
ticketqc.fmt_feet(row['to_x_ft']), row['note']))
|
||||
output.print_md(
|
||||
'> The recommendation is one position per insert, solved for every '
|
||||
'stage at once. **x** is searched to minimise the governing '
|
||||
'utilisation; **y** is geometry only — a beam model has no '
|
||||
'transverse mechanics, so whether the piece hangs level is stated '
|
||||
'from the layout and not calculated.')
|
||||
|
||||
for stage in summary['stages']:
|
||||
bad = stage['findings']
|
||||
if not bad:
|
||||
continue
|
||||
output.print_md('### {0}'.format(stage['name']))
|
||||
for f in bad:
|
||||
output.print_md('- **{0}** `{1}` — {2}'
|
||||
.format(f['severity'], f['code'], f['message']))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('Report: `{0}` \nJob file: `{1}`'.format(out_path, job_path))
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Reinforce
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line. It is the only large button on this ribbon that does
|
||||
# not break, and it fits inside the width the two-line ones already take.
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,425 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reinforcing suggestion for one assembly: which already-reinforced panels,
|
||||
# between them, account for every feature of this one.
|
||||
#
|
||||
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
|
||||
# a similarity ranking. Every detail of the target has to be covered by
|
||||
# something, and the number of donors should be as small as possible. The
|
||||
# search lives in `vibe.reinforcematch`, the candidate index in
|
||||
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
|
||||
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
|
||||
# the Revit end and nothing more: it resolves the selection, decides how
|
||||
# current the index has to be, and prints.
|
||||
#
|
||||
# READ ONLY, AND THAT IS THE POINT
|
||||
# --------------------------------
|
||||
# No Transaction is opened anywhere in this file or anything it calls. Nothing
|
||||
# is written to the model -- not a parameter, not a rebar, not a copy of a
|
||||
# donor's cage. The tool proposes; a human decides and a human draws it. This
|
||||
# is the single most important constraint on the feature, because a suggestion
|
||||
# tool is exactly the thing somebody will ask to "just copy it in for me", and
|
||||
# the middle band of a wall panel's vertical reinforcing is explicitly PER
|
||||
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
|
||||
# automatically would look like an ordinary suggestion and would be the most
|
||||
# dangerous thing this feature could do.
|
||||
#
|
||||
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
|
||||
# ----------------------------------------------------
|
||||
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
|
||||
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
|
||||
# on which a button press deserves. Which one to spend is a UI policy question
|
||||
# (how many seconds may a click cost, and what does a modifier key mean), so it
|
||||
# belongs at the UI, in one readable place, not buried in the library where two
|
||||
# callers would end up disagreeing.
|
||||
#
|
||||
# The policy, with the measured numbers it rests on:
|
||||
#
|
||||
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
|
||||
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
|
||||
# moved. Nothing moved is the common case and then nothing is rebuilt and
|
||||
# nothing is rewritten: about a second. A full rebuild on every press is
|
||||
# 10 s of frozen UI to answer a question that usually has not changed.
|
||||
# * cold or empty index -- a full build regardless, ~10 s, with a progress
|
||||
# bar. There is no cheaper honest answer available.
|
||||
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
|
||||
#
|
||||
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
|
||||
# ---------------------------------------------------
|
||||
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
|
||||
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
|
||||
# only between donor sets the geometry cannot separate. Owner's ruling: fit
|
||||
# wins. The report marks it and says so.
|
||||
#
|
||||
# The staleness signature is `reinforced | on_hold | master | member-count |
|
||||
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
|
||||
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
|
||||
# catch a geometry edit that leaves the member set alone -- an opening moved or
|
||||
# resized in place, a panel stretched. Those pieces serve a stale measurement
|
||||
# until something else about them moves.
|
||||
#
|
||||
# So an ordinary press can be wrong in exactly that one way, and the report
|
||||
# says so every time, in `reinforcereport.staleness_note`, along with the
|
||||
# instruction to shift-click. The report also always prints when the index was
|
||||
# built, so a reader who knows he has been editing geometry can tell whether
|
||||
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
|
||||
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
|
||||
# inconvenience, not a last resort.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. One target per press. The engineer is reinforcing one piece; a batch of
|
||||
# suggestions is a report nobody reads. Several assemblies preselected asks
|
||||
# which one rather than fanning out.
|
||||
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
|
||||
# told, so it is in the footer of every report and in the staleness note
|
||||
# the report prints when the index may be out of date. It used to be in the
|
||||
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
|
||||
# the report is now the only place that says it. A dialog
|
||||
# on every press would be worse: the answer is the same 99 times out of 100.
|
||||
# 3. The outline audit runs automatically the first time only. It costs one
|
||||
# geometry read per indexed piece, and its answer changes only when the
|
||||
# model does. Never-audited and audited-and-found-none are different
|
||||
# answers, so the report says which one it has.
|
||||
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
|
||||
# four cannot account for everything, the report prints the best four and
|
||||
# names what they miss -- it does not relax to a fifth. That overflow rule
|
||||
# is INFERRED from the ruling, not stated by the owner, and is the thing to
|
||||
# overrule first if this behaves wrongly on a real piece.
|
||||
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
|
||||
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
|
||||
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
|
||||
# 6. A target that is itself already reinforced, or on hold, is answered
|
||||
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
|
||||
# rules -- but silently treating an already-reinforced piece as a fresh
|
||||
# request would hide a misclick.
|
||||
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
|
||||
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
|
||||
# count as a problem is `reinforcereport`'s decision and is stated there,
|
||||
# section by section; the one to argue with first is the staleness rule,
|
||||
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
|
||||
# a folded header names what it holds, and the whole report is written
|
||||
# unfolded to `last-report.txt` beside the index on every press.
|
||||
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
|
||||
# "not judged" and an exit. It used to lose the index stamp and the
|
||||
# staleness note on the way out, which are exactly what a reader needs in
|
||||
# order to know whether to believe a refusal.
|
||||
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
|
||||
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
|
||||
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
|
||||
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
|
||||
# before a single comparison is made, and when no same-design donor exists
|
||||
# this button REFUSES -- it names the design, counts what the pool actually
|
||||
# held, and says the way out. It never falls back to the nearest other
|
||||
# design, not even labelled as one. Packet suffixes do not split a design:
|
||||
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
|
||||
# `specformat.design_key` reduction Ticket QC already uses.
|
||||
# A design number nobody could read is UNJUDGED, never a match -- an index
|
||||
# whose design numbers are all empty (which is what a build with no
|
||||
# knowledge layer produces) refuses and asks for a rebuild.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Reinforce'
|
||||
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
|
||||
# the force-full-rebuild gesture. See the notes at the top of this file.
|
||||
__shiftclick__ = True
|
||||
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import compat
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import scope as scopelib
|
||||
from vibe import reinforcebind as bind
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import reinforcereport as R
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = R.TITLE
|
||||
|
||||
|
||||
def die(message):
|
||||
"""A usage error: the user has not given us something yet. Not a refusal
|
||||
to judge -- those go in the report, where they survive the dialog."""
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
# `not_judged` used to live here: a short page and an exit, for a target that
|
||||
# could not be measured. It is gone, and deliberately -- 'could not judge' is
|
||||
# a RESULT, and printing it on a stripped-down page dropped the index stamp
|
||||
# and the staleness note, which are precisely what tells a reader whether to
|
||||
# believe the refusal. `reinforcereport.render` now returns the whole page for
|
||||
# that case too, with the headline saying NOT JUDGED, and this file prints it
|
||||
# the same way it prints an answer.
|
||||
|
||||
|
||||
# ------------------------------------------------------------ force rebuild
|
||||
|
||||
def _forced():
|
||||
"""True when the button was shift-clicked. Never fatal if unavailable."""
|
||||
try:
|
||||
from pyrevit import EXEC_PARAMS
|
||||
return bool(EXEC_PARAMS.config_mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
#
|
||||
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, nothing
|
||||
# picked offers a list. The one difference is that this tool answers about
|
||||
# ONE piece, so several preselected assemblies ask which.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not picked_assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
picked_assemblies = owners
|
||||
|
||||
if selection and not picked_assemblies:
|
||||
die('What is selected is not part of an assembly, so there is no piece '
|
||||
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
|
||||
'any member of it -- and run this again.')
|
||||
|
||||
all_assemblies = index.assemblies(doc)
|
||||
by_name = dict(all_assemblies)
|
||||
|
||||
# The knowledge file is read here rather than further down, because the pick
|
||||
# list below needs the DECLARED SCOPE before it can offer anything.
|
||||
try:
|
||||
know = knowledge.load(doc)
|
||||
except Exception as exc:
|
||||
know = None
|
||||
output.print_md('> The knowledge file could not be read ({0}). No scope '
|
||||
'is declared, so every assembly in the model is offered '
|
||||
'— and **design numbers will be blank**, which '
|
||||
'since 2026-09-01 means this run will REFUSE rather than '
|
||||
'answer: a donor has to carry the same design number as '
|
||||
'the target, and an unread design number is never assumed '
|
||||
'to match. Fix the knowledge file, or shift-click to '
|
||||
'rebuild the index once it reads.'.format(exc))
|
||||
|
||||
the_scope = scopelib.of(know) if know else scopelib.Scope()
|
||||
|
||||
if not picked_assemblies:
|
||||
# The list this button OFFERS is scoped: this is the TOOL choosing, and
|
||||
# it chooses inside the declared scope. What the user picks HIMSELF is
|
||||
# never filtered -- see the branches below and the report's annotation.
|
||||
names, left_alone = the_scope.split_names(
|
||||
sorted(n for n, _a in all_assemblies))
|
||||
if not names:
|
||||
die('There is nothing here to pick from.\n\n{0}'
|
||||
.format(the_scope.line(left_alone, total=len(all_assemblies),
|
||||
unit='assembly')))
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False, title=TITLE + ' - which piece',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
elif len(picked_assemblies) > 1:
|
||||
names = sorted(str(a.Name) for a in picked_assemblies)
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False,
|
||||
title=TITLE + ' - one piece at a time',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
else:
|
||||
target_name = str(picked_assemblies[0].Name)
|
||||
|
||||
target_asm = by_name.get(target_name)
|
||||
if target_asm is None:
|
||||
die('{0} is not an assembly in this model.'.format(target_name))
|
||||
|
||||
|
||||
def _flag(el, pname):
|
||||
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
|
||||
try:
|
||||
p = el.LookupParameter(pname)
|
||||
return None if p is None else int(p.AsInteger())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
target_reinforced = _flag(target_asm, index.P_REINFORCED)
|
||||
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
|
||||
|
||||
# ------------------------------------------------------------------ the index
|
||||
#
|
||||
# See the notes at the top of this file for the policy. The cheap plan is
|
||||
# computed here rather than inside `refresh` so that the commonest press --
|
||||
# nothing changed -- can skip `refresh` entirely and avoid a second
|
||||
# signature pass, and so the progress bar knows how many pieces it is about
|
||||
# to build.
|
||||
|
||||
forced = _forced()
|
||||
|
||||
# `know` was loaded above, for the pick list. Past the scope it is used for
|
||||
# one optional thing -- reading each piece's design number for the report --
|
||||
# so a missing or broken one costs a label, not the answer. It is not worth
|
||||
# stopping the run over, unlike in Ticket QC where every check depends on it.
|
||||
|
||||
cache = index.read(doc)
|
||||
if cache.get('_load_error'):
|
||||
output.print_md('> The index on disk could not be read ({0}); it is being '
|
||||
'rebuilt from the model. A cache is derived data, so this '
|
||||
'costs time and nothing else.'
|
||||
.format(cache['_load_error']))
|
||||
forced = True
|
||||
|
||||
live = index.live_signatures(doc, all_assemblies, know=know)
|
||||
the_plan = core.plan(cache, live)
|
||||
todo = len(the_plan['build'])
|
||||
refreshed = bool(todo or the_plan['drop']) or forced
|
||||
|
||||
refresh_report = None
|
||||
if refreshed:
|
||||
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
|
||||
bar = 'Indexing donors ({value} of {max_value})'
|
||||
if forced:
|
||||
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
|
||||
with forms.ProgressBar(title=bar, cancellable=False) as pb:
|
||||
state = {'n': 0}
|
||||
|
||||
def _tick(_name):
|
||||
state['n'] += 1
|
||||
pb.update_progress(state['n'], max(total, 1))
|
||||
|
||||
pb.update_progress(0, max(total, 1))
|
||||
cache, refresh_report = index.refresh(
|
||||
doc, rebuild_all=forced, know=know, on_piece=_tick)
|
||||
|
||||
# The outline audit: once, unless forced. It is what keeps an approximated
|
||||
# edge condition from ever being printed as a measured exact match.
|
||||
audit_needed = forced or (bind.load_audit(doc) is None)
|
||||
|
||||
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
|
||||
indeterminate=True):
|
||||
suggestion, info = bind.suggest_for_piece(
|
||||
doc, target_name, refresh=False, audit=audit_needed, know=know)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- report
|
||||
#
|
||||
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
|
||||
#
|
||||
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
|
||||
# 可以折叠的样子。"
|
||||
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
|
||||
# 给做成默认折叠。"
|
||||
#
|
||||
# So the target and the donor proposals are the page, printed big and first
|
||||
# through pyRevit's own markdown and tables -- which is also what can linkify
|
||||
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
|
||||
# section that has a problem in it opens itself. That turns the page into a
|
||||
# contract: if nothing below the answer is open, nothing needs him.
|
||||
#
|
||||
# The rule lives in `vibe.foldreport`, not here, and the sections come from
|
||||
# `reinforcereport.render` -- the same sections the offline CLI prints as
|
||||
# plain text, so the two cannot say different things.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
|
||||
# -----------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
|
||||
# hosted in WinForms, running in IE7 document mode unless the host process
|
||||
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
|
||||
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
|
||||
# element renders inline and the "collapsed" content is simply always visible.
|
||||
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
|
||||
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
|
||||
# but the toggle has NOT been exercised inside Revit, so it is designed for
|
||||
# failure rather than trusted:
|
||||
#
|
||||
# * a section with a problem in it renders OPEN, as plain HTML, and does not
|
||||
# need the script to run at all;
|
||||
# * the whole report, nothing folded, is written to `last-report.txt` beside
|
||||
# the index every press, and the page says where. A fold that will not
|
||||
# open therefore costs a file open, never a lost sentence.
|
||||
|
||||
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
|
||||
'last-report.txt')
|
||||
|
||||
|
||||
def _donor_link(_name, record):
|
||||
"""pyRevit's clickable element id, for a donor's line in the headline."""
|
||||
if not (record or {}).get('asm_id'):
|
||||
return None
|
||||
try:
|
||||
return output.linkify(ElementId(record['asm_id']), 'select assembly')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
best = suggestion.best if suggestion is not None else None
|
||||
rows = bind.explain(suggestion, info, best) if best is not None else []
|
||||
|
||||
sections = R.render(suggestion, info, rows, cache,
|
||||
model=doc.Title,
|
||||
refresh_report=refresh_report,
|
||||
rebuilt_all=forced, refreshed=refreshed,
|
||||
offline=False,
|
||||
reinforced=target_reinforced, on_hold=target_on_hold,
|
||||
full_path=full_path, donor_link=_donor_link)
|
||||
|
||||
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
|
||||
# even if the printing throws.
|
||||
try:
|
||||
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
|
||||
# string, and a piece mark or design number carrying a non-ASCII character
|
||||
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
payload = compat.as_text(fold.text(sections, show_all=True))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(exc))
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
if opened:
|
||||
output.print_md('---')
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('---')
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one. The whole report is also '
|
||||
'in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Snapshot
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
|
||||
# four large buttons on this panel all come out the same width.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,838 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
||||
#
|
||||
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
||||
# ------------------------------------
|
||||
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
||||
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
||||
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
||||
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
||||
#
|
||||
# So the whole cycle moved out of this button and into
|
||||
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
||||
# it, and can be started by Claude. What is left here is the one job that
|
||||
# genuinely requires the live session and cannot be done anywhere else:
|
||||
# handing over the model's IDENTITY. Only the open document can cheaply report
|
||||
# its cloud region and GUIDs, and without those nothing else can find the
|
||||
# model to copy.
|
||||
#
|
||||
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
||||
# ----------------------------------------------
|
||||
# The design this replaced expected an in-session background open --
|
||||
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
||||
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
||||
# does not exist for this model, and the reason is not a version guard:
|
||||
#
|
||||
# * a cloud model cannot be opened detached at all. Any
|
||||
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
||||
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
||||
# does not allow it and that SaveAs is the way; and the UI agrees --
|
||||
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
||||
# * Revit holds one Document per model per session, so the live session
|
||||
# cannot open the model it already has open.
|
||||
# * the only call that could copy the document already in memory is
|
||||
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
||||
# here it would leave HIS Revit sitting in a local copy, disconnected
|
||||
# from the cloud. There is no SaveCopyAs.
|
||||
#
|
||||
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
||||
# The copy is therefore made by a Revit that is not his, and this button's
|
||||
# phase C is a spawn rather than an open.
|
||||
#
|
||||
# THE OLD RITUAL, KEPT
|
||||
# ---------------------
|
||||
# `snapshot.after_close` is NOT retired. The close-model path below is
|
||||
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
||||
# day, where the answer really is "close everything first". It is simply no
|
||||
# longer the only way to get a copy, and it is no longer the default.
|
||||
#
|
||||
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
||||
# ------------------------------------------------
|
||||
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
||||
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
||||
# session and a detached sandbox ran side by side all morning. What nobody has
|
||||
# measured is three, so the pipeline sequences to keep it to two and this
|
||||
# button never opens a model at all.
|
||||
#
|
||||
# The close-model ritual, still here as one of the options:
|
||||
#
|
||||
# he syncs his act, outside this button. This button NEVER
|
||||
# calls SynchronizeWithCentral and never asks about it.
|
||||
# phase A this button indexes the OPEN document. The model is
|
||||
# already in RAM and already paid for; a snapshot read
|
||||
# from the live document at sync state and one read
|
||||
# from a detached copy of that same state contain the
|
||||
# same numbers. Seconds, not minutes, and no copy.
|
||||
# phase B Revit closes. The press IS the authorisation for the
|
||||
# close -- that is what the button is called.
|
||||
# phase C AFTER Revit has exited and the 10 GB is free, a
|
||||
# deferred host process opens the model detached, saves
|
||||
# a timestamped copy and quits. Optional, off unless
|
||||
# the job layer configures it, and its outcome lands in
|
||||
# a log this button reads out on the next press.
|
||||
#
|
||||
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
||||
# --------------------------------------------------------------------
|
||||
# No Transaction is opened in this file or in anything it calls. Nothing is
|
||||
# written to the model -- not a parameter, not a workset, not a save. The only
|
||||
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
||||
# file, in another process, after this one has exited. The sync that used to
|
||||
# live here has been removed entirely at the owner's instruction.
|
||||
#
|
||||
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
||||
# ----------------------------------------------------
|
||||
# Revit is closed LAST and only after phase A has reported complete. A crash
|
||||
# halfway through the index must leave the session open, with the traceback on
|
||||
# screen, because the alternative is a closed Revit and no way to find out
|
||||
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
||||
#
|
||||
# The other half of that discipline is phase C. It runs when nobody is
|
||||
# watching, in a process this one has already outlived, and a day it silently
|
||||
# did not run looks exactly like a day it worked. So the request is journalled
|
||||
# BEFORE the close and the deferred process journals its own outcome; a
|
||||
# request with nothing after it is reported, in capitals, at the top of the
|
||||
# next press. Silence is not an acceptable report.
|
||||
#
|
||||
# JUDGEMENT CALLS, for the owner to overrule
|
||||
# -------------------------------------------
|
||||
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
||||
# press elsewhere trusts the cheap signature, which cannot see an opening
|
||||
# moved or resized in place. This is the daily archival snapshot and it is
|
||||
# the one that gets trusted months later, so it pays the measured 6-10 s
|
||||
# for an answer that owes nothing to yesterday's cache.
|
||||
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
||||
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
||||
# lunchtime checkpoint). Cancel writes nothing at all.
|
||||
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
||||
# declared scope and no parameter-role mapping, so the index would measure
|
||||
# the whole model and mark every piece could-not-judge on the review
|
||||
# signal -- and would overwrite a good index to do it. That is worth a
|
||||
# dialog. It is not worth a refusal on a machine that legitimately has no
|
||||
# job layer yet.
|
||||
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
||||
# default and None means not configured, never "pick somewhere". The button
|
||||
# says so instead of guessing a folder for a 300 MB file.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Snapshot'
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Snapshot & Close'
|
||||
|
||||
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
||||
# because three separate places need it now (the deferred tool, the cycle
|
||||
# pipeline and the runner) and computing it three times is how two of them end
|
||||
# up one directory apart.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
||||
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
||||
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
||||
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
||||
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
||||
|
||||
CYCLE = 'Snapshot, then run the sandbox cycle'
|
||||
CLOSE = 'Snapshot, then close Revit'
|
||||
STAY = 'Snapshot only, stay open'
|
||||
CANCEL = 'Cancel'
|
||||
|
||||
|
||||
def die(message):
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
if doc is None:
|
||||
die('No model is open, so there is nothing to snapshot.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- versions
|
||||
|
||||
def _revit_version():
|
||||
try:
|
||||
app = doc.Application
|
||||
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pyrevit_version():
|
||||
try:
|
||||
from pyrevit import versionmgr
|
||||
return versionmgr.get_pyrevit_version().get_formatted()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _revit_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------ what already happened
|
||||
#
|
||||
# Read and printed FIRST, before anything is decided, because the one thing
|
||||
# this design can lose is a phase C that never ran -- and the only moment
|
||||
# anybody is looking is the moment they press this button again.
|
||||
|
||||
know = knowledge.load(doc)
|
||||
job_folder = None
|
||||
try:
|
||||
from vibe import designspec
|
||||
job_folder = designspec.root_dir(doc)
|
||||
except Exception:
|
||||
job_folder = None
|
||||
|
||||
log = S.log_path(job_folder) if job_folder else None
|
||||
history = S.read(log) if log else []
|
||||
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
for line in S.summary(history):
|
||||
if line.startswith('WARNING'):
|
||||
output.print_md('**{0}**'.format(line))
|
||||
else:
|
||||
output.print_md('- {0}'.format(line))
|
||||
|
||||
|
||||
# ---------------------------------------------------- is this job set up
|
||||
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
die('\n\n'.join(problems))
|
||||
|
||||
has_job_layer = False
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
has_job_layer = True
|
||||
|
||||
if not has_job_layer:
|
||||
keep_going = forms.alert(
|
||||
'This model has no job knowledge file.\n\n'
|
||||
'Without it there is no declared scope and no parameter-name '
|
||||
'mapping, so the index will measure every assembly in the model and '
|
||||
'will record "could not judge" for the review signal on all of them '
|
||||
'-- and it will overwrite whatever index is there now.\n\n'
|
||||
'Snapshot anyway?',
|
||||
title=TITLE, yes=True, no=True)
|
||||
if not keep_going:
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------- phase C settings
|
||||
#
|
||||
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
||||
# happen after the close, rather than promising a detached copy that turns out
|
||||
# not to be configured.
|
||||
|
||||
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
||||
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
||||
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
||||
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
||||
# TEXT, so two call sites spelling the same value differently are reported as
|
||||
# disagreeing about what "unset" means.
|
||||
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
||||
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
||||
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
||||
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
||||
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
||||
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
||||
or S.revit_exe_guess(revit_year)
|
||||
if not revit_year:
|
||||
# `pyrevit run` (both the cycle route's detach step and the close-model
|
||||
# route) takes an optional `--revit=<year>`, and a job layer that states
|
||||
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
||||
# job's shape -- used to leave it off entirely. On a machine with more
|
||||
# than one Revit version attached that is not a neutral omission: see
|
||||
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
||||
# is named for. `revit_exe`'s own path almost always carries the year.
|
||||
revit_year = S.revit_year_from_exe(revit_exe)
|
||||
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
||||
|
||||
|
||||
def _pyrevit_cli():
|
||||
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
||||
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
||||
return pyrevit_exe
|
||||
if pyrevit_exe:
|
||||
return None # stated and wrong: never fall back
|
||||
return S.pyrevit_exe_guess()
|
||||
|
||||
|
||||
def _copy_blocker():
|
||||
"""What stops ANY detached copy being made, whichever route. Or None."""
|
||||
if not detached_dir:
|
||||
return ('not configured -- set snapshot.detached_dir in the job '
|
||||
'knowledge file to switch it on')
|
||||
if not os.path.isdir(detached_dir):
|
||||
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
||||
if _pyrevit_cli() is None:
|
||||
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
||||
if liftexport.find_python(know) is None:
|
||||
return 'no host Python was found to run the deferred step'
|
||||
return None
|
||||
|
||||
|
||||
def phase_c_blocker():
|
||||
"""Why the OLD after-close route cannot run, or None.
|
||||
|
||||
`snapshot.after_close` is deliberately still consulted here and nowhere
|
||||
else: it gates the close-model ritual, which is the thing it has always
|
||||
meant. The cycle route below does not read it -- it is not chained to an
|
||||
exit, so "after close" has nothing to say about it, and making one key
|
||||
mean two things is how a setting becomes untrustworthy.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not after_close:
|
||||
return ('switched off -- snapshot.after_close is false in the job '
|
||||
'knowledge file')
|
||||
return None
|
||||
|
||||
|
||||
def cycle_blocker():
|
||||
"""Why the sandbox cycle cannot be started, or None.
|
||||
|
||||
Note what is NOT in here: `after_close`, and any check on other Revits.
|
||||
The cycle runs beside this session on purpose -- that is the whole point
|
||||
of it -- and it sequences its own steps so that at most two Revits are
|
||||
ever resident.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not revit_exe:
|
||||
return ('no Revit.exe to open the sandbox with -- set '
|
||||
'snapshot.revit_exe or snapshot.revit_year')
|
||||
if not os.path.isfile(revit_exe):
|
||||
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
||||
if not batch:
|
||||
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
||||
if not os.path.isfile(CYCLE_TOOL):
|
||||
return 'tools/snapshot_cycle.py is not where this button expects it'
|
||||
return None
|
||||
|
||||
|
||||
blocker = phase_c_blocker()
|
||||
cycle_stop = cycle_blocker()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the decision
|
||||
|
||||
if cycle_stop is None:
|
||||
cycle_tail = (
|
||||
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
||||
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
||||
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
||||
.format(detached_dir, ' + '.join(batch)))
|
||||
else:
|
||||
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
||||
|
||||
if blocker is None:
|
||||
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
||||
'copy is written to:\n{0}'.format(detached_dir))
|
||||
else:
|
||||
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
||||
|
||||
choice = forms.alert(
|
||||
'Sync first if you have not -- this button does not sync, and it never '
|
||||
'touches the model.\n\n'
|
||||
'It rebuilds the reinforcing index from the model as it stands now and '
|
||||
'stamps it with where it came from.' + cycle_tail + close_tail,
|
||||
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
||||
|
||||
if choice not in (CYCLE, CLOSE, STAY):
|
||||
output.print_md('---')
|
||||
output.print_md('Cancelled. Nothing was read, written or closed.')
|
||||
script.exit()
|
||||
|
||||
closing = (choice == CLOSE)
|
||||
cycling = (choice == CYCLE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase A
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase A - indexing the open model')
|
||||
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
||||
'an earlier press. Measured at 6-10 s on this model.')
|
||||
|
||||
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
||||
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
||||
# only loads the file, it never touches the model. Absent or damaged is fine:
|
||||
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
||||
try:
|
||||
old_cache = index.read(doc)
|
||||
except Exception:
|
||||
old_cache = None
|
||||
|
||||
report = None
|
||||
cache = None
|
||||
failure = None
|
||||
try:
|
||||
# An upper bound for the bar: a full rebuild measures at most every
|
||||
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
||||
# buys a progress bar that moves, instead of one pinned at 100%.
|
||||
total = max(len(index.assemblies(doc)), 1)
|
||||
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
||||
cancellable=False) as bar:
|
||||
state = {'n': 0}
|
||||
|
||||
def tick(_name):
|
||||
state['n'] += 1
|
||||
bar.update_progress(state['n'], total)
|
||||
|
||||
bar.update_progress(0, total)
|
||||
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
||||
on_piece=tick)
|
||||
except Exception:
|
||||
failure = traceback.format_exc()
|
||||
|
||||
if failure is not None:
|
||||
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
||||
output.print_md('## Phase A FAILED - Revit has not been closed')
|
||||
output.print_md('```\n{0}\n```'.format(failure))
|
||||
if log:
|
||||
S.append(log, {'event': 'phase-a-failed',
|
||||
'error': failure.splitlines()[-1]})
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- stamp it with where it came from
|
||||
#
|
||||
# `refresh` writes the index; this adds the block that says whose numbers
|
||||
# those are, and rewrites. It is written on EVERY press, including the press
|
||||
# where nothing changed and `refresh` therefore did not save -- an index whose
|
||||
# provenance is a week older than its numbers is worse than none.
|
||||
|
||||
source_path = None
|
||||
try:
|
||||
source_path = doc.PathName or None
|
||||
except Exception:
|
||||
source_path = None
|
||||
|
||||
prov = S.provenance(
|
||||
'A', S.LIVE_DOC,
|
||||
source_path=source_path,
|
||||
source_stamp=None, # a live document has no snapshot stamp
|
||||
source_title=index.doc_key(doc),
|
||||
revit=_revit_version(),
|
||||
pyrevit=_pyrevit_version(),
|
||||
job_folder=job_folder,
|
||||
note=('read from the open document immediately after the engineer synced; '
|
||||
'no detached copy was involved in these numbers'))
|
||||
|
||||
pieces = len(cache.get('pieces') or {})
|
||||
try:
|
||||
cache['provenance'] = prov
|
||||
core.save(report['path'], cache)
|
||||
stamped = True
|
||||
except Exception:
|
||||
stamped = False
|
||||
|
||||
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
||||
.format(report['assemblies'], pieces))
|
||||
output.print_md('- scope: {0}'.format(report['scope']))
|
||||
output.print_md('- built in {0} s, written to `{1}`'
|
||||
.format(report['seconds_total'], report['path']))
|
||||
if stamped:
|
||||
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
||||
else:
|
||||
output.print_md('- **the provenance stamp could not be written** -- this '
|
||||
'index cannot say where it came from')
|
||||
|
||||
missing = (report.get('coverage') or {}).get('missing') or []
|
||||
if missing:
|
||||
output.print_md('- **{0} pieces could not be measured** and are not in '
|
||||
'this snapshot'.format(len(missing)))
|
||||
|
||||
if log:
|
||||
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
||||
'path': report['path'], 'scope': report['scope'],
|
||||
'seconds': report['seconds_total'],
|
||||
'provenance': prov})
|
||||
|
||||
|
||||
# ------------------------------------------------- update statistics
|
||||
#
|
||||
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
||||
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
||||
# press changed: which pieces are newly donors, how many features each carries,
|
||||
# and which feature-IDs went into the shared vocabulary for the first time
|
||||
# versus which were already known. READ ONLY -- this only reads the two caches
|
||||
# and one cheap signature pass; nothing about the model is written.
|
||||
#
|
||||
# `index.refresh` already grew the registry (via `core.register_features`), so
|
||||
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
||||
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
||||
# runs, redone here so the diff sees the model exactly as it stands now.
|
||||
|
||||
output.print_md('## Update statistics -- what this snapshot marked')
|
||||
try:
|
||||
live = index.live_signatures(doc, know=know)
|
||||
stats = core.snapshot_stats(old_cache, cache, live)
|
||||
except Exception:
|
||||
stats = None
|
||||
output.print_md('- **the update statistics could not be computed** -- the '
|
||||
'index was still written; only this summary is missing')
|
||||
|
||||
if stats is not None:
|
||||
newly = stats['newly']
|
||||
if stats['first_snapshot']:
|
||||
output.print_md('- **first snapshot for this index** -- there was '
|
||||
'nothing to diff against, so every reinforced piece '
|
||||
'below is new to the donor pool, not a change since '
|
||||
'last press.')
|
||||
if not newly:
|
||||
output.print_md('- no piece was newly marked reinforced since the last '
|
||||
'snapshot. (This is "nothing changed", not "nothing '
|
||||
'is reinforced".)')
|
||||
else:
|
||||
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
||||
'{1}'.format(len(newly), ', '.join(newly)))
|
||||
output.print_md('- feature-IDs new to the vocabulary this press: '
|
||||
'**{0}**; already known: **{1}**'
|
||||
.format(len(stats['new_ids']),
|
||||
len(stats['known_ids'])))
|
||||
for panel in stats['panels']:
|
||||
if panel['feature_count'] is None:
|
||||
output.print_md(' - **{0}** -- could not be measured; no '
|
||||
'features recorded'.format(panel['name']))
|
||||
continue
|
||||
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
||||
if panel['new_ids']:
|
||||
bits.append('new IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
||||
if panel['known_ids']:
|
||||
bits.append('known IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
||||
if panel['unregistered']:
|
||||
bits.append('{0} unregistered'.format(panel['unregistered']))
|
||||
output.print_md(' - **{0}** -- {1}'.format(
|
||||
panel['name'], '; '.join(bits)))
|
||||
|
||||
if log:
|
||||
# Journal the same figures. Per-panel id lists are kept so the ledger
|
||||
# can be replayed without the model; `first_snapshot` distinguishes a
|
||||
# cold index from a genuine no-change press.
|
||||
S.append(log, {'event': 'phase-a-update-stats',
|
||||
'first_snapshot': stats['first_snapshot'],
|
||||
'newly': newly,
|
||||
'new_ids': stats['new_ids'],
|
||||
'known_ids': stats['known_ids'],
|
||||
'panels': stats['panels']})
|
||||
|
||||
|
||||
# ---------------------------------------------- handing the model over
|
||||
#
|
||||
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
||||
# HOW to find the model again (its identity). Built once, here, so the cycle
|
||||
# and the close-model ritual cannot drift into naming files differently.
|
||||
|
||||
|
||||
def _source_block():
|
||||
"""How another session is to find this model again.
|
||||
|
||||
Cloud GUIDs and file path both, when both are available: a cloud model is
|
||||
reopened from its GUIDs, a file-based one from its path, and which one a
|
||||
job is was never something to assume. A cloud model reports no usable
|
||||
local path, which is the ordinary case here, not an error.
|
||||
"""
|
||||
src = {'path': source_path, 'title': index.doc_key(doc)}
|
||||
try:
|
||||
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
||||
mp = doc.GetCloudModelPath()
|
||||
src['cloud'] = {
|
||||
'region': str(mp.Region),
|
||||
'project_guid': str(mp.GetProjectGUID()),
|
||||
'model_guid': str(mp.GetModelGUID()),
|
||||
}
|
||||
except Exception as exc:
|
||||
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
||||
# path is then the only handle and the runner says so if it is absent.
|
||||
src['cloud'] = None
|
||||
src['cloud_error'] = str(exc)
|
||||
return src
|
||||
|
||||
|
||||
def _write_request(run_id, plan, source, extra=None):
|
||||
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
||||
request = {
|
||||
'run': run_id,
|
||||
'base': plan['title'],
|
||||
'layout': layout,
|
||||
'worksets': worksets,
|
||||
'detached_dir': detached_dir,
|
||||
'plan': plan,
|
||||
'log': log,
|
||||
'job_folder': job_folder,
|
||||
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'requested_by_revit': _revit_version(),
|
||||
'source': source,
|
||||
}
|
||||
request.update(extra or {})
|
||||
req_dir = os.path.join(detached_dir, '_requests')
|
||||
req_path = os.path.join(req_dir, run_id + '.json')
|
||||
if not os.path.isdir(req_dir):
|
||||
os.makedirs(req_dir)
|
||||
import json
|
||||
handle = open(req_path, 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
# And the identity on its own, where the host pipeline looks for it.
|
||||
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
||||
# machine knows those except a session with the model open -- this one.
|
||||
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
||||
# later with no Revit anywhere and still find the model to copy. It is
|
||||
# identity, never state: it says which model, never anything about a copy.
|
||||
try:
|
||||
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
||||
try:
|
||||
cache.write(json.dumps(source, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
cache.close()
|
||||
except Exception:
|
||||
pass # a lost cache costs a flag, not a snapshot
|
||||
return req_path
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
"""Start a host process that outlives this one. Returns its pid.
|
||||
|
||||
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
||||
minutes with nobody watching, and a console window popping up over the
|
||||
engineer's Revit is the kind of thing that gets a tool switched off.
|
||||
"""
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = argv[0]
|
||||
psi.Arguments = S.quote(argv[1:])
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
psi.WorkingDirectory = REPO_ROOT
|
||||
return Process.Start(psi).Id
|
||||
|
||||
|
||||
def _job_knowledge_path():
|
||||
"""The job knowledge file, so the host pipeline reads the same layer.
|
||||
|
||||
Handed over as a path rather than as resolved values: the pipeline needs
|
||||
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
||||
block, and a button that copies six settings across is a button that
|
||||
forgets the seventh the day one is added.
|
||||
"""
|
||||
try:
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- the sandbox cycle
|
||||
#
|
||||
# The new route, and the one the commission is about. Nothing here opens,
|
||||
# saves or closes a model: it writes the handoff and starts a host process
|
||||
# that does all of it in Revits that are not this one.
|
||||
|
||||
run_id = None
|
||||
if cycling and cycle_stop is None:
|
||||
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
started_pid = None
|
||||
argv = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block(),
|
||||
{'requested_by': 'SnapshotClose button, '
|
||||
'cycle route'})
|
||||
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
||||
'--fresh-detach',
|
||||
'--request', req_path,
|
||||
'--detached-dir', detached_dir,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--revit-exe', revit_exe]
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
if log:
|
||||
argv.extend(['--log', log])
|
||||
job_knowledge = _job_knowledge_path()
|
||||
if job_knowledge:
|
||||
argv.extend(['--knowledge', job_knowledge])
|
||||
for command in batch:
|
||||
argv.extend(['--run', command])
|
||||
started_pid = _spawn(argv)
|
||||
except Exception:
|
||||
started_pid = None
|
||||
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - running now, beside this session')
|
||||
if started_pid:
|
||||
output.print_md(
|
||||
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
||||
'-- it is not closed, not saved, and not read again.'
|
||||
.format(run_id, started_pid))
|
||||
output.print_md('It writes:')
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('then opens that copy in a Revit of its own, runs '
|
||||
'`{0}`, and closes it again. Its log lands beside the '
|
||||
'copy and its outcome in `{1}`.'
|
||||
.format(' + '.join(batch), log))
|
||||
output.print_md('The command it is running:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'cycle', 'host_pid': started_pid})
|
||||
else:
|
||||
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'cycle', 'reason': cycle_stop})
|
||||
elif cycling:
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - not run')
|
||||
output.print_md('Not started: {0}'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
||||
'reason': cycle_stop})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase C
|
||||
#
|
||||
# The OLD route, kept: requested here, run later, by a process that outlives
|
||||
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
||||
# passes no arguments to the script it runs, so a file named by an environment
|
||||
# variable is the only handoff there is.
|
||||
#
|
||||
# This is the only place `snapshot.after_close` still means anything.
|
||||
|
||||
if closing and blocker is None:
|
||||
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
launched = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block())
|
||||
argv = [liftexport.find_python(know), DEFER_TOOL,
|
||||
'--request', req_path,
|
||||
'--runner', RUNNER,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--wait-pid', str(_revit_pid()),
|
||||
'--wait-minutes', str(wait_minutes),
|
||||
# The close-model ritual is the one case where waiting for the
|
||||
# whole machine to be free is still the right answer: nobody
|
||||
# is at the keyboard and nothing else is queued behind it.
|
||||
'--machine-free', 'require']
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
_spawn(argv)
|
||||
launched = argv
|
||||
except Exception:
|
||||
launched = None
|
||||
blocker = 'the deferred process could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - the detached copy, after Revit exits')
|
||||
if launched:
|
||||
output.print_md('Requested as `{0}`. It waits for this Revit '
|
||||
'(pid {1}) to exit -- and then for the machine to be '
|
||||
'free of Revit altogether -- then opens the model in a '
|
||||
'session of its own and writes:'
|
||||
.format(run_id, _revit_pid()))
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('The command it will run:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(
|
||||
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
||||
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
||||
'next time this button is pressed.'.format(log))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'after-close', 'wait_pid': _revit_pid()})
|
||||
else:
|
||||
output.print_md('**Not requested: {0}**'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'after-close', 'reason': blocker})
|
||||
elif closing:
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - not run')
|
||||
output.print_md('No detached copy: {0}'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
||||
'reason': blocker})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase B
|
||||
|
||||
output.print_md('---')
|
||||
if not closing:
|
||||
if cycling:
|
||||
output.print_md('Revit stays open. The index is written and stamped, '
|
||||
'and the cycle is running in processes of its own -- '
|
||||
'nothing here was closed and your model was never '
|
||||
'touched.')
|
||||
else:
|
||||
output.print_md('Revit stays open, as asked. The index is written and '
|
||||
'stamped; nothing was closed.')
|
||||
script.exit()
|
||||
|
||||
output.print_md('## Phase B - closing Revit')
|
||||
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
||||
'That question is yours to answer -- this button never syncs '
|
||||
'and never saves the model.')
|
||||
|
||||
posted = False
|
||||
# There is no PostableCommand for quitting, so the internal command id is
|
||||
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
||||
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
||||
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
||||
# helper is a version-dependent convenience and this is the one step of the
|
||||
# button that cannot be retried by pressing it again.
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
HOST_APP.post_command('ID_APP_EXIT')
|
||||
posted = True
|
||||
except Exception:
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
from Autodesk.Revit.UI import RevitCommandId
|
||||
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
||||
if cmd is not None:
|
||||
HOST_APP.uiapp.PostCommand(cmd)
|
||||
posted = True
|
||||
except Exception:
|
||||
posted = False
|
||||
|
||||
if not posted:
|
||||
output.print_md('**Revit would not accept the close command.** The '
|
||||
'snapshot is written and stamped; close Revit yourself.')
|
||||
@@ -0,0 +1,24 @@
|
||||
title: "Ticket\nQC"
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom, and it is the hottest button here --
|
||||
# 51 commits to its six modules in the seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,742 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check shop tickets against their design spec.
|
||||
#
|
||||
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
|
||||
# then compares every piece of the chosen design numbers against them.
|
||||
#
|
||||
# Every CHECK here is pure arithmetic - the same model always gives the same
|
||||
# answer, and every row of the checklist table is computed. One thing in the
|
||||
# report is not: with the run-time option `vision_escalation` on, the checks
|
||||
# code cannot settle (5.5) are escalated to a small model reading one rendered
|
||||
# view. That result is quarantined - its own list, its own codes, its own
|
||||
# section, and it can never set a checklist row. See vibe.ticketvision and
|
||||
# docs/ticket-checklist.md section 8bis.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction, including the image export.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Ticket\nQC'
|
||||
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import ticketqc as Q
|
||||
from vibe import configdump
|
||||
from vibe import designspec, knowledge
|
||||
from vibe import scope as scopelib
|
||||
from vibe import ticketchecks as TC
|
||||
from vibe import ticketvision as TV
|
||||
from vibe import ticketpage as TP
|
||||
from vibe import foldreport as fold
|
||||
from vibe import compat
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
# ------------------------------------------------------------------ context
|
||||
# The published checklist has to exist before anything else here is worth
|
||||
# doing: every row this button ever prints comes off it. On a knowledge-less
|
||||
# trial install (tools/make_dist.py ships no knowledge/catalogue.yaml) this
|
||||
# is the ordinary, expected state -- refuse loudly and say so, rather than
|
||||
# rendering a report with zero rows that would look exactly like a piece with
|
||||
# nothing wrong on it. See vibe.ticketpage's CHECKLIST_UNAVAILABLE_REASON.
|
||||
if TP.CHECKLIST_UNAVAILABLE_REASON:
|
||||
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title='Ticket QC',
|
||||
exitscript=True)
|
||||
|
||||
# The knowledge file has to come first: it holds the mapping from role to this
|
||||
# office's parameter names, and without it nothing can even be grouped.
|
||||
know = knowledge.load(doc)
|
||||
|
||||
# A project config that has gone missing means this run would use a
|
||||
# configuration nobody chose, and look entirely normal doing it.
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
forms.alert(problem, title='Ticket QC', exitscript=True)
|
||||
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
if missing:
|
||||
forms.alert(
|
||||
'These parameter roles are not mapped in knowledge.yaml, so the '
|
||||
'checks that need them cannot run:\n\n {0}\n\n'
|
||||
'Edit the "parameters:" section of\n{1}\n\n'
|
||||
'Run Self Check for the full picture.'
|
||||
.format('\n '.join(missing), designspec.knowledge_path(doc)),
|
||||
title='Ticket QC', exitscript=('panel.design_number' in missing))
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
# The selection decides, exactly as Panel Lift Check does: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, and
|
||||
# nothing picked means a checklist of every assembly. The one deliberate
|
||||
# difference from Panel Lift Check: there is no "batch or single" question
|
||||
# here -- Ticket QC is always a batch of one or more pieces.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
assemblies = owners
|
||||
|
||||
specs = designspec.list_specs(doc) if doc.PathName else {}
|
||||
summaries, _sheets = Q.collect_context(doc)
|
||||
grouped = Q.assemblies_by_design(doc, know=know)
|
||||
|
||||
# ------------------------------------------------------------- the scope
|
||||
#
|
||||
# The DECLARED SCOPE, off the job knowledge. Two different things are done
|
||||
# with it, and the difference is the whole point:
|
||||
#
|
||||
# * the CHECKLIST this button offers when nothing is preselected is the
|
||||
# tool choosing what to look at, so it offers what the scope declares.
|
||||
# What it left off the list is stated on the report, by kind.
|
||||
# * a piece the USER preselected is judged whatever its prefix. The
|
||||
# selection is the request, and dropping it would be the silent
|
||||
# exclusion this whole tool exists to refuse. It is judged and the
|
||||
# report says the verdict rests on knowledge nobody validated for that
|
||||
# kind of piece.
|
||||
the_scope = scopelib.of(know)
|
||||
scope_line = None # what was left off the checklist
|
||||
scope_annotation = None # what was judged outside the scope anyway
|
||||
|
||||
if not assemblies:
|
||||
# Nothing preselected: a checklist of every assembly, usable at 238
|
||||
# pieces -- mark, design number, and whether a spec or only a regex
|
||||
# summary backs it (the information the old design dialog showed,
|
||||
# moved onto the pieces).
|
||||
items = []
|
||||
for design, asm_list in sorted(grouped.items()):
|
||||
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
|
||||
if design in specs:
|
||||
state = 'spec'
|
||||
elif design in summaries:
|
||||
state = 'regex fallback'
|
||||
else:
|
||||
state = 'NO spec/summary'
|
||||
items.append((asm, '{0} - {1} - {2}'
|
||||
.format(asm.AssemblyTypeName, design, state)))
|
||||
items.sort(key=lambda r: r[1])
|
||||
offered, left_alone = [], []
|
||||
for row in items:
|
||||
(offered if the_scope.includes(row[0].AssemblyTypeName)
|
||||
else left_alone).append(row)
|
||||
scope_line = the_scope.line(
|
||||
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
|
||||
unit='piece')
|
||||
items = offered
|
||||
if not items:
|
||||
forms.alert('There is nothing to check.\n\n{0}'.format(scope_line),
|
||||
title='Ticket QC', exitscript=True)
|
||||
names = [r[1] for r in items]
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True, title='Ticket QC - choose pieces',
|
||||
button_name='Check tickets')
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
assemblies = [r[0] for r in items if r[1] in picked_set]
|
||||
if not assemblies:
|
||||
script.exit()
|
||||
|
||||
if not grouped and not assemblies:
|
||||
forms.alert(
|
||||
'No assembly carries a value for the "panel.design_number" role '
|
||||
'(mapped to: {0}).\n\nEither the mapping is wrong or this model does '
|
||||
'not group pieces that way.'
|
||||
.format(Q.role_label(know, 'panel.design_number')), exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
# Per-piece design intent (Phase 3): the picked pieces may span designs, so
|
||||
# each assembly's design number is looked up on its own -- not under one
|
||||
# design's summary. Progress is visible and cancellable: context is
|
||||
# collected once (not per piece), each piece runs in turn, and the bar
|
||||
# advances per piece so the user sees movement rather than a frozen window.
|
||||
# The first piece of an untouched design pays Revit's one-time geometry
|
||||
# generation, which can take a while -- the title says so.
|
||||
context = Q.collect_context(doc)
|
||||
results, missing = [], []
|
||||
total = len(assemblies)
|
||||
bar_title = 'Checking tickets ({0} pieces) - first of a design can be slow'
|
||||
with forms.ProgressBar(title=bar_title.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, asm in enumerate(assemblies):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
batch, miss = Q.run_for_assemblies(
|
||||
doc, [asm], specs=specs, know=know, context=context)
|
||||
results.extend(batch)
|
||||
missing.extend(miss)
|
||||
pb.update_progress(total, total)
|
||||
|
||||
# ------------------------------------------- the escalation, when asked for
|
||||
# Checks no comparison can settle (5.5) escalate to a small model reading one
|
||||
# rendered view. Its answer is NEVER a pass and NEVER a fail: the checklist
|
||||
# row stays as it was and a WARN says a person must look
|
||||
# (docs/ticket-checklist.md section 8bis). Kept in `model_read`, never in
|
||||
# `findings`, so nothing computed and nothing read can be mistaken for each
|
||||
# other. OFF unless the run option says otherwise -- a model call spends
|
||||
# money and leaves the building.
|
||||
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
|
||||
for r in results:
|
||||
r['model_read'] = []
|
||||
if _vision_on and results:
|
||||
with forms.ProgressBar(title='Escalating to a model reading the drawing',
|
||||
cancellable=True) as pb:
|
||||
for i, r in enumerate(results):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, len(results))
|
||||
try:
|
||||
r['model_read'] = TV.read_for_ticket(
|
||||
r['ticket'], know, designspec.root_dir(doc))
|
||||
except Exception as _ex:
|
||||
r['model_read'] = [TV.not_run_row(
|
||||
'AI-BARMARK', 'the drawing',
|
||||
'the escalation itself failed ({0})'
|
||||
.format(type(_ex).__name__))]
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
#
|
||||
# THE CHECKLIST TABLE IS THE PAGE. Everything else is appendix.
|
||||
#
|
||||
# Owner, 2026-08-30, after reading a real run:
|
||||
#
|
||||
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
|
||||
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
|
||||
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
|
||||
#
|
||||
# So: the tables first and big; the design intent, the scope, the knowledge
|
||||
# path, the tally and the caveats after them, folded. The fold rule is not
|
||||
# this button's -- it lives in `vibe.foldreport` and it is the standing
|
||||
# pattern for every report this tool prints: default folded, a section that
|
||||
# carries a problem opens itself, and a folded header still says in one line
|
||||
# what is inside it. The reader's contract is that if nothing below the
|
||||
# tables is open, nothing below the tables needs him.
|
||||
#
|
||||
# WHY THE HTML IS HAND-WRITTEN AND LOOKS DATED
|
||||
# ---------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine
|
||||
# in WinForms, in IE7 document mode unless the host sets
|
||||
# FEATURE_BROWSER_EMULATION, which pyRevit does not. `<details>` /
|
||||
# `<summary>` DO NOT EXIST here and fail SILENTLY: an unknown element
|
||||
# renders inline and the "collapsed" content is simply always visible. So
|
||||
# the fold is an inline `onclick` plus `style.display`, which is IE5-era DOM
|
||||
# and works in every mode this control can be in.
|
||||
#
|
||||
# The checklist table itself uses NO script and NO HTML5 -- a plain <table>
|
||||
# with inline colours. If the toggle is dead, the product is still on the
|
||||
# page, in full, in colour. That is the whole point of putting it outside
|
||||
# the fold machinery.
|
||||
#
|
||||
# WHAT THE COLOURS SAY (vibe.ticketpage.state_color owns the grouping):
|
||||
# OK green
|
||||
# WARN, FOR ENGINEER yellow
|
||||
# NOT RUN, FAIL RED -- both of them, deliberately. An unrun check
|
||||
# tells you nothing about the piece, so it must never
|
||||
# look calmer than a failed one. Silence is not a pass.
|
||||
|
||||
# Muting: what this job has DECLARED it does not want asked, with a reason
|
||||
# (`checks.muted` in the job knowledge, edited on the Project Config screen).
|
||||
# It filters ROWS ONLY. `r['findings']` is untouched from here to the JSON
|
||||
# writer and to the clean/failing/warning tally, so a muted check that
|
||||
# failed still fails the ticket and still lands on disk.
|
||||
# THE MUTES, RESOLVED TO MASTER IDS AS THEY ARE READ.
|
||||
#
|
||||
# Owner, 2026-08-31: "以后引用需要前面加 plant-member。" A mute may be written
|
||||
# as a bare master id (`3.3` -- every existing one is) or qualified against a
|
||||
# plant's own board (`sample_plant-wall_panel 3.3`). Both end up keyed by the
|
||||
# MASTER id, which is the whole point: when the plant renumbers its board
|
||||
# tomorrow, the mute follows the QUESTION and not the POSITION. A ruling can
|
||||
# no longer be orphaned by a renumbering.
|
||||
_plant_id = knowledge.plant_id(doc)
|
||||
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
|
||||
plant=_plant_id)
|
||||
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
|
||||
|
||||
# Applicability: what a KNOWLEDGE LAYER declares is not a question about this
|
||||
# kind of piece, so the check was never run (`checks.not_applicable`). It is
|
||||
# not muting and it is not scope -- see the block over
|
||||
# `ticketpage.apply_applicability` for where each of the five states sits.
|
||||
#
|
||||
# WHICH LAYER DECLARED IT is computed rather than typed. `declared_by` in the
|
||||
# entry says who RULED; this says which FILE the tool read it from, which is
|
||||
# the file somebody has to edit to change it. It is resolved once per press,
|
||||
# not per row -- it re-reads the layer files.
|
||||
try:
|
||||
_layers = configdump.layer_values(doc)
|
||||
except Exception:
|
||||
_layers = []
|
||||
|
||||
|
||||
def _declaring_layer(item, condition):
|
||||
if not _layers or not condition:
|
||||
return None
|
||||
# KEYS, NOT A DOTTED PATH: a checklist row number contains a dot.
|
||||
for branch in ('not_applicable', 'only_applies_to'):
|
||||
found = knowledge.declaring_layer(
|
||||
_layers, ['checks', branch, item, condition])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
for r in results:
|
||||
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
|
||||
# A model read never sets a status. It only marks the row it looked at,
|
||||
# so a reader following that row knows to read the quarantined section
|
||||
# below rather than mistaking silence for a verdict.
|
||||
_rows = TV.annotate_rows(_rows, r.get('model_read'))
|
||||
# THE SAME RESOLUTION `run_all` USED. Two independent resolutions of
|
||||
# "does this row apply" is how a page comes to describe a run that did
|
||||
# not happen, so both sides call one function.
|
||||
_decisions, _classification, _member, _profile = \
|
||||
TC.applicability_decisions_for(r['ticket'], know)
|
||||
for _num in _decisions:
|
||||
_d = _decisions[_num]
|
||||
if _d.get('not_applicable'):
|
||||
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
|
||||
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
|
||||
r['applicability_notes'] = _na_notes
|
||||
r['applicability_problems'] = TP.applicability_problems(_decisions)
|
||||
r['classification'] = _classification
|
||||
r['member'] = _member
|
||||
r['profile'] = _profile
|
||||
r['off_board'] = TP.off_board(_decisions)
|
||||
_kept, _muted_notes = TP.apply_muting(_rows, muted)
|
||||
r['rows'] = _kept
|
||||
# The same rows BEFORE muting. Nothing on the page reads this; it is what
|
||||
# lets `qc-run.json` record the state a muted row really reported as a
|
||||
# STATE rather than as a sentence buried in an English note. A grey row
|
||||
# whose original answer a machine cannot read back has thrown the answer
|
||||
# away, which is the thing muting is specifically not allowed to do.
|
||||
r['rows_unmuted'] = _rows
|
||||
r['muted_notes'] = _muted_notes
|
||||
# LAST, AND IT ONLY REORDERS AND LABELS. The stable master id stays at
|
||||
# `row[0]` -- `checklist_details`, the note folds, the JSON and every
|
||||
# rule key on it -- and the plant's own number rides at `row[5]`.
|
||||
r['rows'] = TP.profile_view(r['rows'], _profile)
|
||||
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
|
||||
r['details'] = TP.checklist_details(r['findings'])
|
||||
r['counts'] = TP.checklist_tally(r['rows'])
|
||||
|
||||
|
||||
def _loudness(r):
|
||||
"""(fails, not-runs, warns, for-engineer) -- how much this ticket wants."""
|
||||
c = r['counts']
|
||||
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
|
||||
c.get('FOR ENGINEER', 0))
|
||||
|
||||
|
||||
loud, all_green = [], []
|
||||
for r in results:
|
||||
(loud if any(_loudness(r)) else all_green).append(r)
|
||||
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
|
||||
-_loudness(r)[2], r['ticket']['mark']))
|
||||
|
||||
clean, failed, warned = [], [], []
|
||||
tally = {}
|
||||
for r in results:
|
||||
bad = [f for f in r['findings'] if f.severity != 'OK']
|
||||
for f in bad:
|
||||
tally[f.code] = tally.get(f.code, 0) + 1
|
||||
if not bad:
|
||||
clean.append(r)
|
||||
elif any(f.severity == 'FAIL' for f in bad):
|
||||
failed.append(r)
|
||||
else:
|
||||
warned.append(r)
|
||||
|
||||
output.add_style(TP.CHECKLIST_STYLE)
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
|
||||
# ---- the headline, then the tables. Nothing folded above this line.
|
||||
output.print_md('# Shop ticket QC')
|
||||
output.print_md('Model **{0}** · {1} ticket(s) across {2} design '
|
||||
'number(s) · {3} clean · {4} failing '
|
||||
'· {5} warning only'
|
||||
.format(doc.Title, len(results),
|
||||
len(set([r['design'] for r in results])),
|
||||
len(clean), len(failed), len(warned)))
|
||||
output.print_html(TP.checklist_key_html())
|
||||
|
||||
for r in loud:
|
||||
t = r['ticket']
|
||||
head = '**{0}** [{1}] {2} x {3} — {4}'.format(
|
||||
t['mark'], r['design'],
|
||||
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
|
||||
TP.checklist_headline(r['counts']))
|
||||
if t.get('sheet') is not None:
|
||||
head += ' ' + output.linkify(t['sheet'].Id, 'open ticket')
|
||||
if t.get('panel_id'):
|
||||
head += ' ' + output.linkify(ElementId(t['panel_id']), 'panel')
|
||||
output.print_md(head)
|
||||
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
|
||||
|
||||
# A ticket whose every row is green needs no table -- there is nothing on it
|
||||
# to read. It gets a line, and the line says the table was all green rather
|
||||
# than that the ticket was "clean", which is a different and weaker claim.
|
||||
if all_green:
|
||||
# "green" is a claim, so it is only made when it is true. A piece whose
|
||||
# quiet rows include one a knowledge layer switched off has not had every
|
||||
# question answered, and saying so here is cheaper than a reader finding
|
||||
# out from the section below.
|
||||
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
|
||||
for r in all_green])
|
||||
output.print_md('**{0} ticket(s) with nothing to read on the checklist:** '
|
||||
'{1}{2}'
|
||||
.format(len(all_green),
|
||||
', '.join([r['ticket']['mark']
|
||||
for r in all_green]),
|
||||
(' — and {0} row(s) across them were NOT '
|
||||
'ASKED because a knowledge layer declares they '
|
||||
'do not apply; see below'.format(_quiet))
|
||||
if _quiet else ' — every row green'))
|
||||
|
||||
# ------------------------------------------------- everything else, folded
|
||||
#
|
||||
# Built as `foldreport.Section`s so the same content renders as HTML here and
|
||||
# as plain text into the file below -- one source, so the page and the file
|
||||
# cannot say different things.
|
||||
sections = []
|
||||
|
||||
|
||||
def _section(key, title, summary, blocks, problem=None):
|
||||
if not blocks:
|
||||
return
|
||||
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
|
||||
problem=problem))
|
||||
|
||||
|
||||
# -- pieces that could not be checked. Always a problem when it is not empty:
|
||||
# 'could not be checked' is a result, not an absence.
|
||||
if missing:
|
||||
_section('missing', 'Pieces that could not be checked',
|
||||
'{0} piece(s)'.format(len(missing)),
|
||||
[(fold.MD, '- **{0}** (id {1}): {2}'
|
||||
.format(m.get('mark', '?'), m.get('id', '?'),
|
||||
m.get('why', 'unknown')))
|
||||
for m in missing],
|
||||
problem='{0} piece(s) were not judged at all'.format(len(missing)))
|
||||
|
||||
# -- weaker readings. A caveat that actually bites, so it opens itself.
|
||||
regex_designs = sorted(set([r['design'] for r in results
|
||||
if r['spec_source'] == 'regex']))
|
||||
if regex_designs:
|
||||
_section('regex', 'Designs read with regexes, not an extracted spec',
|
||||
', '.join(regex_designs),
|
||||
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
|
||||
'read with regexes instead. That reading is weaker '
|
||||
'-- run **Extract Design** on them.'
|
||||
.format(', '.join(regex_designs)))],
|
||||
problem='{0} design(s) are being judged against a weaker '
|
||||
'reading'.format(len(regex_designs)))
|
||||
|
||||
# -- WHICH PROFILE RAN, and what it switched off.
|
||||
#
|
||||
# Required once per run, per piece, and as loud as the scope line: a
|
||||
# mistaken applicability rule is a safety check disabled in silence, and the
|
||||
# only thing standing between that and a signed report is this section. So it
|
||||
# names the classification AND the rows, and it opens itself whenever
|
||||
# anything was switched off or any declaration was refused -- unlike muting,
|
||||
# which folds when it is tidy, because a mute leaves the check running.
|
||||
_na_notes, _na_problems, _profile_lines = [], [], []
|
||||
_off_board = []
|
||||
_seen_na = set()
|
||||
for r in results:
|
||||
# WHICH BOARD, THEN WHICH KIND OF PIECE. The profile line says what the
|
||||
# numbers in the table even mean; the classification line says how the
|
||||
# piece was placed within it. A renumbered table with neither has shown
|
||||
# the reader a checklist without telling him whose.
|
||||
_profile_lines.append(TP.profile_line(
|
||||
r['ticket'].get('mark') or '(no mark)', _plant_id,
|
||||
r.get('member'), r.get('profile'), r.get('off_board')))
|
||||
for _num in (r.get('off_board') or []):
|
||||
if _num not in _off_board:
|
||||
_off_board.append(_num)
|
||||
_profile_lines.append(TP.classification_line(
|
||||
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
|
||||
r.get('applicability_notes')))
|
||||
for note in (r.get('applicability_notes') or []):
|
||||
if note[0] not in _seen_na:
|
||||
_seen_na.add(note[0])
|
||||
_na_notes.append(note)
|
||||
for problem in (r.get('applicability_problems') or []):
|
||||
if problem not in _na_problems:
|
||||
_na_problems.append(problem)
|
||||
_pp = (r.get('profile') or {}).get('problem')
|
||||
if _pp and ('profile', _pp) not in _na_problems:
|
||||
_na_problems.append(('profile', _pp))
|
||||
for _written, _why in _mute_problems:
|
||||
_entry = ('mute {0!r}'.format(_written), _why)
|
||||
if _entry not in _na_problems:
|
||||
_na_problems.append(_entry)
|
||||
|
||||
_na_blocks = [(fold.MD, line) for line in _profile_lines]
|
||||
if _na_notes:
|
||||
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
|
||||
_na_blocks.extend([(fold.MD, line)
|
||||
for line in TP.applicability_lines(_na_notes)])
|
||||
if _na_problems:
|
||||
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
|
||||
'rows RAN:**'))
|
||||
_na_blocks.extend([(fold.MD, '**{0}** — {1}'.format(num, text))
|
||||
for (num, text) in _na_problems])
|
||||
_na_blocks.append(
|
||||
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
|
||||
'is not muting (a muted check runs and is repainted grey), not '
|
||||
'scope (which sets aside a whole piece), and not dormancy (a '
|
||||
'check whose enabling knowledge is unset). It is a knowledge '
|
||||
'layer declaring that this question is not about this kind of '
|
||||
'piece. A row that FAILED is never switched off -- the '
|
||||
'declaration is declined and says so. A row scoped to a panel '
|
||||
'type, on a piece nothing could classify, is NOT RUN in red, '
|
||||
'never quiet: nobody could ask it._'))
|
||||
_section('applicability', 'Which checklist ran on each piece, and what it '
|
||||
'did not ask',
|
||||
'{0} row(s) not asked; {1} declaration(s) refused'
|
||||
.format(len(_na_notes), len(_na_problems)),
|
||||
_na_blocks,
|
||||
problem=TP.applicability_problem(_na_notes, _na_problems))
|
||||
|
||||
# -- muted on this job. Folded when the muting is tidy; open when it is not.
|
||||
_all_muted = []
|
||||
_seen_muted = set()
|
||||
for r in results:
|
||||
for note in r['muted_notes']:
|
||||
if note[0] not in _seen_muted:
|
||||
_seen_muted.add(note[0])
|
||||
_all_muted.append(note)
|
||||
if _all_muted:
|
||||
_section('muted', 'Checklist items muted on this job',
|
||||
'{0} item(s): {1}'.format(
|
||||
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
|
||||
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
|
||||
[(fold.MD, '_Muting does not remove a row -- it stays on the '
|
||||
'table, repainted grey as MUTED, with this note '
|
||||
'saying what the check really reported. The check '
|
||||
'still runs every time; only the presentation '
|
||||
'changes. It never removes a finding: everything '
|
||||
'below still reaches the machine-readable JSON '
|
||||
'(`qc-run.json`, written every press) and still '
|
||||
'counts in the header line, and a muted item that '
|
||||
'FAILED keeps its own red on the table -- muting '
|
||||
'never softens a failure._')],
|
||||
problem=TP.muting_problem(_all_muted))
|
||||
|
||||
# -- the issue tally. Routine; folded.
|
||||
if tally:
|
||||
_section('tally', 'Issues by code',
|
||||
'{0} code(s) across {1} ticket(s)'
|
||||
.format(len(tally), sum(tally.values())),
|
||||
[(fold.TABLE, ['issue', 'tickets'],
|
||||
[[c, str(n)] for c, n in
|
||||
sorted(tally.items(), key=lambda kv: -kv[1])])])
|
||||
|
||||
# -- what each design is being held to. Configuration: folded, by the rule.
|
||||
seen, intent_rows = [], []
|
||||
for r in results:
|
||||
intent = r['intent']
|
||||
if intent['name'] in seen:
|
||||
continue
|
||||
seen.append(intent['name'])
|
||||
env = intent.get('envelope') or {}
|
||||
|
||||
def bound(key, _env=env):
|
||||
return _env[key][1] if key in _env else '-'
|
||||
|
||||
intent_rows.append([
|
||||
intent['name'], r['spec_source'],
|
||||
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
|
||||
Q._fmt_num(intent.get('pcf')),
|
||||
Q.strand_signature(intent.get('strands') or []),
|
||||
intent.get('wwf_mesh') or '-',
|
||||
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
|
||||
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
|
||||
])
|
||||
_section('intent', 'Design intent being enforced',
|
||||
'{0} design(s), from {1}'.format(
|
||||
len(intent_rows),
|
||||
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
|
||||
[(fold.TABLE,
|
||||
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
|
||||
'length', 'width'], intent_rows)])
|
||||
|
||||
# -- scope and knowledge. The context every report carries; folded unless
|
||||
# the scope actually removed or annotated something.
|
||||
if scope_annotation is None:
|
||||
scope_annotation = the_scope.annotation(
|
||||
[str(a.AssemblyTypeName) for a in assemblies
|
||||
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
|
||||
_scope_blocks = [(fold.MD, '> ' + (scope_line or
|
||||
the_scope.line([], unit='piece')))]
|
||||
if scope_annotation:
|
||||
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
|
||||
for _note in the_scope.declaration_lines():
|
||||
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
|
||||
if doc.PathName:
|
||||
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
|
||||
.format(designspec.knowledge_path(doc))))
|
||||
_section('scope', 'Scope and knowledge',
|
||||
'what this run was and was not asked to judge',
|
||||
_scope_blocks,
|
||||
problem=(scope_annotation or None))
|
||||
|
||||
# -- lifting/handling provenance. Only when HANDLE-* actually fired.
|
||||
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
|
||||
_section('handling', 'How the lifting/handling result was obtained',
|
||||
'`HANDLE-*` rows on this run',
|
||||
[(fold.MD,
|
||||
'A current **Panel Lift Check** or sweep result is reused when '
|
||||
'one is on file; otherwise a default-stage check runs on the '
|
||||
'spot, rate limited so this does not hang on a large design. '
|
||||
'`HANDLE-STALE` means a saved result no longer matches the '
|
||||
'piece and was not used. A result not tagged "reviewed Panel '
|
||||
'Lift Check" is good enough to find a problem, not good enough '
|
||||
'to sign -- see docs/lift-check.md.')])
|
||||
|
||||
# ------------------------------- read by a model, quarantined from the above
|
||||
# Its own section, its own columns, and a column that names what read it. A
|
||||
# computed verdict and a model-read one fail differently -- a wrong computed
|
||||
# answer is a bug, a wrong read is a hallucination -- so a reader has to be
|
||||
# able to tell them apart at a glance. They are never in the same table, and
|
||||
# this section opens itself: a model read is never a verdict, so somebody has
|
||||
# to look at it.
|
||||
_read = [(r['ticket']['mark'], f)
|
||||
for r in results for f in (r.get('model_read') or [])]
|
||||
if _read:
|
||||
_head, _read_rows = TV.report_block(_read)
|
||||
_section('modelread', 'Read by a model (never a verdict)',
|
||||
'{0} row(s) a person must judge'.format(len(_read_rows)),
|
||||
[(fold.MD, _head),
|
||||
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
|
||||
problem='a model was asked to look at {0} thing(s); nothing '
|
||||
'here is a pass or a fail'.format(len(_read_rows)))
|
||||
elif _vision_on:
|
||||
output.print_md('> The vision escalation was on and produced nothing, '
|
||||
'which should not happen - every escalation reports a '
|
||||
'reason even when it does not run.')
|
||||
|
||||
# ---- THE MACHINE-READABLE COPY. Written on EVERY press, escalation or not.
|
||||
# The same separation as the page, on disk, for anything that reads the run
|
||||
# programmatically: two keys, never one list, and `computed` on every entry
|
||||
# so even a consumer that merges them cannot lose the difference. When no
|
||||
# model was consulted `model_read` is an empty list -- which is itself the
|
||||
# answer to "was one?", and is why this no longer waits for a vision row:
|
||||
# the muted section promises every run's findings reach this file, and
|
||||
# vision defaults off.
|
||||
#
|
||||
# AND THE DISPOSITION, added 2026-08-31. Until then this file held findings
|
||||
# and NOTHING ELSE -- no checklist rows, no mutes, no `run_all` log -- so a
|
||||
# consumer reading it could not tell a row that PASSED from a row NOBODY
|
||||
# ASKED. Both were an absence of findings. That is this project's cardinal
|
||||
# failure reproduced inside its own output, and `tickets` below is the fix:
|
||||
# every checklist row of every piece, with its state and, where it produced
|
||||
# no verdict, its reason verbatim. See the block above `qc_json`.
|
||||
#
|
||||
# NOTHING HERE CHANGES WHAT A HUMAN SEES. The page is already printed by this
|
||||
# point and `last-ticketqc.txt` below is written from the same `r['rows']`
|
||||
# this reads; `qc_json` builds a new document and never touches a row, a
|
||||
# finding or a note.
|
||||
#
|
||||
# NOTE ON MUTING: this is built from `r['findings']`, which muting never
|
||||
# touches. A muted checklist row is a rendering decision; the finding
|
||||
# behind it still reaches this file -- and now the ROW does too, in grey,
|
||||
# saying what the check really reported.
|
||||
try:
|
||||
_doc = TV.qc_json([f for r in results for f in r['findings']],
|
||||
[f for r in results
|
||||
for f in (r.get('model_read') or [])],
|
||||
tickets=results,
|
||||
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
|
||||
_path = TV.write_run_json(designspec.root_dir(doc), _doc)
|
||||
output.print_md('_Machine-readable: `{0}`_'.format(_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the JSON copy could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- THE EVIDENCE FILE. Migration step 1, docs/architecture-migration.md.
|
||||
#
|
||||
# Everything this press MEASURED, as a versioned JSON document with no live
|
||||
# Revit object in it, beside the model at `_ticketqc/evidence/run-<stamp>.json`.
|
||||
# One file per press, never one per piece.
|
||||
#
|
||||
# NOTHING READS IT. Not this button, not `ticketchecks`, not a runner -- the
|
||||
# reader is step 2. That is why this can ship on its own: a file nobody reads
|
||||
# cannot change what the press above reported. No judgement moved, `read_ticket`
|
||||
# is untouched, and `Q.ticket_to_json` builds a new dict rather than editing
|
||||
# the ticket the checks were run against.
|
||||
#
|
||||
# It carries no findings, deliberately. `qc-run.json` above owns the verdicts;
|
||||
# this file owns the inputs, and keeping the two apart is what will let step 2
|
||||
# re-judge one against the other.
|
||||
#
|
||||
# Wrapped exactly as the JSON copy above is: a failure here prints a line and
|
||||
# loses nothing. The report is already computed by this point.
|
||||
try:
|
||||
_ev_doc = Q.run_evidence(results, doc=doc)
|
||||
_ev_path = Q.write_evidence(
|
||||
designspec.sub_dir(doc, Q.EVIDENCE_FOLDER), _ev_doc)
|
||||
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
|
||||
.format(_ev_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the evidence file could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- FOLDED IS NOT GONE. The whole page, nothing folded, every checklist
|
||||
# table in full text with long notes wrapped rather than cut, written to a
|
||||
# file every press. A fold that will not open then costs a file open, never
|
||||
# a lost sentence.
|
||||
full_path = None
|
||||
try:
|
||||
full_path = os.path.join(designspec.root_dir(doc), 'last-ticketqc.txt')
|
||||
_lines = ['SHOP TICKET QC -- ' + str(doc.Title), '']
|
||||
for r in loud + all_green:
|
||||
_t = r['ticket']
|
||||
_lines.append('')
|
||||
_lines.append('{0} [{1}] {2}'.format(
|
||||
_t['mark'], r['design'],
|
||||
TP.checklist_headline(r['counts']).replace('·', '-')))
|
||||
_lines.append('-' * 78)
|
||||
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
|
||||
_lines.append('')
|
||||
_lines.append(fold.text(sections, show_all=True))
|
||||
payload = compat.as_text('\n'.join(_lines))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as _exc:
|
||||
full_path = None
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(_exc))
|
||||
|
||||
if sections:
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md('**Nothing below the tables needs you.** Every section '
|
||||
'is folded; click a header to open one.')
|
||||
if full_path:
|
||||
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
|
||||
.format(full_path))
|
||||
output.print_html(fold.html(sections))
|
||||
@@ -0,0 +1,19 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
|
||||
# `title:` here -- `uimaker._produce_ui_panels` calls
|
||||
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
|
||||
#
|
||||
# THE FOUR FINISHED TOOLS, all as large icons, in the owner's own order
|
||||
# (2026-08-30: "reinforce, LIFT, ticketqc, snapshot 四个是大图标, 放在一起").
|
||||
# Nothing half-built may join them -- that is what Setup and Design are for.
|
||||
#
|
||||
# Every title here keeps each line under ~9 characters. A large ribbon button
|
||||
# is as wide as its widest line of text, so one long title makes one button
|
||||
# dwarf its neighbours, which is exactly what the owner objected to when
|
||||
# these four were spread across three panels.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ReinforceSuggest
|
||||
- PanelLiftCheck
|
||||
- TicketQC
|
||||
- SnapshotClose
|
||||
@@ -0,0 +1,19 @@
|
||||
# Panel order on the tab, left to right.
|
||||
#
|
||||
# Without this key pyRevit falls back to whatever order the filesystem hands
|
||||
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
|
||||
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
|
||||
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
|
||||
# looks at a `title:` here, so do not add one expecting it to render.
|
||||
#
|
||||
# Three panels, and the split between them is what the owner asked for on
|
||||
# 2026-08-30: the four finished tools stand together as large icons, the
|
||||
# set-up commands collapse behind one arrow, the unbuilt design work
|
||||
# collapses behind another.
|
||||
#
|
||||
# A panel not listed below is not created at all
|
||||
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
|
||||
layout:
|
||||
- Tools
|
||||
- Setup
|
||||
- Design
|
||||
@@ -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,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,415 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
|
||||
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
|
||||
# sample_job. The iron rule is: read-only inside live, never write.
|
||||
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
|
||||
|
||||
The rule this enforces is the owner's, and it was a memory until now: the
|
||||
LIVE model is his, and nothing here may read it, run in it, or touch it. The
|
||||
sandbox -- a timestamped detached copy, opened in a Revit session of its own
|
||||
-- is the only session an agent is allowed to drive.
|
||||
|
||||
WHY IT HAS TO BE MECHANICAL
|
||||
----------------------------
|
||||
pyRevit's routes server picks its port by walking UP from the configured one
|
||||
past every port a running Revit has already taken
|
||||
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
|
||||
session I meant"; it means "whichever Revit started first". Restart the
|
||||
sandbox while the live session is up and the live session is now on 48884 and
|
||||
answering. Every request an agent then sends -- `status`, `exec`, a whole
|
||||
sweep -- lands in the model it must never touch, and every one of them looks
|
||||
completely normal.
|
||||
|
||||
Nothing about that is detectable from the client side: a `status` that says
|
||||
`ok` and names the right title is exactly what a correct sandbox says (the
|
||||
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
|
||||
note). So the refusal has to be made where the document actually is.
|
||||
|
||||
WHY A TITLE ALONE CANNOT DECIDE IT
|
||||
-----------------------------------
|
||||
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
|
||||
leaves the model's own name -- and therefore `doc.Title` -- constant, because
|
||||
the job folder is keyed on the title and a title that moves daily moves the
|
||||
job folder with it. The consequence lands here: the sandbox copy's title is
|
||||
IDENTICAL to the live model's. A denylist of titles would refuse both, which
|
||||
is a bridge that refuses everything.
|
||||
|
||||
So the title is the ACCUSATION and the path is the ALIBI. A document whose
|
||||
title is on the denylist is refused unless it can prove it is a copy:
|
||||
|
||||
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
|
||||
plus anything in `bridge.allow_roots`), or
|
||||
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
|
||||
Revit process's own environment -- which only the pipeline that
|
||||
launched the sandbox can have set.
|
||||
|
||||
The live cloud model has no local `PathName` at all (it is
|
||||
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
|
||||
so it can produce neither alibi and is refused. That is the intended outcome
|
||||
and it is not an accident of string matching.
|
||||
|
||||
WHAT IS NOT HERE, DELIBERATELY
|
||||
-------------------------------
|
||||
No file reading, no `knowledge.load`, no environment lookups except the two
|
||||
helpers at the bottom that are handed their own input. Everything is a pure
|
||||
function of arguments so `startup.py` can be tested at the function level
|
||||
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
|
||||
around by parsing source text, which is a worse test than this one.
|
||||
|
||||
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
|
||||
------------------------------------------------------------
|
||||
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
|
||||
cloud model outright. Hours later the owner reversed it, in his own words:
|
||||
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
|
||||
rule is: read-only inside live, never write." -- the
|
||||
bridge may run on the live model now; the iron rule is it may never write to
|
||||
it. `bridge.readonly_titles` is that rule. A title on it answers every read
|
||||
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
|
||||
transaction or arbitrary code, so they are reads by construction and never
|
||||
consult this at all) and `/exec` -- the only route that can write -- refuses
|
||||
when the request carries a transaction, or when the snippet text matches
|
||||
`WRITE_PATTERNS` below.
|
||||
|
||||
`refuse_titles` and `readonly_titles` are NOT layered. A title on
|
||||
`refuse_titles` is refused before anything here is reached; a title only on
|
||||
`readonly_titles` is never refused outright, only stopped from writing.
|
||||
|
||||
THIS IS A GUARD RAIL, NOT A SANDBOX
|
||||
-------------------------------------
|
||||
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
|
||||
not a permissions model. It catches the ordinary way `/exec` writes -- an
|
||||
explicit transaction, or a snippet that calls a mutating Revit API method by
|
||||
its literal name -- because that is what an ordinary agent request looks
|
||||
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
|
||||
setter (Revit exposes several that mutate without any method call at all),
|
||||
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
|
||||
code that reaches a write through a helper this list has not been told
|
||||
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
|
||||
A determined snippet gets through. This module only stops the request an
|
||||
agent following instructions would send.
|
||||
|
||||
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
|
||||
pathlib, `os.path` throughout.
|
||||
"""
|
||||
# pylint: disable=broad-except,invalid-name
|
||||
import os
|
||||
import re
|
||||
|
||||
from vibe.snapshotmeta import MODEL_EXT
|
||||
|
||||
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
|
||||
# was launched on in its own environment. A session started from the taskbar
|
||||
# does not, and cannot be made to look as though it did without somebody
|
||||
# deciding to.
|
||||
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
|
||||
|
||||
# The denylist, semicolon-separated, for the case where the knowledge layers
|
||||
# cannot be read. It is a way to STATE the answer, not a way to switch the
|
||||
# guard off -- there is deliberately no off switch, because an off switch is
|
||||
# the hole this module exists to close.
|
||||
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
|
||||
|
||||
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
|
||||
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
|
||||
|
||||
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
|
||||
# for a machine whose knowledge layers will not load. Stating the answer
|
||||
# here does not widen what the bridge may do -- read-only is already the
|
||||
# more permissive of the two lists.
|
||||
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
|
||||
|
||||
|
||||
def normalize_title(text):
|
||||
"""A title as it is compared: no model extension, trimmed, case-folded.
|
||||
|
||||
`doc.Title` is sometimes reported with the extension and sometimes
|
||||
without, depending on version and on whether the document was opened from
|
||||
a path or from the cloud, so both have to compare equal or the denylist
|
||||
works on one machine and not the next.
|
||||
"""
|
||||
try:
|
||||
text = '{0}'.format(text or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
if text.lower().endswith(MODEL_EXT):
|
||||
text = text[:-len(MODEL_EXT)]
|
||||
return text.strip().lower()
|
||||
|
||||
|
||||
def split_titles(raw):
|
||||
"""A `;`-separated (or list) denylist into a clean list of strings.
|
||||
|
||||
Accepts what a YAML layer produces (a list), what an environment variable
|
||||
produces (one string), and a single string in the YAML -- the last is a
|
||||
predictable mis-edit and reading it as a one-item list beats reading it as
|
||||
a list of characters.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
items = list(raw)
|
||||
else:
|
||||
items = '{0}'.format(raw).split(';')
|
||||
out = []
|
||||
for item in items:
|
||||
try:
|
||||
text = '{0}'.format(item).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if text:
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _norm_path(path):
|
||||
try:
|
||||
text = '{0}'.format(path or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
if not text:
|
||||
return ''
|
||||
text = text.replace('/', os.sep).replace('\\', os.sep)
|
||||
try:
|
||||
text = os.path.normcase(os.path.normpath(text))
|
||||
except Exception:
|
||||
pass
|
||||
return text.rstrip(os.sep)
|
||||
|
||||
|
||||
def is_under(path, root):
|
||||
"""True when `path` sits inside `root`. Case- and separator-insensitive.
|
||||
|
||||
A prefix test on its own says `C:\\snapshots-old\\x` is under
|
||||
`C:\\snapshots`, so the separator is required after the root.
|
||||
"""
|
||||
path = _norm_path(path)
|
||||
root = _norm_path(root)
|
||||
if not path or not root:
|
||||
return False
|
||||
return path == root or path.startswith(root + os.sep)
|
||||
|
||||
|
||||
def same_model(path_a, path_b):
|
||||
"""True when two paths name the same model file, as strings."""
|
||||
a, b = _norm_path(path_a), _norm_path(path_b)
|
||||
return bool(a) and a == b
|
||||
|
||||
|
||||
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
|
||||
"""True when `title` is on `listed` and this document cannot prove it is
|
||||
a sandbox copy. Shared by `refusal()` (refuse_titles) and
|
||||
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
|
||||
|
||||
No document open (`title` falsy after normalising) is never "on the
|
||||
list": there is nothing to protect and nothing to touch.
|
||||
"""
|
||||
banned = [normalize_title(t) for t in split_titles(listed)]
|
||||
banned = [t for t in banned if t]
|
||||
if not banned:
|
||||
return False
|
||||
|
||||
here = normalize_title(title)
|
||||
if not here or here not in banned:
|
||||
return False
|
||||
|
||||
# The title is on the list. Can this document prove it is a copy?
|
||||
if sandbox_model and same_model(path, sandbox_model):
|
||||
return False
|
||||
for root in (sandbox_roots or ()):
|
||||
if root and is_under(path, root):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
|
||||
config_error=None):
|
||||
"""None when the bridge may serve this document; one sentence when not.
|
||||
|
||||
`config_error` is how a caller says "I could not read the denylist". That
|
||||
is NOT the same answer as "the denylist is empty", and it is refused --
|
||||
a bridge that cannot prove it is not in the live model has not proved
|
||||
anything. The refusal names the two ways to answer it, so the fix is in
|
||||
the message rather than in somebody's memory.
|
||||
"""
|
||||
if config_error:
|
||||
return ('REFUSED: this bridge cannot tell whether the open document '
|
||||
'is the live model -- the refuse-titles configuration could '
|
||||
'not be read ({0}). Fix the job/plant knowledge layer, or '
|
||||
'state the answer in {1} (semicolon separated), then restart '
|
||||
'Revit. Nothing was read or run.'
|
||||
.format(config_error, ENV_TITLES))
|
||||
|
||||
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
|
||||
sandbox_model):
|
||||
return None
|
||||
|
||||
return ('REFUSED: the open document is "{0}", a title on '
|
||||
'bridge.refuse_titles -- the live model this bridge must never '
|
||||
'touch. Nothing was read, run or changed. Its file is {1}, which '
|
||||
'is under no configured snapshot root and is not {2}. If this '
|
||||
'really is a detached snapshot, open it from under '
|
||||
'snapshot.detached_dir, or launch its Revit with {2} set to its '
|
||||
'path.'
|
||||
.format(title, path or '(no local file -- a cloud model)',
|
||||
ENV_SANDBOX))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- read-only
|
||||
#
|
||||
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
|
||||
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
|
||||
|
||||
# Grounded in what this codebase's own write idioms actually look like:
|
||||
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
|
||||
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
|
||||
# needs a Transaction" and "never reload" sections name the rest. Matched
|
||||
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
|
||||
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
|
||||
WRITE_PATTERNS = [
|
||||
('Transaction(', re.compile(r'\bTransaction\s*\(')),
|
||||
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
|
||||
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
|
||||
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
|
||||
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
|
||||
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
|
||||
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
|
||||
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
|
||||
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
|
||||
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
|
||||
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
|
||||
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
|
||||
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
|
||||
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
|
||||
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
|
||||
('doc.Save(', re.compile(r'\.Save\s*\(')),
|
||||
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
|
||||
('Purge', re.compile(r'\bPurge\w*\s*\(')),
|
||||
('.Rename(', re.compile(r'\.Rename\s*\(')),
|
||||
('WorksharingUtils / SynchronizeWithCentral',
|
||||
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
|
||||
]
|
||||
|
||||
|
||||
def write_signature(code):
|
||||
"""The name of the first write-capable pattern found in `code`, or None.
|
||||
|
||||
A plain regex scan over the snippet text -- not a parser, and not a
|
||||
defence against an obfuscated call. See the module docstring.
|
||||
"""
|
||||
text = '{0}'.format(code or '')
|
||||
for name, pattern in WRITE_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
|
||||
sandbox_model=None, tx_name=None, code=None):
|
||||
"""None when `/exec` may run this request; one sentence when not.
|
||||
|
||||
Only reached once `refusal()` above has already let the request through
|
||||
-- a title on `bridge.refuse_titles` is refused outright and this is
|
||||
never consulted. `readonly_titles` is the softer rule: the document may
|
||||
still be read, but nothing that could write to it may run.
|
||||
"""
|
||||
if not _on_list_and_not_exempt(title, path, readonly_titles,
|
||||
sandbox_roots, sandbox_model):
|
||||
return None
|
||||
|
||||
if tx_name:
|
||||
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
||||
'rule is read-only, never write. This request asked to run '
|
||||
'inside a transaction ("{1}"), so it was not run. Nothing '
|
||||
'was changed.'.format(title, tx_name))
|
||||
|
||||
hit = write_signature(code)
|
||||
if hit:
|
||||
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
||||
'rule is read-only, never write. This snippet contains '
|
||||
'{1}, which can write to the model, so it was not run. '
|
||||
'Nothing was changed.'.format(title, hit))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def payload(title, reason):
|
||||
"""The refusal as a routes response body.
|
||||
|
||||
`ok: False` so every existing client already treats it as a failure, plus
|
||||
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
|
||||
grabbed the port" apart from "the sandbox is not up yet" -- two failures
|
||||
that look identical from the outside and need opposite responses.
|
||||
|
||||
The title is reported even though the request is refused: "which model is
|
||||
open" is the one question a refusal still has to answer, or nobody can
|
||||
work out which session they reached.
|
||||
"""
|
||||
return {'ok': False, 'refused': True, 'doc_title': title,
|
||||
'error': reason}
|
||||
|
||||
|
||||
def config_from(know, getter, environ=None):
|
||||
"""(titles, roots) out of the knowledge tree and the environment.
|
||||
|
||||
`getter` is `knowledge.get` passed in rather than imported, so this module
|
||||
keeps its promise of importing nothing that reads a file. The environment
|
||||
is a UNION with the configured values, never a replacement: a denylist you
|
||||
can shrink by unsetting a variable is not a denylist.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
titles = []
|
||||
roots = []
|
||||
try:
|
||||
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
titles.extend(split_titles(environ.get(ENV_TITLES)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
|
||||
detached = getter(know, 'snapshot.detached_dir', None)
|
||||
if detached:
|
||||
roots.append(detached)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
roots.extend(split_titles(environ.get(ENV_ROOTS)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|
||||
seen = set()
|
||||
roots = [r for r in roots if not (_norm_path(r) in seen or
|
||||
seen.add(_norm_path(r)))]
|
||||
return titles, roots
|
||||
|
||||
|
||||
def readonly_titles_from(know, getter, environ=None):
|
||||
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
|
||||
|
||||
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
|
||||
as its own function, returning one list rather than folding a third value
|
||||
into `config_from`'s tuple, so every existing caller of `config_from`
|
||||
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
|
||||
roots are shared with `config_from` -- a copy is a copy whichever list is
|
||||
being checked against -- so this does not read `bridge.allow_roots` or
|
||||
`snapshot.detached_dir` again; callers pass the roots `config_from`
|
||||
already returned.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
titles = []
|
||||
try:
|
||||
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|
||||
@@ -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,739 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Design summaries as reviewable data.
|
||||
|
||||
A design summary is a drafting view full of free text that a human wrote. The
|
||||
checking scripts want numbers. Turning one into the other is the only part of
|
||||
the job that genuinely needs judgement, so it happens once, up front, and the
|
||||
result is written to disk where you can read and correct it:
|
||||
|
||||
<model folder>/_ticketqc/
|
||||
evidence/<DESIGN>.json everything we could scrape off the summary
|
||||
specs/<DESIGN>.json the structured spec <-- the thing to review
|
||||
knowledge.yaml hand-written rules that outlive one project
|
||||
log/<DESIGN>.txt what the extractor printed
|
||||
|
||||
Evidence is gathered deterministically here. So is the spec now: the shape
|
||||
of a spec, and how it reads and writes as text, live in `specformat.py` --
|
||||
which touches no Revit API at all, on purpose, so that shape can be unit
|
||||
tested without a licence or a model (see its docstring). This module is the
|
||||
Revit side of that boundary: it reads the model, and it reads and writes
|
||||
specformat's shapes to disk beside it. Everything downstream reads only the
|
||||
spec, so a checking run is pure, repeatable arithmetic.
|
||||
|
||||
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
|
||||
whole point of this file is that an engineer reads it and corrects it by hand.
|
||||
An older spec written as plain JSON is still read -- see load_spec.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import json
|
||||
import datetime
|
||||
|
||||
from Autodesk.Revit.DB import (
|
||||
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
|
||||
BrowserOrganization, Element, StorageType,
|
||||
)
|
||||
|
||||
from vibe import specformat
|
||||
from vibe import specsave
|
||||
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
|
||||
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
|
||||
normalize_spec, new_spec, strand_positions, strand_row_marks,
|
||||
strand_diagram, spec_text, parse_spec_text, design_key,
|
||||
)
|
||||
from vibe.compat import as_text
|
||||
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
|
||||
# path arithmetic and lives in a module with no Revit import, so it can be
|
||||
# tested on host CPython -- but every caller has always asked designspec for
|
||||
# it, and a second spelling of the same question is how two answers start.
|
||||
from vibe.plantpath import ( # noqa: F401 -- re-exported
|
||||
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
|
||||
)
|
||||
|
||||
# Control characters and format characters that ride along in Revit text and
|
||||
# crash downstream printers / parsers. Measured: a model parameter whose
|
||||
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
|
||||
# way into evidence/WP1.json and broke a tool that printed it. Everything
|
||||
# that scrapes model text goes through clean_text at the boundary; the
|
||||
# function itself lives in ticketpage (the Revit-free module, unit-tested).
|
||||
from vibe.ticketpage import clean_text
|
||||
|
||||
FOLDER = '_ticketqc'
|
||||
|
||||
JOBS_ENV = 'WILLTOOLS_JOBS'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- layout
|
||||
|
||||
def root_dir(doc):
|
||||
"""Where this project's QC data lives.
|
||||
|
||||
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
|
||||
underneath it. Everything a project knows then sits in one place the
|
||||
engineer chose, rather than in two that the tool chose for him.
|
||||
|
||||
Without it, beside the model, falling back to APPDATA when the model has
|
||||
no path yet or its folder cannot be written. That fallback is the reason
|
||||
the override exists. Saving a detached copy gives it a path, so the
|
||||
lookup silently moves from APPDATA to the model folder, and a job
|
||||
knowledge file written before the save stops being read -- measured
|
||||
2026-08-30, and from the outside it looks exactly like edits that did not
|
||||
take.
|
||||
"""
|
||||
override = os.environ.get(JOBS_ENV)
|
||||
if override:
|
||||
return _ensure(os.path.join(override, doc.Title or 'untitled'))
|
||||
model = doc.PathName
|
||||
if model:
|
||||
base = os.path.join(os.path.dirname(model), FOLDER)
|
||||
try:
|
||||
_ensure(base)
|
||||
return base
|
||||
except Exception:
|
||||
pass
|
||||
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
|
||||
'pyrevit-ticketqc', doc.Title or 'untitled')
|
||||
_ensure(fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
def _ensure(path):
|
||||
if not os.path.isdir(path):
|
||||
os.makedirs(path)
|
||||
return path
|
||||
|
||||
|
||||
def sub_dir(doc, name):
|
||||
return _ensure(os.path.join(root_dir(doc), name))
|
||||
|
||||
|
||||
def evidence_path(doc, design):
|
||||
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
|
||||
|
||||
|
||||
def spec_path(doc, design, ext='yaml'):
|
||||
"""Where one design's spec lives. New specs are written as '.yaml'.
|
||||
|
||||
A spec written before this file carried comments is still on disk as
|
||||
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
|
||||
whichever one is actually there.
|
||||
"""
|
||||
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
|
||||
|
||||
|
||||
def find_spec_path(doc, design):
|
||||
"""(path, ext) of the spec actually on disk for this design.
|
||||
|
||||
'.yaml' wins when both exist -- it is the one a human could have
|
||||
corrected, and load_spec must not silently prefer the one they didn't
|
||||
touch. Neither existing, this is where a new spec would be written.
|
||||
"""
|
||||
yaml_path = spec_path(doc, design, 'yaml')
|
||||
if os.path.isfile(yaml_path):
|
||||
return yaml_path, 'yaml'
|
||||
json_path = spec_path(doc, design, 'json')
|
||||
if os.path.isfile(json_path):
|
||||
return json_path, 'json'
|
||||
return yaml_path, 'yaml'
|
||||
|
||||
|
||||
def log_path(doc, design):
|
||||
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
|
||||
|
||||
|
||||
def knowledge_path(doc):
|
||||
return os.path.join(root_dir(doc), 'knowledge.yaml')
|
||||
|
||||
|
||||
# ------------------------------------------------------- the project config
|
||||
#
|
||||
# A project's configuration file is written wherever the engineer wants it --
|
||||
# the job folder on a share, usually, where the rest of the job's paperwork
|
||||
# lives and where a reviewer will look for it. That is the whole point of
|
||||
# letting them choose, and it means the tool cannot go and find it by
|
||||
# convention afterwards.
|
||||
#
|
||||
# So a one-line pointer stays behind, beside the model with the rest of the QC
|
||||
# data. It holds the path and nothing else. Losing it costs one button press;
|
||||
# putting the config itself here would defeat the choice.
|
||||
|
||||
CONFIG_POINTER = 'active-config.txt'
|
||||
|
||||
|
||||
def config_pointer_path(doc):
|
||||
return os.path.join(root_dir(doc), CONFIG_POINTER)
|
||||
|
||||
|
||||
def active_config_path(doc):
|
||||
"""The project config file in force for this model, or None.
|
||||
|
||||
Returns the path even when the file has gone missing. A pointer to
|
||||
nothing is a fact the caller has to be told about -- resolving it to None
|
||||
would silently fall back to the layered files, and the run would use a
|
||||
different configuration than the one the engineer set, without saying so.
|
||||
"""
|
||||
pointer = config_pointer_path(doc)
|
||||
if not os.path.isfile(pointer):
|
||||
return None
|
||||
try:
|
||||
with io.open(pointer, 'r', encoding='utf-8') as fh:
|
||||
path = fh.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return path or None
|
||||
|
||||
|
||||
def set_active_config(doc, path):
|
||||
"""Make this file the configuration this model runs on."""
|
||||
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(path))
|
||||
return path
|
||||
|
||||
|
||||
def clear_active_config(doc):
|
||||
"""Go back to the layered files. Returns what was in force, or None."""
|
||||
pointer = config_pointer_path(doc)
|
||||
previous = active_config_path(doc)
|
||||
if os.path.isfile(pointer):
|
||||
os.remove(pointer)
|
||||
return previous
|
||||
|
||||
|
||||
def _safe(name):
|
||||
"""A design number as a filename.
|
||||
|
||||
One rule, and it lives in `specsave` -- the Extract Design button names
|
||||
the COPY it saves elsewhere with the same function, so a copy can never
|
||||
end up called something the canonical file is not.
|
||||
"""
|
||||
return specsave.safe_name(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- design numbers
|
||||
#
|
||||
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
|
||||
# file, alongside the rest of the spec shape. It is the same function under
|
||||
# the same name; nothing that calls `designspec.design_key` has to change.
|
||||
#
|
||||
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
|
||||
# asking "are these two design numbers the same design?" needed a licence.
|
||||
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
|
||||
# out of a suggestion -- and it must answer both inside Revit and in
|
||||
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
|
||||
# the reduction there would have been a second spelling of one question, which
|
||||
# is how two answers start; so the function went to the Revit-free module and
|
||||
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
|
||||
# `specformat.FALLBACK_DESIGN_RE`.
|
||||
|
||||
|
||||
def _nomen(know, key, default):
|
||||
"""One nomenclature setting, tolerating a knowledge dict that is not there.
|
||||
|
||||
Imported late: knowledge imports this module, so a module-level import
|
||||
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
|
||||
and resolves `nomenclature.<key>` at each call site, so do not rename it
|
||||
without teaching the scanner.
|
||||
"""
|
||||
if know is None:
|
||||
return default
|
||||
from vibe import knowledge as K
|
||||
return K.get(know, 'nomenclature.' + key, default)
|
||||
|
||||
|
||||
def list_specs(doc):
|
||||
"""Design numbers that already have a spec on disk.
|
||||
|
||||
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
|
||||
wins, same rule as find_spec_path, so a spec a human has never touched
|
||||
(still '.json') does not shadow one they have.
|
||||
"""
|
||||
folder = sub_dir(doc, 'specs')
|
||||
by_design = {}
|
||||
for fn in sorted(os.listdir(folder)):
|
||||
low = fn.lower()
|
||||
if low.endswith('.yaml'):
|
||||
rank = 0
|
||||
elif low.endswith('.json'):
|
||||
rank = 1
|
||||
else:
|
||||
continue
|
||||
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
|
||||
if not spec or not spec.get('design_number'):
|
||||
continue
|
||||
design = spec['design_number']
|
||||
prior = by_design.get(design)
|
||||
if prior is None or rank < prior[0]:
|
||||
by_design[design] = (rank, spec)
|
||||
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- io
|
||||
|
||||
def read_json(path):
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
return json.loads(fh.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
_ensure(os.path.dirname(path))
|
||||
text = as_text(json.dumps(data, indent=2, sort_keys=True,
|
||||
ensure_ascii=False))
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(text)
|
||||
return path
|
||||
|
||||
|
||||
def read_yaml(path):
|
||||
"""A spec file written by save_spec, or None. Comments are just dropped."""
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
text = fh.read()
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return specformat.parse_spec_text(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_spec_file(path):
|
||||
"""A spec, whichever of the two formats it was written in."""
|
||||
if path.lower().endswith('.json'):
|
||||
return read_json(path)
|
||||
return read_yaml(path)
|
||||
|
||||
|
||||
def load_spec(doc, design):
|
||||
path, _ext = find_spec_path(doc, design)
|
||||
return normalize_spec(read_spec_file(path))
|
||||
|
||||
|
||||
def save_spec(doc, design, spec):
|
||||
"""Write a spec as hand-editable YAML.
|
||||
|
||||
Always '.yaml', regardless of what was there before -- a spec that gets
|
||||
re-extracted goes back to being machine-written, and a stale '.json'
|
||||
left beside it would otherwise look like an untouched original. Nothing
|
||||
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
|
||||
here on, same as list_specs.
|
||||
"""
|
||||
path = spec_path(doc, design, 'yaml')
|
||||
_ensure(os.path.dirname(path))
|
||||
text = specformat.spec_text(spec)
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(text))
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------ element utils
|
||||
|
||||
def _name(el):
|
||||
try:
|
||||
return Element.Name.GetValue(el)
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _eid(el):
|
||||
try:
|
||||
return el.Id.IntegerValue
|
||||
except Exception:
|
||||
return el.Id.Value
|
||||
|
||||
|
||||
def _params(el, wanted=None):
|
||||
out = {}
|
||||
for p in el.Parameters:
|
||||
pname = p.Definition.Name
|
||||
if wanted is not None and pname not in wanted:
|
||||
continue
|
||||
try:
|
||||
if p.StorageType == StorageType.String:
|
||||
val = p.AsString()
|
||||
elif p.StorageType == StorageType.Integer:
|
||||
val = p.AsInteger()
|
||||
elif p.StorageType == StorageType.Double:
|
||||
val = p.AsValueString()
|
||||
else:
|
||||
val = p.AsValueString()
|
||||
except Exception:
|
||||
val = None
|
||||
if val not in (None, ''):
|
||||
out[pname] = val
|
||||
return out
|
||||
|
||||
|
||||
def _dimension(d):
|
||||
"""One dimension, with enough geometry to say what it measures.
|
||||
|
||||
A bare string is not enough for a strand chain. Where the strands sit is
|
||||
dimensioned as a multi-segment chain -- rail to first strand, then strand
|
||||
to strand, then last strand to the far edge -- so the segments in order,
|
||||
their exact lengths, and which way the chain runs are all part of the
|
||||
reading. N segments locate N-1 strands and the total is the piece width,
|
||||
which is what makes the reading checkable.
|
||||
|
||||
Two traps, both met on real summaries:
|
||||
|
||||
* Dimension.Curve throws on these drafting views, so the direction is
|
||||
inferred from how the segment origins are spread instead.
|
||||
* segment.Value is the drawn geometry and segment.ValueString is what is
|
||||
printed. On an overridden segment they disagree, and only the printed
|
||||
text is the instruction -- so both are recorded and neither is
|
||||
silently preferred.
|
||||
"""
|
||||
entry = {}
|
||||
try:
|
||||
entry['value'] = clean_text(d.ValueString)
|
||||
except Exception:
|
||||
entry['value'] = None
|
||||
for key, attr in (('above', 'Above'), ('below', 'Below'),
|
||||
('prefix', 'Prefix'), ('suffix', 'Suffix')):
|
||||
try:
|
||||
val = getattr(d, attr, None)
|
||||
except Exception:
|
||||
val = None
|
||||
if val:
|
||||
entry[key] = val
|
||||
try:
|
||||
raw = d.Value
|
||||
if raw is not None:
|
||||
entry['value_ft'] = round(float(raw), 6)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
entry['references'] = d.References.Size
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
origin = d.Origin
|
||||
entry['x'] = round(origin.X, 4)
|
||||
entry['y'] = round(origin.Y, 4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
segments = []
|
||||
try:
|
||||
if d.NumberOfSegments > 1:
|
||||
for seg in d.Segments:
|
||||
item = {}
|
||||
try:
|
||||
item['value'] = clean_text(seg.ValueString)
|
||||
except Exception:
|
||||
item['value'] = None
|
||||
try:
|
||||
if seg.Value is not None:
|
||||
item['value_ft'] = round(float(seg.Value), 6)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
point = seg.Origin
|
||||
item['x'] = round(point.X, 4)
|
||||
item['y'] = round(point.Y, 4)
|
||||
except Exception:
|
||||
pass
|
||||
segments.append(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if segments:
|
||||
entry['segments'] = segments
|
||||
entry['segment_count'] = len(segments)
|
||||
xs = [s['x'] for s in segments if 'x' in s]
|
||||
ys = [s['y'] for s in segments if 'y' in s]
|
||||
if xs and ys:
|
||||
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
|
||||
else 'vertical')
|
||||
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
|
||||
if len(lengths) == len(segments):
|
||||
entry['total_ft'] = round(sum(lengths), 6)
|
||||
|
||||
if entry.get('value') or segments:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _view_content(doc, view):
|
||||
"""Text, dimensions and drawn components of one view, positions included.
|
||||
|
||||
Positions matter: the min/max table in a summary is a label note and a
|
||||
value note sitting side by side, and only the geometry links them.
|
||||
"""
|
||||
notes = []
|
||||
for t in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
|
||||
try:
|
||||
c = t.Coord
|
||||
notes.append({'text': t.Text.replace('\r', '').strip(),
|
||||
'x': round(c.X, 4), 'y': round(c.Y, 4)})
|
||||
except Exception:
|
||||
notes.append({'text': t.Text.replace('\r', '').strip()})
|
||||
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
|
||||
|
||||
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_Dimensions)
|
||||
.WhereElementIsNotElementType().ToElements())]
|
||||
dims = [d for d in dims if d]
|
||||
|
||||
components = {}
|
||||
for e in (FilteredElementCollector(doc, view.Id)
|
||||
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
|
||||
try:
|
||||
key = e.Symbol.Family.Name
|
||||
except Exception:
|
||||
key = type(e).__name__
|
||||
components[key] = components.get(key, 0) + 1
|
||||
|
||||
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
|
||||
'text_notes': notes, 'dimensions': dims, 'components': components}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the gathering
|
||||
|
||||
def _sheet_folder(org, sheet):
|
||||
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
|
||||
if org is None:
|
||||
return ''
|
||||
try:
|
||||
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def find_summary_views(doc, design, know=None):
|
||||
"""The views and sheets that make up one design's packet.
|
||||
|
||||
Driven by SHEETS, not by view names. A view belongs to the design of the
|
||||
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
|
||||
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
|
||||
would file it under the wrong design without anything looking wrong.
|
||||
|
||||
Sheets are reduced through design_key, so the whole packet -- cover sheet
|
||||
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
|
||||
order, which puts the cover sheet (the design summary itself) first.
|
||||
"""
|
||||
key = design_key(design, know)
|
||||
if not key:
|
||||
return [], []
|
||||
|
||||
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
|
||||
ignore = [str(p).upper()
|
||||
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
|
||||
stale = _nomen(know, 'stale_view_re', None)
|
||||
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
sheets = []
|
||||
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if s.IsTemplate:
|
||||
continue
|
||||
number = (s.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(p) for p in ignore):
|
||||
continue
|
||||
if design_key(number, know) != key:
|
||||
continue
|
||||
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
|
||||
continue
|
||||
sheets.append(s)
|
||||
sheets.sort(key=lambda s: (s.SheetNumber or ''))
|
||||
|
||||
views, seen = [], set()
|
||||
for sheet in sheets:
|
||||
for vid in sheet.GetAllPlacedViews():
|
||||
if vid in seen:
|
||||
continue
|
||||
seen.add(vid)
|
||||
view = doc.GetElement(vid)
|
||||
if view is None:
|
||||
continue
|
||||
if stale:
|
||||
try:
|
||||
if re.search(stale, view.Name):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
views.append(view)
|
||||
return views, sheets
|
||||
|
||||
|
||||
def gather_evidence(doc, design, know=None):
|
||||
"""Scrape everything the extractor could possibly need. No interpretation."""
|
||||
views, sheets = find_summary_views(doc, design, know)
|
||||
|
||||
evidence = {
|
||||
'schema': SCHEMA_VERSION,
|
||||
'design_number': design,
|
||||
'model': doc.Title,
|
||||
'model_path': doc.PathName,
|
||||
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'summary_views': [_view_content(doc, v) for v in views],
|
||||
'sheets': [],
|
||||
}
|
||||
|
||||
for sheet in sheets:
|
||||
entry = {
|
||||
'number': sheet.SheetNumber,
|
||||
'name': sheet.Name,
|
||||
'id': _eid(sheet),
|
||||
'parameters': _params(sheet),
|
||||
'views': [],
|
||||
}
|
||||
for vid in sheet.GetAllPlacedViews():
|
||||
view = doc.GetElement(vid)
|
||||
if any(v.Id == view.Id for v in views):
|
||||
continue
|
||||
entry['views'].append(_view_content(doc, view))
|
||||
evidence['sheets'].append(entry)
|
||||
|
||||
# A worked example of a real piece helps the extractor ground its units.
|
||||
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
|
||||
return evidence
|
||||
|
||||
|
||||
def _sample_piece(doc, design, know=None):
|
||||
"""One assembly on this design, so the extractor can see real magnitudes."""
|
||||
from Autodesk.Revit.DB import AssemblyInstance
|
||||
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
for mid in asm.GetMemberIds():
|
||||
member = doc.GetElement(mid)
|
||||
if not (member.Category and member.Category.Name == 'Structural Framing'):
|
||||
continue
|
||||
from vibe.ticketqc import rstr
|
||||
# Compared as reduced keys: the piece may be stamped PNL1 while
|
||||
# the summary sheet is PNL1.0, and they are the same design.
|
||||
if design_key(rstr(member, know, 'panel.design_number'),
|
||||
know) != design_key(design, know):
|
||||
break
|
||||
bom = {}
|
||||
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
|
||||
try:
|
||||
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'mark': asm.AssemblyTypeName,
|
||||
'family': member.Symbol.Family.Name,
|
||||
'panel_parameters': _params(member),
|
||||
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
|
||||
'bill_of_material': bom,
|
||||
}
|
||||
else:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- spec shape
|
||||
#
|
||||
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
|
||||
# in specformat.py now -- see the imports at the top of this file -- because
|
||||
# none of them touch Revit and every one of them is worth testing without it.
|
||||
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
|
||||
# code written against this module.
|
||||
|
||||
|
||||
def build_spec(doc, design, know=None, evidence=None):
|
||||
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
|
||||
|
||||
Everything below either matched one of ticketqc's rules or it did not --
|
||||
there is no third option, and nothing here guesses. What matched is
|
||||
filled in; every text note on the design summary that nothing matched is
|
||||
recorded in 'unresolved' rather than silently dropped. What the chain of
|
||||
dimensions means -- the strand matrix -- is not attempted here at all: it
|
||||
is not text, matching it well enough to trust would take real judgement,
|
||||
and a wrong grid is worse than an honest blank one a human fills in by
|
||||
looking at the drawing. datum comes from knowledge (rails.strand_datum)
|
||||
because that much genuinely is known already; columns_in and rows start
|
||||
empty.
|
||||
|
||||
Returns None when this design has no summary view to read.
|
||||
"""
|
||||
from vibe import knowledge as K
|
||||
from vibe import ticketqc
|
||||
|
||||
if know is None:
|
||||
know = K.load(doc)
|
||||
views, sheets = find_summary_views(doc, design, know)
|
||||
if not views:
|
||||
return None
|
||||
if evidence is None:
|
||||
evidence = gather_evidence(doc, design, know=know)
|
||||
|
||||
view = views[0] # the design's own summary view --
|
||||
# the rest of the packet's views
|
||||
# are sub-details, not this design
|
||||
reading = ticketqc.parse_design_summary(doc, view)
|
||||
|
||||
spec = new_spec(design, evidence)
|
||||
spec['source']['extractor'] = 'regex'
|
||||
spec['piece_description'] = sheets[0].Name if sheets else None
|
||||
|
||||
spec['concrete']['fc_psi'] = reading.get('fc')
|
||||
spec['concrete']['fci_psi'] = reading.get('fci')
|
||||
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
|
||||
|
||||
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
|
||||
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
|
||||
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
|
||||
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
|
||||
spec['reinforcing']['strand_groups'] = [
|
||||
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
|
||||
'it_lb': g.get('it'), 'role': None, 'note': None}
|
||||
for g in (reading.get('strands') or [])
|
||||
]
|
||||
spec['reinforcing']['strand_layout']['datum'] = K.get(
|
||||
know, 'rails.strand_datum', 'left_rail')
|
||||
|
||||
envelope = reading.get('envelope') or {}
|
||||
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
|
||||
for bound in ('min', 'max'):
|
||||
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
|
||||
if entry:
|
||||
val, raw = entry
|
||||
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
|
||||
# which does not land on a clean decimal even when the
|
||||
# drawing dimension is exact -- 34'-11 1/2" is really
|
||||
# 34.958333333333336 in binary float. Round to the nearest
|
||||
# 1/16" (6 decimal places comfortably clears that) so the
|
||||
# spec shows the number a person would actually write, not
|
||||
# float noise nobody typed.
|
||||
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
|
||||
spec['envelope'][dim][bound + '_text'] = raw
|
||||
|
||||
# Every note the generic rules could not place. The plant's declared rule
|
||||
# packs then get one look at that list and may move a note into `notes`
|
||||
# (prose a person reads) or leave it where it is with a better sentence
|
||||
# beside it -- never remove one. `apply_to` returns both halves and their
|
||||
# lengths always sum back to what went in, which is the property that
|
||||
# keeps a house-style rule from becoming a silent exclusion.
|
||||
#
|
||||
# No packs declared, or a declaration this build refuses, means this is
|
||||
# exactly the single assignment it used to be. A refusal is carried on the
|
||||
# spec rather than raised: a spec that lost its extraction because a
|
||||
# knowledge file named an unknown pack would be a worse outcome than one
|
||||
# that says so on its face and leaves every note visible.
|
||||
from vibe import extractrules
|
||||
|
||||
entries = ticketqc.unresolved_in_view(doc, view)
|
||||
packs, problems = extractrules.load(know)
|
||||
notes, unresolved = extractrules.apply_to(entries, packs)
|
||||
spec['notes'] = notes
|
||||
spec['unresolved'] = unresolved
|
||||
if problems:
|
||||
spec['source']['rule_pack_problems'] = problems
|
||||
return spec
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of
|
||||
the generic reader and loaded only where the plant knowledge says so.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`ticketqc.parse_design_summary` reads a design summary with rules that are
|
||||
true of precast drawings generally -- an f'c line, a strand callout, a mesh
|
||||
callout. Every text note it cannot place lands in the spec's `unresolved`
|
||||
list verbatim, which is what stops a narrow rule set from being a lossy one.
|
||||
|
||||
But a real sheet carries a second population of notes that are not general at
|
||||
all. They are one producer's house style: the title of a standard detail, the
|
||||
label above a legend, a standing instruction that appears on every sheet that
|
||||
producer draws. The generic reader cannot place them and should not try --
|
||||
they are not facts about precast, they are facts about an office.
|
||||
|
||||
Left alone they are not harmless. Seven of them on one sheet turn `unresolved`
|
||||
into a list nobody reads, and the day a genuinely unread value lands there it
|
||||
is the eighth line of a list the reader has already learned to skim. A signal
|
||||
that always fires is not a signal -- the same argument `.setup-opts` makes for
|
||||
a check that always FAILs, and the same one `checks.muted` makes for a row
|
||||
that is permanently NOT RUN.
|
||||
|
||||
WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO
|
||||
--------------------------------------------
|
||||
A pack classifies a note the generic rules did not match. It may say:
|
||||
|
||||
* `note` -- prose that belongs in the spec's `notes` list. An
|
||||
instruction, a condition, a standing rule. It is CARRIED,
|
||||
verbatim, into a field a person reads.
|
||||
* `furniture` -- drawing furniture that carries no data at all: the bare
|
||||
title of a legend or a detail. It STAYS in `unresolved`,
|
||||
with the pack's reason in place of the blanket one.
|
||||
|
||||
A pack may NEVER remove a note from the spec, and there is no third verdict
|
||||
that makes one disappear. `furniture` is not an ignore list: the line is still
|
||||
written out, still readable, still countable -- only the sentence beside it
|
||||
changes, from "no rule matched this" to "this is a legend title, it carries no
|
||||
data". An exclusion the code chose for itself is the one failure this project
|
||||
has already paid for (see `vibe/scope.py`), and nothing here may repeat it.
|
||||
|
||||
WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture`
|
||||
--------------------------------------------------
|
||||
The two errors are not symmetric, in the same way `vibe/scope.py`'s two are
|
||||
not. A note wrongly called `note` is copied into a field a person reads and
|
||||
costs them one line of attention. A note wrongly called `furniture` has been
|
||||
declared meaningless by a regex, and the reader is told there is nothing
|
||||
there. So a rule that is not certain writes `note`, and a title that carries
|
||||
any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a
|
||||
note, not furniture -- the condition is the part that could matter.
|
||||
|
||||
WHICH PACKS LOAD IS DECLARED, NEVER INFERRED
|
||||
--------------------------------------------
|
||||
From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though
|
||||
that key exists and would be enough to guess with: which house style a job is
|
||||
drawn in is a thing somebody knows and can write down, and a guess that is
|
||||
right today is a guess that goes wrong silently the first time a job is drawn
|
||||
to another producer's standard on this plant's template.
|
||||
|
||||
extract:
|
||||
rule_packs: [mc]
|
||||
|
||||
Undeclared means NO PACK LOADS and every note behaves exactly as it did
|
||||
before this module existed -- unmatched notes land in `unresolved` under the
|
||||
generic reason. That is the honest default: a machine that has been told
|
||||
nothing about an office's habits should claim to recognise nothing.
|
||||
|
||||
A declared name this module does not know is REFUSED, loudly, and no pack
|
||||
loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts`
|
||||
check: an opt file that has gone quiet about a line nobody honours is worse
|
||||
than no opt file, because the reader believes something is in force that is
|
||||
not. `problems` comes back beside the packs and the caller prints it.
|
||||
|
||||
Nothing here imports a module named in knowledge. `PACKS` is a whitelist of
|
||||
names this package ships; a name outside it is an error, never an import.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||||
"""
|
||||
|
||||
# Declared name -> module basename inside this package. A WHITELIST: the
|
||||
# knowledge layer chooses among these, it never names a module to import.
|
||||
PACKS = {
|
||||
'mc': 'mc',
|
||||
}
|
||||
|
||||
KEY = 'extract.rule_packs'
|
||||
|
||||
# What a pack may return from `classify`. `note` is carried into the spec's
|
||||
# notes; `furniture` stays in unresolved with a better reason. There is no
|
||||
# verdict that drops a line -- see the module docstring.
|
||||
NOTE = 'note'
|
||||
FURNITURE = 'furniture'
|
||||
KINDS = (NOTE, FURNITURE)
|
||||
|
||||
|
||||
class Placement(object):
|
||||
"""One pack's verdict on one note.
|
||||
|
||||
`rule` is the pack-qualified id of the rule that fired (`mc/legend-title`)
|
||||
so a reader of the spec can find the line of code that made the claim, and
|
||||
argue with it. A placement that cannot say which rule made it is not
|
||||
accepted -- an unattributable classification is exactly the kind nobody
|
||||
can correct.
|
||||
"""
|
||||
|
||||
__slots__ = ('kind', 'rule', 'reason')
|
||||
|
||||
def __init__(self, kind, rule, reason):
|
||||
if kind not in KINDS:
|
||||
raise ValueError('unknown placement kind: %r' % (kind,))
|
||||
if not rule:
|
||||
raise ValueError('a placement must name the rule that made it')
|
||||
self.kind = kind
|
||||
self.rule = rule
|
||||
self.reason = reason
|
||||
|
||||
def __repr__(self):
|
||||
return 'Placement(%r, %r)' % (self.kind, self.rule)
|
||||
|
||||
|
||||
def load(know, get=None):
|
||||
"""(packs, problems) for the rule packs this knowledge declares.
|
||||
|
||||
`packs` is a list of imported pack modules, in declared order. `problems`
|
||||
is a list of one-line strings naming anything refused; when it is
|
||||
non-empty `packs` is EMPTY -- a half-loaded rule set would classify some
|
||||
notes and not others, and the reader could not tell which sentences to
|
||||
trust. All or nothing, and say why.
|
||||
"""
|
||||
if get is None:
|
||||
from vibe import knowledge as K
|
||||
get = K.get
|
||||
declared = get(know, KEY, None) or []
|
||||
if isinstance(declared, str):
|
||||
declared = [declared]
|
||||
|
||||
problems = []
|
||||
names = []
|
||||
for raw in declared:
|
||||
name = (raw or '').strip().lower()
|
||||
if not name:
|
||||
problems.append('an empty name in %s -- remove the entry or '
|
||||
'name a pack' % (KEY,))
|
||||
elif name not in PACKS:
|
||||
problems.append("%s names '%s', which this build does not ship. "
|
||||
'Known packs: %s'
|
||||
% (KEY, name, ', '.join(sorted(PACKS)) or '(none)'))
|
||||
elif name in names:
|
||||
problems.append("%s names '%s' twice" % (KEY, name))
|
||||
else:
|
||||
names.append(name)
|
||||
|
||||
if problems:
|
||||
return [], problems
|
||||
|
||||
packs = []
|
||||
for name in names:
|
||||
packs.append(__import__('vibe.extractrules.' + PACKS[name],
|
||||
globals(), locals(), ['classify'], 0))
|
||||
return packs, []
|
||||
|
||||
|
||||
def classify(text, packs):
|
||||
"""The first pack that recognises `text`, or None.
|
||||
|
||||
First, not best: packs are tried in the order they were declared, and the
|
||||
reader can see that order in the job file. Nothing here merges two packs'
|
||||
opinions -- a note that two house styles both claim is a fact about the
|
||||
declaration, not something this function should quietly average.
|
||||
"""
|
||||
for pack in (packs or []):
|
||||
placed = pack.classify(text)
|
||||
if placed is not None:
|
||||
return placed
|
||||
return None
|
||||
|
||||
|
||||
def apply_to(entries, packs):
|
||||
"""Split `unresolved` entries into (notes, unresolved) using the packs.
|
||||
|
||||
`entries` are the dicts `ticketqc.unresolved_in_view` returns -- each
|
||||
carries at least 'text' and 'reason'. Every entry comes back in exactly
|
||||
one of the two lists and none is dropped: `len(notes) + len(unresolved)`
|
||||
always equals `len(entries)`, which `test_extractrules` asserts directly
|
||||
because it is the property the whole module rests on.
|
||||
|
||||
A `note` gains 'rule' so the spec says which rule claimed it. A
|
||||
`furniture` keeps its place in unresolved and only its 'reason' changes.
|
||||
"""
|
||||
notes = []
|
||||
unresolved = []
|
||||
for entry in (entries or []):
|
||||
placed = classify(entry.get('text') or '', packs)
|
||||
if placed is None:
|
||||
unresolved.append(entry)
|
||||
continue
|
||||
out = dict(entry)
|
||||
out['rule'] = placed.rule
|
||||
if placed.reason:
|
||||
out['reason'] = placed.reason
|
||||
if placed.kind == NOTE:
|
||||
notes.append(out)
|
||||
else:
|
||||
unresolved.append(out)
|
||||
return notes, unresolved
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The `mc` pack: one producer's design-summary habits.
|
||||
|
||||
Loaded only when the plant layer declares it (`extract.rule_packs: [mc]`) --
|
||||
see `vibe/extractrules/__init__.py` for why that is declared and not inferred
|
||||
from `plant.id`.
|
||||
|
||||
WHERE THESE RULES COME FROM
|
||||
---------------------------
|
||||
Every rule below was written against text observed on a real sheet, and each
|
||||
one says which. They are not a model of how this producer draws; they are a
|
||||
record of lines that actually landed in `unresolved` and what they turned out
|
||||
to be. A rule with no observation behind it does not belong here -- this file
|
||||
is evidence, and a guess mixed into evidence is indistinguishable from it
|
||||
afterwards.
|
||||
|
||||
Observed on `WPA1.0` (sample_job, 8" horizontal wall, read 2026-09-01):
|
||||
all seven notes the generic reader could not place, listed in the order they
|
||||
sit on the sheet.
|
||||
|
||||
WHAT IS DELIBERATELY NOT HERE
|
||||
------------------------------
|
||||
`STRIPPING & BLOCKING DETAIL < 25'` is NOT treated as the same thing as the
|
||||
bare `STRIPPING & BLOCKING DETAIL` beside it. The bare one is the title of a
|
||||
detail drawn on the sheet and carries no data. The other one carries a
|
||||
CONDITION -- which detail applies below twenty-five feet -- and a condition is
|
||||
the part that could change what gets built. So the bare title is furniture and
|
||||
the conditional one is a note, and the rule that separates them tests for
|
||||
anything trailing the title rather than matching both with one loose pattern.
|
||||
That asymmetry is the module's standing rule (`__init__`: when unsure, note).
|
||||
|
||||
Nothing here encodes a NUMBER. `*USE MC STANDARD REINFORCEMENT ...` names a
|
||||
standard this office keeps; what that standard says is plant knowledge and
|
||||
belongs in the plant knowledge file, looked up by name, never frozen into a
|
||||
regex in this file. The rule's whole job is to carry the sentence to a field a
|
||||
person reads.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||||
"""
|
||||
import re
|
||||
|
||||
from vibe.extractrules import Placement, NOTE, FURNITURE
|
||||
|
||||
NAME = 'mc'
|
||||
|
||||
|
||||
def _rule(rule_id, pattern, kind, reason):
|
||||
return (rule_id, re.compile(pattern, re.IGNORECASE), kind, reason)
|
||||
|
||||
|
||||
# Order matters and is not alphabetical: a more specific rule must come before
|
||||
# the looser one it would otherwise be swallowed by. The conditional detail
|
||||
# title is tested before the bare one for exactly that reason.
|
||||
RULES = (
|
||||
# ---------------------------------------------------------- instructions
|
||||
_rule('mc/standard-reinforcement',
|
||||
r"^\*?\s*USE\s+MC\s+STANDARD\s+REINFORCEMENT\b",
|
||||
NOTE,
|
||||
'a standing instruction naming this plant\'s standard reinforcement '
|
||||
'for cut-down panels and openings -- prose, not a number; what the '
|
||||
'standard says is plant knowledge, looked up by name'),
|
||||
|
||||
_rule('mc/conditional-bar',
|
||||
r"^#\s*\d+\s+BAR\b.*\bONLY\s+NEEDED\s+WHEN\b",
|
||||
NOTE,
|
||||
'a conditional reinforcing instruction -- the bar is required only '
|
||||
'when the condition in the sentence holds, so the condition is the '
|
||||
'value and it stays as prose'),
|
||||
|
||||
_rule('mc/pick-instruction',
|
||||
r"^USE\s+FOR\b.*\bPICK\b",
|
||||
NOTE,
|
||||
'a handling instruction saying which pick this detail is for -- '
|
||||
'prose, and it qualifies a detail rather than stating a number'),
|
||||
|
||||
_rule('mc/detail-title-conditional',
|
||||
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\b\s*\S+",
|
||||
NOTE,
|
||||
'a standard-detail title carrying a CONDITION (which detail applies, '
|
||||
'and when) -- the title alone would be furniture, the condition is '
|
||||
'not'),
|
||||
|
||||
# ------------------------------------------------------------- furniture
|
||||
_rule('mc/detail-title',
|
||||
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\s*$",
|
||||
FURNITURE,
|
||||
'the bare title of a standard detail drawn on the sheet -- names '
|
||||
'the detail, states nothing about this design'),
|
||||
|
||||
_rule('mc/legend-title',
|
||||
r"^STRAND\s+PATTERN\s*$",
|
||||
FURNITURE,
|
||||
'the title above the strand-pattern legend -- the legend\'s DATA is '
|
||||
'the dimension chain under it, which is read as geometry, not as '
|
||||
'this text'),
|
||||
|
||||
_rule('mc/legend-label',
|
||||
r"^STRAND\s*$",
|
||||
FURNITURE,
|
||||
'a bare column label in the strand legend -- a heading over values '
|
||||
'read elsewhere, with no value of its own'),
|
||||
)
|
||||
|
||||
|
||||
def classify(text):
|
||||
"""A Placement for `text`, or None when this pack does not recognise it.
|
||||
|
||||
`text` arrives already whitespace-normalised by the caller (one space
|
||||
between words, no leading or trailing run), so every pattern here can
|
||||
assume single spaces and anchor at both ends where it means to.
|
||||
"""
|
||||
probe = (text or '').strip()
|
||||
if not probe:
|
||||
return None
|
||||
for rule_id, pattern, kind, reason in RULES:
|
||||
if pattern.search(probe):
|
||||
return Placement(kind, rule_id, reason)
|
||||
return None
|
||||
@@ -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,788 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
u"""The Design Criteria section of the Panel Analysis input form.
|
||||
|
||||
WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three
|
||||
times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like
|
||||
this, where would a user even understand it), "现在这个panel analysis做的也太丑
|
||||
了" (even now it is still too ugly) -- and then gave the fix in one sentence:
|
||||
"全部给我做成普通人能看懂的样子", make every part of it something an ordinary
|
||||
person can understand. The form is being rebuilt to look like Eriksson Wall's
|
||||
own printed pages, split one Eriksson section per file so eight agents can
|
||||
build it at once without stepping on each other. This file owns exactly one
|
||||
of those sections: **Design Criteria**, which on Eriksson's own printout is
|
||||
four blocks stacked on one page -- Project Information, Material Properties,
|
||||
Load Combinations, Settings -- not four separate sections. `page()` below
|
||||
returns all four, in that order, as one flat block list.
|
||||
|
||||
`page()` NEVER RAISES. A form button that throws mid-render leaves the owner
|
||||
looking at a stack trace instead of a form, which is worse than an ugly form
|
||||
-- so every lookup here is defensive, and anything this module could not lay
|
||||
out is named in the `problems` list it returns instead.
|
||||
|
||||
THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF
|
||||
renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`,
|
||||
`table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as
|
||||
nothing, so a typo here is a silently missing block, not an error. There is
|
||||
no test for "the renderer draws this correctly" (there is no renderer to run
|
||||
against on host CPython, and Revit is not running for this module to check
|
||||
against) -- only for "every block this function returns is a well-formed
|
||||
member of that vocabulary."
|
||||
|
||||
WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol`
|
||||
/ `hint` for every field, rather than importing them from
|
||||
`vibe.panelanalysisform`. Two reasons, both load-bearing:
|
||||
|
||||
1. Another agent is restructuring `panelanalysisform.FIELDS` while this file
|
||||
is being written, so importing text from it ties this module's stability
|
||||
to a moving target -- exactly what "the block list is stable" (tested
|
||||
below) is supposed to rule out.
|
||||
2. Roughly half of what Eriksson's own Design Criteria page prints has NO
|
||||
key in `panelanalysisform.FIELDS` at all yet (project identity, concrete
|
||||
elastic modulus, deflection-limit checkboxes, stress-limit checkboxes,
|
||||
resistance-factor splits, development multipliers by strand type, ...).
|
||||
Those need a label and a hint regardless of whether the backing field
|
||||
exists, so authoring locally is the only option that covers both cases the
|
||||
same way.
|
||||
|
||||
`panelanalysisform` IS still consulted, but only for one yes/no fact per
|
||||
field: does this key already exist in `FIELDS`. That answer decides EXISTS
|
||||
vs. MISSING for the `problems` list, per the owner's instruction that a field
|
||||
whose key does not exist yet degrades to an empty editor plus a named
|
||||
problem, never to a vanished block. The import is wrapped in `try/except
|
||||
Exception` (not just `ImportError`) because a sibling agent editing that file
|
||||
right now can leave it syntactically broken for a moment, and this module
|
||||
must keep rendering through that -- "could not judge whether the key exists"
|
||||
degrades to "treat it as missing", the same discipline CLAUDE.md's
|
||||
`same_face()` example applies to an unrecognised abbreviation.
|
||||
|
||||
WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH
|
||||
`columns`, NOT `table`. Eriksson prints both as grids, and the block
|
||||
vocabulary's `table` kind was the first thing tried -- but a `table` cell is
|
||||
only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no
|
||||
way to put a live numeric/text editor inside one. Every value in those two
|
||||
grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is
|
||||
something the user can actually type, so they are laid out as `columns` of
|
||||
`field` blocks that mirror Eriksson's rows and column groupings instead. The
|
||||
Load Combinations tables, by contrast, use `table` for real: every row is a
|
||||
fixed ACI 318-14 coefficient (public code text, not this job's data -- see
|
||||
"psi vs. ksi" below for the same reasoning applied to a different table) and
|
||||
the only per-row editable thing is the Include tick, which the `table`
|
||||
vocabulary already supports natively.
|
||||
|
||||
PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength
|
||||
keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in
|
||||
ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not
|
||||
perfectly consistent about it, and this file does not try to fix that, only
|
||||
to read it correctly field by field). Eriksson prints everything in **ksi**.
|
||||
Per the assignment: keep the psi keys as the source of truth and put the ksi
|
||||
reading in the field's `symbol` or `hint` instead of introducing a second,
|
||||
parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and
|
||||
`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this
|
||||
divided by 1000, in ksi" so the two reconcile by eye against a printed sheet;
|
||||
the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`,
|
||||
`strand_ep_ksi`) need no such note.
|
||||
|
||||
WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS.
|
||||
On Eriksson's own page these are not something the user types on THIS page at
|
||||
all -- they are a computed caption ("Design contains no exterior soil loads")
|
||||
that reads back whatever soil-load rows exist elsewhere in the model. Nothing
|
||||
in this codebase collects soil-load rows anywhere yet, so there is no field
|
||||
for this section to degrade to an "empty editor" for -- there is no editor to
|
||||
have. Rendering it as a static `sentence` instead of inventing a text field
|
||||
that would silently claim to collect something it cannot is the same
|
||||
discipline as `knowledge.same_face()` returning `None` rather than `False`:
|
||||
"cannot judge, and does not pretend to." One `problems` entry names this once
|
||||
for both columns, so the gap is visible without repeating the same sentence
|
||||
twice.
|
||||
|
||||
THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a
|
||||
checkbox that turns a calculation on, printed beside the sentence that
|
||||
applies when it is off ("R-Value not being calculated"). Both checkbox keys
|
||||
are new (`design_criteria.rvalue_calc_enabled`,
|
||||
`design_criteria.min_reinf_checked` -- see the naming note below) and both
|
||||
degrade the same way every other missing key does: an empty `check` editor
|
||||
plus a named `problems` entry, with the off-state sentence always shown
|
||||
underneath since "unchecked" is this page's honest default until the field
|
||||
exists to say otherwise.
|
||||
|
||||
NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted
|
||||
namespacing convention for one composite input (`stage.<stage_slug>.<opt>`,
|
||||
see `stage_field_key`). New keys here that do not fit a single flat name
|
||||
follow the same shape: `design_criteria.<thing>` for one-off Settings/Project
|
||||
fields (e.g. `design_criteria.analysis_beta_d`), and
|
||||
`load_combo.<strength|service>.<n>.include` /
|
||||
`load_combo.<strength|service>.min_earth_factor` for the Load Combinations
|
||||
grid, mirroring `stage_field_key`'s `<group>.<slug>.<opt>` shape exactly.
|
||||
|
||||
IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting
|
||||
is `'{0}'.format(...)`; every literal that carries the prime character or a
|
||||
non-ASCII letter is a `u'...'` literal with the coding cookie above declaring
|
||||
the file UTF-8, the same convention `panelanalysisform.py` uses throughout.
|
||||
Imports NOTHING beyond the standard library and, defensively,
|
||||
`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit,
|
||||
no `panelsketch`, so this loads and runs on plain CPython the way
|
||||
`tests/conftest.py` expects.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------- key existence
|
||||
|
||||
def _existing_keys():
|
||||
"""The set of keys `panelanalysisform.FIELDS` already declares, or None.
|
||||
|
||||
None means "could not judge" -- the import failed, or the module's shape
|
||||
changed out from under us mid-refactor -- and every caller below treats
|
||||
None the same as "key not found": every field on this page degrades to
|
||||
an empty editor plus a `problems` entry rather than silently assuming
|
||||
the other agent's in-flight edit added the key. Broad `except Exception`
|
||||
on purpose: a sibling agent saving a half-written `panelanalysisform.py`
|
||||
can raise a SyntaxError, not just ImportError, and this page must keep
|
||||
rendering through that.
|
||||
"""
|
||||
try:
|
||||
from vibe import panelanalysisform as pf
|
||||
return set(f['key'] for f in pf.FIELDS)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- fields
|
||||
#
|
||||
# One entry per field this page can lay out: key -> (label, symbol, hint,
|
||||
# required, editor, choices). Authored locally -- see the module docstring's
|
||||
# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the
|
||||
# two keys `panelanalysisform` itself already gates the Run button on
|
||||
# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching
|
||||
# the rest of Design Criteria in that module.
|
||||
_F = {
|
||||
# --------------------------------------------------- Project Information
|
||||
'project_name': (
|
||||
'Project name', '',
|
||||
'The name of the project this panel belongs to, for the record on '
|
||||
'this printout. Leave blank if the project has no name yet.',
|
||||
False, 'text', None),
|
||||
'client_name': (
|
||||
'Client name', '',
|
||||
"The client this panel's project is for. Leave blank if unknown.",
|
||||
False, 'text', None),
|
||||
'project_description': (
|
||||
'Project description', '',
|
||||
'A short free-text description of the project. Leave blank if '
|
||||
'there is nothing to add.',
|
||||
False, 'text', None),
|
||||
'project_location': (
|
||||
'Project location', '',
|
||||
'Where the project is built -- city, state, or site name. Leave '
|
||||
'blank if unknown.',
|
||||
False, 'text', None),
|
||||
'engineer_initials': (
|
||||
'Engineer of record', '',
|
||||
'Who is responsible for this design. Leave blank if not yet '
|
||||
'assigned.',
|
||||
False, 'text', None),
|
||||
'project_date': (
|
||||
'Design date', '',
|
||||
'The date this design applies to. Leave blank to leave it unset.',
|
||||
False, 'text', None),
|
||||
'job_number': (
|
||||
'Job number', '',
|
||||
"This job's internal tracking number, if it has one. Leave blank "
|
||||
'if none has been assigned yet.',
|
||||
False, 'text', None),
|
||||
'design_spec': (
|
||||
'Governing design code', '',
|
||||
'Which code edition this design is checked against, e.g. '
|
||||
'"ACI 318-14". Leave blank to leave it unstated.',
|
||||
False, 'text', None),
|
||||
# ------------------------------------------- Material Properties/Concrete
|
||||
'fci_psi': (
|
||||
'Concrete strength at release', u"f′ci (psi)",
|
||||
u"How strong the concrete must be when the strand is cut or the "
|
||||
u"panel is stripped from the bed -- usually lower than the 28-day "
|
||||
u"strength. Eriksson prints this divided by 1000, in ksi. Leave "
|
||||
u"blank if this panel is not prestressed.",
|
||||
False, 'num', None),
|
||||
'fc_psi': (
|
||||
'Concrete strength at 28 days', u"f′c (psi)",
|
||||
u"The concrete's design strength once fully cured; every stress "
|
||||
u"limit on this page is a multiple of this number. Eriksson prints "
|
||||
u"this divided by 1000, in ksi. Required -- the solve cannot run "
|
||||
u"without it.",
|
||||
True, 'num', None),
|
||||
'conc_eci_psi': (
|
||||
'Concrete elastic modulus at release', u"E′ci (psi)",
|
||||
u"How stiff the concrete is at release strength. Not yet used by "
|
||||
u"the solve. Leave blank if unknown -- it can be computed from "
|
||||
u"f′ci once a rule for that is agreed.",
|
||||
False, 'num', None),
|
||||
'conc_ec_psi': (
|
||||
'Concrete elastic modulus, in-place', u"E′c (psi)",
|
||||
u"How stiff the concrete is once fully cured. Not yet used by the "
|
||||
u"solve. Leave blank if unknown.",
|
||||
False, 'num', None),
|
||||
'unit_weight_pcf': (
|
||||
'Concrete unit weight', 'pcf',
|
||||
"How much the concrete weighs per cubic foot; the panel's own "
|
||||
'weight is computed from this. Required.',
|
||||
True, 'num', None),
|
||||
'concrete_type': (
|
||||
'Concrete type', '',
|
||||
'What kind of concrete this panel is cast from (normal weight, '
|
||||
'lightweight, ...). Reference only -- nothing computes from it '
|
||||
'yet.',
|
||||
False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')),
|
||||
# ----------------------------------------------- Material Properties/Bar
|
||||
'rebar_fy_ksi': (
|
||||
'Rebar yield strength', 'fy (ksi)',
|
||||
'The stress at which ordinary (non-prestressed) reinforcing bar '
|
||||
'starts to yield. Shown for the record; not yet used by the '
|
||||
'solve.',
|
||||
False, 'num', None),
|
||||
'rebar_es_ksi': (
|
||||
'Rebar elastic modulus', 'Es (ksi)',
|
||||
'How stiff ordinary reinforcing steel is. Shown for the record; '
|
||||
'not yet used by the solve.',
|
||||
False, 'num', None),
|
||||
'ps_fpu_ksi': (
|
||||
u'Strand ultimate strength', u'fₚᵤ (ksi)',
|
||||
'The breaking strength of the prestressing strand. Leave blank if '
|
||||
'this panel is not prestressed.',
|
||||
False, 'num', None),
|
||||
'strand_ep_ksi': (
|
||||
'Strand elastic modulus', 'Ep (ksi)',
|
||||
'How stiff the prestressing strand is. Shown for the record; not '
|
||||
'yet used by the solve.',
|
||||
False, 'num', None),
|
||||
# -------------------------------------------------------- Load combos
|
||||
'design_criteria.min_earth_factor_strength': (
|
||||
'Minimum earth factor, strength combinations', '',
|
||||
'The smallest lateral-earth-pressure factor applied across the '
|
||||
'strength combinations below. Leave blank to use none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.min_earth_factor_service': (
|
||||
'Minimum earth factor, service combinations', '',
|
||||
'The smallest lateral-earth-pressure factor applied across the '
|
||||
'service combinations below. Leave blank to use none.',
|
||||
False, 'num', None),
|
||||
# --------------------------------------------------- Settings/Deflection
|
||||
'deflection_limit': (
|
||||
'Typical deflection limit', 'L / n',
|
||||
'How much the panel is allowed to bow or sag, as a fraction of its '
|
||||
'span, e.g. "150" for L/150. Not yet used by the solve.',
|
||||
False, 'text', None),
|
||||
'design_criteria.deflection_add_bow': (
|
||||
'Add an initial bow to reported deflections?', '',
|
||||
'Whether the panel is assumed to already be slightly bowed before '
|
||||
'any load is applied, and that starting bow is added to every '
|
||||
'reported deflection. Leave unchecked to assume none.',
|
||||
False, 'check', None),
|
||||
'design_criteria.deflection_additional_bow_in': (
|
||||
'Additional bow assumed', 'in',
|
||||
'How much starting bow to add, if the box above is checked. Leave '
|
||||
'blank for none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.deflection_check_cantilever': (
|
||||
'Check deflections on cantilevers?', '',
|
||||
'Whether cantilevered portions of the panel get their own '
|
||||
'deflection check, separate from the typical limit above. Leave '
|
||||
'unchecked to skip it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.deflection_cantilever_limit': (
|
||||
'Cantilever deflection limit', 'L / n',
|
||||
'The separate deflection limit for cantilevered portions, if the '
|
||||
'box above is checked, e.g. "75" for L/75.',
|
||||
False, 'text', None),
|
||||
# ---------------------------------------------- Settings/Structural Anal.
|
||||
'second_order': (
|
||||
'2nd-order effects considered?', '',
|
||||
'Whether P-delta / second-order bending is considered for this '
|
||||
'panel. Not yet used by the solve.',
|
||||
False, 'text', None),
|
||||
'design_criteria.analysis_beta_d': (
|
||||
u'Sustained-load ratio for 2nd-order analysis', u'βd',
|
||||
'The fraction of the axial load that is sustained (long-term), '
|
||||
'used by the 2nd-order analysis above. Leave blank if not '
|
||||
'applicable.',
|
||||
False, 'num', None),
|
||||
'design_criteria.cracking_method': (
|
||||
'Cracking method', '',
|
||||
'Which published method decides whether the section is treated as '
|
||||
'cracked. Leave blank if not applicable.',
|
||||
False, 'text', None),
|
||||
'design_criteria.cracking_limit_sqrt_fc': (
|
||||
u'Cracking stress limit', u"n·√f′c",
|
||||
u"The stress, as a multiple of √f′c, at which the "
|
||||
u"section is assumed to crack. Leave blank to use the built-in "
|
||||
u"default.",
|
||||
False, 'num', None),
|
||||
'design_criteria.rvalue_calc_enabled': (
|
||||
'Calculate an R-value for this panel?', '',
|
||||
'Whether an insulation R-value is calculated for this panel. Leave '
|
||||
'unchecked if it is not being calculated -- that is the default.',
|
||||
False, 'check', None),
|
||||
'design_criteria.min_reinf_checked': (
|
||||
'Check minimum reinforcement?', '',
|
||||
'Whether the ACI minimum-steel requirement is checked for this '
|
||||
'panel. Leave unchecked if it is not being checked -- that is the '
|
||||
'default.',
|
||||
False, 'check', None),
|
||||
# -------------------------------------------------- Settings/Stress opts
|
||||
'design_criteria.stress_failure_if_cracked': (
|
||||
'Fail the panel if it cracks?', '',
|
||||
'Whether a cracked section is treated as an outright failure '
|
||||
'rather than just a flag. Leave unchecked to only flag it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.stress_ignore_lateral_ecc': (
|
||||
'Ignore lateral strand eccentricity?', '',
|
||||
'Whether side-to-side strand placement is ignored when computing '
|
||||
'stress. Leave unchecked to include it.',
|
||||
False, 'check', None),
|
||||
'design_criteria.stress_use_principal_axis': (
|
||||
'Use the principal axis for stress?', '',
|
||||
'Whether stress is computed about the section\'s principal axis '
|
||||
'rather than its geometric one. Leave unchecked to use the '
|
||||
'geometric axis.',
|
||||
False, 'check', None),
|
||||
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
|
||||
'Modulus of rupture, in place', u"n·√f′c",
|
||||
u"The concrete's tensile cracking stress once erected, as a "
|
||||
u"multiple of √f′c. Leave blank to use the built-in "
|
||||
u"default.",
|
||||
False, 'num', None),
|
||||
'inplace_comp_ratio': (
|
||||
'Compression allowed once erected', u"n·f′c",
|
||||
'The compression limit for the panel once it is standing in '
|
||||
'place, as a fraction of f′c. Not yet used by the solve.',
|
||||
False, 'num', None),
|
||||
'handling_tension_sqrt': (
|
||||
'Tension allowed while handling', u"n·√f′ci",
|
||||
u"How much the concrete may be pulled apart during stripping / "
|
||||
u"handling, as a multiple of √f′ci. Blank uses the "
|
||||
u"built-in default.",
|
||||
False, 'num', None),
|
||||
'handling_comp_ratio': (
|
||||
'Compression allowed while handling', u"n·f′ci",
|
||||
u"How much the concrete may be squeezed during handling, as a "
|
||||
u"fraction of f′ci. Blank uses the built-in default.",
|
||||
False, 'num', None),
|
||||
'design_criteria.flexure_method': (
|
||||
'Flexure calculation method', '',
|
||||
'Which method computes bending capacity (e.g. a stress-block '
|
||||
'method). Leave blank to use the built-in default.',
|
||||
False, 'text', None),
|
||||
'design_criteria.flexure_ignored_end_distance_in': (
|
||||
'Ignored end distance', 'in',
|
||||
'A length at each end of the panel excluded from the flexure '
|
||||
'check (strand not yet developed there). Leave blank for none.',
|
||||
False, 'num', None),
|
||||
'design_criteria.flexure_cracking_check': (
|
||||
'Flexural cracking check', '',
|
||||
'Which cracking check applies to the flexure calculation, if any. '
|
||||
'Leave blank for none.',
|
||||
False, 'text', None),
|
||||
# ------------------------------------------------ Settings/Resist. Fact.
|
||||
'phi_flexure': (
|
||||
'Bending strength reduction factor, developed', u'φ, tension',
|
||||
"The ACI safety factor applied to the panel's bending strength "
|
||||
'once the strand is fully developed. Blank uses the built-in '
|
||||
'default (0.90).',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_compression': (
|
||||
'Compression strength reduction factor, developed',
|
||||
u'φ, compression',
|
||||
"The ACI safety factor applied to the panel's compression "
|
||||
'strength once the strand is fully developed. Leave blank to use '
|
||||
'the built-in default.',
|
||||
False, 'num', None),
|
||||
'phi_shear': (
|
||||
'Shear strength reduction factor, developed', u'φ, shear',
|
||||
"The ACI safety factor applied to the panel's shear strength once "
|
||||
'the strand is fully developed. Blank uses the built-in default '
|
||||
'(0.75).',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_tension_transfer': (
|
||||
'Bending strength reduction factor, transferring',
|
||||
u'φ, tension',
|
||||
'The same safety factor as above, but while the strand is still '
|
||||
'transferring its force into the concrete. Leave blank if not '
|
||||
'applicable.',
|
||||
False, 'num', None),
|
||||
'design_criteria.phi_shear_transfer': (
|
||||
'Shear strength reduction factor, transferring', u'φ, shear',
|
||||
'The same safety factor as above, but while the strand is still '
|
||||
'transferring its force into the concrete. Leave blank if not '
|
||||
'applicable.',
|
||||
False, 'num', None),
|
||||
'dev_mult': (
|
||||
'Strand development multiplier, normal', '',
|
||||
'A multiplier on the strand development length for ordinary '
|
||||
'(non-debonded, uncut) strand. Blank uses the built-in default '
|
||||
'(1.0).',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_debonded': (
|
||||
'Strand development multiplier, debonded', '',
|
||||
'The same multiplier, for strand that is deliberately debonded '
|
||||
'over part of its length. Leave blank to use the built-in '
|
||||
'default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_cut': (
|
||||
'Strand development multiplier, cut', '',
|
||||
'The same multiplier, for strand that is field-cut rather than '
|
||||
'released normally. Leave blank to use the built-in default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.dev_mult_mild': (
|
||||
'Development multiplier, mild reinforcement', '',
|
||||
'The same idea applied to ordinary (non-prestressed) reinforcing '
|
||||
'bar. Leave blank to use the built-in default.',
|
||||
False, 'num', None),
|
||||
'design_criteria.shear_treat_as_column': (
|
||||
'Treat this panel as a column for shear?', '',
|
||||
'Whether the shear check uses column rules rather than wall '
|
||||
'rules. Leave unchecked to use wall rules.',
|
||||
False, 'check', None),
|
||||
'design_criteria.axial_ignore_tensile_failures': (
|
||||
'Ignore axial tensile-strength failures?', '',
|
||||
'Whether the axial check is allowed to pass even when the '
|
||||
"concrete's tensile strength alone is exceeded. Leave unchecked "
|
||||
'to flag them.',
|
||||
False, 'check', None),
|
||||
'design_criteria.axial_ignore_buckling_failures': (
|
||||
'Ignore buckling failures?', '',
|
||||
'Whether the axial check is allowed to pass even when the panel '
|
||||
'would buckle. Leave unchecked to flag them.',
|
||||
False, 'check', None),
|
||||
}
|
||||
|
||||
# Prestress-loss group: shown as a sentence when every one of these is blank
|
||||
# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here"
|
||||
# style for Exterior/Interior Soil, applied to the one other Design Criteria
|
||||
# group that is entirely optional and entirely empty on a fresh panel.
|
||||
_PRESTRESS_LOSS_FIELDS = (
|
||||
'ps_loss_method', 'ps_loss_humidity_pct',
|
||||
'ps_loss_release_days', 'ps_loss_erection_days',
|
||||
)
|
||||
_F['ps_loss_method'] = (
|
||||
'Prestress loss method', '',
|
||||
'Which published method (PCI, AASHTO, ...) the assumed loss below '
|
||||
'comes from. Reference only.',
|
||||
False, 'text', None)
|
||||
_F['ps_loss_humidity_pct'] = (
|
||||
'Ambient humidity assumed', '%',
|
||||
'The relative humidity the loss method uses -- losses are larger in '
|
||||
'drier air. Not yet used by the solve.',
|
||||
False, 'num', None)
|
||||
_F['ps_loss_release_days'] = (
|
||||
'Days from casting to release', '',
|
||||
'How many days after casting the strand is cut / the panel is '
|
||||
'stripped. Not yet used by the solve.',
|
||||
False, 'num', None)
|
||||
_F['ps_loss_erection_days'] = (
|
||||
'Days from casting to erection', '',
|
||||
'How many days after casting the panel is erected in place. Not yet '
|
||||
'used by the solve.',
|
||||
False, 'num', None)
|
||||
|
||||
|
||||
def _field(key, problems, seen, existing):
|
||||
"""One `field` block for `key`, plus a `problems` entry if it is missing.
|
||||
|
||||
`seen` de-dupes: several groups below reuse a couple of keys (none do
|
||||
today, but the guard costs nothing and keeps a future edit from quietly
|
||||
filing the same problem twice). `existing` is `_existing_keys()`'s
|
||||
result, threaded through rather than re-read per field so one broken
|
||||
import during a render is reported once, not dozens of times.
|
||||
"""
|
||||
spec = _F.get(key)
|
||||
if spec is None:
|
||||
# Programming error inside this file, not a missing form key --
|
||||
# still degrade rather than raise, and say so plainly.
|
||||
problems.append(
|
||||
"Design Criteria has no text authored for the key '{0}' -- "
|
||||
'this is a bug in pa_criteria.py, not a missing form field.'
|
||||
.format(key))
|
||||
return {'kind': 'field', 'key': key, 'label': key, 'symbol': None,
|
||||
'hint': None, 'required': False, 'editor': 'text',
|
||||
'choices': None}
|
||||
label, symbol, hint, required, editor, choices = spec
|
||||
if existing is None or key not in existing:
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
problems.append(
|
||||
"'{0}' ({1}) has no backing field in panelanalysisform.FIELDS "
|
||||
'yet -- shown as an empty editor until it is added.'
|
||||
.format(label, key))
|
||||
return {'kind': 'field', 'key': key, 'label': label,
|
||||
'symbol': symbol or None, 'hint': hint, 'required': required,
|
||||
'editor': editor, 'choices': list(choices) if choices else None}
|
||||
|
||||
|
||||
def _heading(text, level):
|
||||
return {'kind': 'heading', 'text': text, 'level': level}
|
||||
|
||||
|
||||
def _cols(columns):
|
||||
return {'kind': 'columns', 'columns': columns}
|
||||
|
||||
|
||||
def _sentence(text):
|
||||
return {'kind': 'sentence', 'text': text}
|
||||
|
||||
|
||||
def _rule():
|
||||
return {'kind': 'rule'}
|
||||
|
||||
|
||||
def _space(h=1):
|
||||
return {'kind': 'space', 'h': h}
|
||||
|
||||
|
||||
def _group_or_sentence(values, keys, empty_text, build_filled):
|
||||
"""[blocks] -- a `sentence` when every key in `keys` is blank, else
|
||||
whatever `build_filled()` returns.
|
||||
|
||||
This is the "nothing here vs. never asked" split applied at group
|
||||
granularity: `keys` all being blank in `values` is a real, filled-in
|
||||
answer ("nothing entered"), not the absence of a form -- so it is shown
|
||||
as a plain sentence instead of a wall of empty boxes, the same way
|
||||
Eriksson's own printout shows a caption instead of blank inputs when
|
||||
there is nothing to report. `values` is read with `.get(...)`, never
|
||||
indexed, and never mutated.
|
||||
|
||||
`build_filled` is a zero-argument CALLABLE, not a plain list -- the
|
||||
filled-in branch typically calls `F(key)`, which files a `problems`
|
||||
entry for any key still missing from `panelanalysisform.FIELDS`, and
|
||||
that side effect must only happen for fields actually being shown. A
|
||||
plain list argument would be built eagerly by the caller before this
|
||||
function ever looks at `keys`, filing a phantom "missing field" problem
|
||||
for a field the empty-sentence branch never renders.
|
||||
"""
|
||||
for k in keys:
|
||||
v = values.get(k)
|
||||
if v is not None and str(v).strip():
|
||||
return build_filled()
|
||||
return [_sentence(empty_text)]
|
||||
|
||||
|
||||
# --------------------------------------------------------- load combinations
|
||||
#
|
||||
# Standard ACI 318-14 basic strength/service combinations -- public code
|
||||
# text, the same eight/ten rows any ACI-compliant tool would print, not this
|
||||
# job's data. Kept as a fixed reference table (see the module docstring's
|
||||
# "WHY THE 3-COLUMN CONCRETE GRID ... `columns`, NOT `table`" note for why
|
||||
# THIS table, unlike the material grids, is a real `table` block): the only
|
||||
# thing a user edits per row is which combinations apply, via the Include
|
||||
# tick, and `table` already carries a native check-editor cell for that.
|
||||
_STRENGTH_COMBOS = (
|
||||
('1', '1.4', '0', '0', '0', '0', '1', '1.6'),
|
||||
('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'),
|
||||
('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'),
|
||||
('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'),
|
||||
('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'),
|
||||
('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'),
|
||||
('7', '0.9', '0', '0', '1', '0', '1', '1.6'),
|
||||
('8', '0.9', '0', '0', '0', '1', '1', '1.6'),
|
||||
)
|
||||
_SERVICE_COMBOS = (
|
||||
('1', '1', '0', '0', '0', '0', '0.75', '1'),
|
||||
('2', '1', '1', '0', '0', '0', '0.75', '1'),
|
||||
('3', '1', '0', '1', '0', '0', '0.75', '1'),
|
||||
('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'),
|
||||
('5', '1', '0', '0', '0.6', '0', '0.75', '1'),
|
||||
('6', '1', '0', '0', '0', '0.7', '0.75', '1'),
|
||||
('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'),
|
||||
('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'),
|
||||
('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'),
|
||||
('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'),
|
||||
)
|
||||
_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include']
|
||||
|
||||
|
||||
def _combo_table(name, rows, problems):
|
||||
table_rows = []
|
||||
for row in rows:
|
||||
n = row[0]
|
||||
include_key = 'load_combo.{0}.{1}.include'.format(name, n)
|
||||
table_rows.append(list(row) + [{'editor': 'check', 'key': include_key}])
|
||||
problems.append(
|
||||
"the Include tick boxes in the {0} load-combination table have no "
|
||||
"backing fields yet (load_combo.{0}.<n>.include) -- shown as empty "
|
||||
"check boxes.".format(name))
|
||||
return {'kind': 'table', 'headers': list(_COMBO_HEADERS),
|
||||
'header_groups': None, 'rows': table_rows, 'row_key': None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- page
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Design Criteria section.
|
||||
|
||||
`values` is the form's current string-valued dict, read defensively --
|
||||
every lookup goes through `.get(...)` and a key this module expects but
|
||||
does not find is treated as blank, never as an error, because another
|
||||
agent is restructuring `panelanalysisform.FIELDS` at the same time this
|
||||
is being written. `values` is never mutated. `reads` is optional and, on
|
||||
this section, only used for the one-line "Panel <mark>" context caption
|
||||
at the top -- everything else on Design Criteria is job-level or
|
||||
material data, not something read off the framing element.
|
||||
|
||||
Never raises: every lookup that could fail (the `panelanalysisform`
|
||||
import, a missing dict key) degrades to a `problems` entry instead.
|
||||
"""
|
||||
values = values if isinstance(values, dict) else {}
|
||||
reads = reads if isinstance(reads, dict) else {}
|
||||
problems = []
|
||||
seen = set()
|
||||
existing = _existing_keys()
|
||||
if existing is None:
|
||||
problems.append(
|
||||
'could not read panelanalysisform.FIELDS to check which keys '
|
||||
'already exist (the module may be mid-edit); every field on '
|
||||
'this page is shown, and is reported as missing until this is '
|
||||
'confirmed.')
|
||||
|
||||
def F(key):
|
||||
return _field(key, problems, seen, existing)
|
||||
|
||||
blocks = []
|
||||
blocks.append(_heading('Design Criteria', 1))
|
||||
|
||||
mark = (reads.get('mark') or '').strip()
|
||||
design_number = (reads.get('design_number') or '').strip()
|
||||
if mark or design_number:
|
||||
blocks.append({'kind': 'kv', 'rows': [
|
||||
('Panel', mark or '(mark not read)'),
|
||||
('Design number', design_number or '(not read)'),
|
||||
]})
|
||||
|
||||
# ----------------------------------------------------- Project Information
|
||||
blocks.append(_heading('Project Information', 2))
|
||||
blocks.append(_cols([
|
||||
[F('project_name'), F('client_name'), F('project_description')],
|
||||
[F('project_location'), F('engineer_initials')],
|
||||
[F('project_date'), F('job_number'), F('design_spec')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ------------------------------------------------------ Material Properties
|
||||
blocks.append(_heading('Material Properties', 2))
|
||||
blocks.append(_heading('Concrete', 3))
|
||||
blocks.append(_cols([
|
||||
[F('fci_psi'), F('fc_psi')],
|
||||
[F('conc_eci_psi'), F('conc_ec_psi')],
|
||||
[F('unit_weight_pcf'), F('concrete_type')],
|
||||
]))
|
||||
blocks.append(_space(1))
|
||||
blocks.append(_cols([
|
||||
[_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')],
|
||||
[_heading('Mesh', 3),
|
||||
_sentence('No mesh material properties are collected on this '
|
||||
'form yet.')],
|
||||
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ---------------------------------------------------------- Prestress Loss
|
||||
blocks.append(_cols([
|
||||
[_heading('Prestress Losses', 2)] +
|
||||
_group_or_sentence(
|
||||
values, _PRESTRESS_LOSS_FIELDS,
|
||||
'No prestress loss assumptions entered for this panel.',
|
||||
lambda: [F(k) for k in _PRESTRESS_LOSS_FIELDS]),
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# -------------------------------------------------------------- Soil loads
|
||||
#
|
||||
# `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR
|
||||
# SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing
|
||||
# to degrade to an editor for here.
|
||||
problems.append(
|
||||
'soil loads are not collected anywhere in this form yet; Exterior '
|
||||
'Soil and Interior Soil always read as "no loads" until a later '
|
||||
'stage adds that input.')
|
||||
blocks.append(_cols([
|
||||
[_heading('Exterior Soil', 2),
|
||||
_sentence('Design contains no exterior soil loads.')],
|
||||
[_heading('Interior Soil', 2),
|
||||
_sentence('Design contains no interior soil loads.')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# ----------------------------------------------------------- Load Combos
|
||||
blocks.append(_heading('Load Combinations', 1))
|
||||
blocks.append(_cols([
|
||||
[_heading('Strength Combinations', 2),
|
||||
_combo_table('strength', _STRENGTH_COMBOS, problems),
|
||||
F('design_criteria.min_earth_factor_strength')],
|
||||
[_heading('Service Combinations', 2),
|
||||
_combo_table('service', _SERVICE_COMBOS, problems),
|
||||
F('design_criteria.min_earth_factor_service')],
|
||||
]))
|
||||
blocks.append(_rule())
|
||||
|
||||
# --------------------------------------------------------------- Settings
|
||||
blocks.append(_heading('Settings', 1))
|
||||
col_a = [
|
||||
_heading('Deflection Limits', 2),
|
||||
F('design_criteria.deflection_add_bow'),
|
||||
F('design_criteria.deflection_additional_bow_in'),
|
||||
F('deflection_limit'),
|
||||
F('design_criteria.deflection_check_cantilever'),
|
||||
F('design_criteria.deflection_cantilever_limit'),
|
||||
_heading('Structural Analysis', 2),
|
||||
F('second_order'),
|
||||
F('design_criteria.analysis_beta_d'),
|
||||
F('design_criteria.cracking_method'),
|
||||
F('design_criteria.cracking_limit_sqrt_fc'),
|
||||
F('design_criteria.rvalue_calc_enabled'),
|
||||
] + _group_or_sentence(
|
||||
values, ('design_criteria.rvalue_calc_enabled',),
|
||||
'R-value is not being calculated for this panel.', lambda: []) + [
|
||||
F('design_criteria.min_reinf_checked'),
|
||||
] + _group_or_sentence(
|
||||
values, ('design_criteria.min_reinf_checked',),
|
||||
'Minimum steel is not being checked for this panel.', lambda: [])
|
||||
|
||||
col_b = [
|
||||
_heading('Stress Options', 2),
|
||||
F('design_criteria.stress_failure_if_cracked'),
|
||||
F('design_criteria.stress_ignore_lateral_ecc'),
|
||||
F('design_criteria.stress_use_principal_axis'),
|
||||
_heading('In-Place Stress Limits', 2),
|
||||
F('design_criteria.inplace_modulus_rupture_sqrt_fc'),
|
||||
F('inplace_comp_ratio'),
|
||||
_heading('Handling Stress Limits', 2),
|
||||
F('handling_tension_sqrt'),
|
||||
F('handling_comp_ratio'),
|
||||
_heading('Flexure Options', 2),
|
||||
F('design_criteria.flexure_method'),
|
||||
F('design_criteria.flexure_ignored_end_distance_in'),
|
||||
F('design_criteria.flexure_cracking_check'),
|
||||
]
|
||||
|
||||
col_c = [
|
||||
_heading('Resistance Factors', 2),
|
||||
_cols([
|
||||
[_heading('Developed', 3),
|
||||
F('phi_flexure'), F('design_criteria.phi_compression'),
|
||||
F('phi_shear')],
|
||||
[_heading('Transferring', 3),
|
||||
F('design_criteria.phi_tension_transfer'),
|
||||
_sentence('Not applicable for compression once the strand '
|
||||
'has fully transferred.'),
|
||||
F('design_criteria.phi_shear_transfer')],
|
||||
]),
|
||||
_heading('Development Multipliers', 2),
|
||||
F('dev_mult'),
|
||||
F('design_criteria.dev_mult_debonded'),
|
||||
F('design_criteria.dev_mult_cut'),
|
||||
F('design_criteria.dev_mult_mild'),
|
||||
_heading('Shear Options', 2),
|
||||
F('design_criteria.shear_treat_as_column'),
|
||||
_heading('Axial Options', 2),
|
||||
F('design_criteria.axial_ignore_tensile_failures'),
|
||||
F('design_criteria.axial_ignore_buckling_failures'),
|
||||
]
|
||||
|
||||
blocks.append(_cols([col_a, col_b, col_c]))
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,570 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Panel Analysis input form -- the "Concrete Extents" section, laid out.
|
||||
|
||||
The owner has said three times the Panel Analysis form is too ugly to read
|
||||
("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成
|
||||
普通人能看懂的样子" -- every part of it has to be something an ordinary
|
||||
person, not just an engineer, can understand. The fix agreed on is to make
|
||||
the form look like Eriksson Wall's own printed page for this section (a solid
|
||||
wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals /
|
||||
Solid Zones table, and a drawings row) but with a plain-English `label`
|
||||
beside every engineering `symbol` and a one-sentence `hint` on every field --
|
||||
never the symbol alone, standing in as if it were the explanation.
|
||||
|
||||
`page(values, reads=None)` returns `(blocks, problems)`: a flat list of block
|
||||
dicts from a fixed vocabulary a generic WPF renderer (built separately, not
|
||||
here) consumes, plus a list of plain-English sentences for anything that
|
||||
could not be laid out. This module never raises -- a field it cannot place
|
||||
becomes a `problems` entry, never a traceback the owner has to read a
|
||||
Python stack to understand.
|
||||
|
||||
WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to
|
||||
load and run on plain CPython under `tests/conftest.py`'s trick (see that
|
||||
file's docstring) the same way the rest of Revit-free `vibe` does, and it has
|
||||
to run under IronPython 2.7 inside the button. `panelsketch` is named by
|
||||
STRING in a `drawing` block's `source` field -- the renderer calls it, not
|
||||
this module -- because a sibling agent is extending `panelsketch` at the same
|
||||
time this file is being written, and importing a module mid-rewrite is how
|
||||
two agents' work collides.
|
||||
|
||||
WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns
|
||||
`vibe.panelanalysisform`, the source of the form's key names, and is
|
||||
restructuring it at the same time this file is being written. A key this
|
||||
module expects that is not actually there is "not present", never treated as
|
||||
a bug -- `_paf_field` returns `None` and the caller falls back rather than
|
||||
raising. The same discipline applies to the structured opening rows a third
|
||||
agent is meant to be adding at `values['openings_rows']` (shaped
|
||||
`{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross',
|
||||
'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's
|
||||
own contract): when it is not there yet, or is not a list, this module falls
|
||||
back to the raw `values['openings']` text field and SAYS SO in `problems`,
|
||||
rather than guessing at a shape that has not landed.
|
||||
|
||||
WHY THE SECTION PROPERTIES BLOCK OMITS MOST OF ERIKSSON'S OWN ROWS: Eriksson
|
||||
prints Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb and St, but the last
|
||||
seven need either an axis convention this module has no authority to invent,
|
||||
or a net-of-openings deduction it does not compute. Printing a plausible
|
||||
number in a column headed "St, in³" when nobody has verified it is exactly
|
||||
the fabricated section modulus the owner has ruled against elsewhere in this
|
||||
codebase (`CLAUDE.md` "no invented default" discipline) -- so this module
|
||||
computes ONLY Area / Width / Height, from the panel's own width and
|
||||
thickness, and names the rest in `problems` as omitted, never fills them in
|
||||
with a guess.
|
||||
|
||||
WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT:
|
||||
`panelsketch.elevation()`'s own docstring says it draws the front elevation
|
||||
BESIDE a side elevation as one combined picture, not two separate ones.
|
||||
Eriksson's page prints them as two separately captioned drawings, and this
|
||||
module is asked to lay out a `columns` row of exactly that shape -- so it
|
||||
does, naming `source: 'elevation'` under both the "Side Elevation" and
|
||||
"Front Elevation" captions, and says in `problems` that until `panelsketch`
|
||||
is split in two, both captions currently draw the same combined image. That
|
||||
is a real, current limitation, not something to paper over by inventing a
|
||||
`source` name that does not exist.
|
||||
|
||||
Miters get the same "print a sentence, never leave a blank" discipline
|
||||
Eriksson uses on its own page (an unchecked "Left Miter" box there is not
|
||||
just left bare -- it prints "Left miter not included in design" underneath).
|
||||
`_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is
|
||||
silent, because blank means "not yet decided", never "no" (same rule
|
||||
`panelanalysisform._parse_bool` documents for its own checkboxes); a miter
|
||||
answered explicitly "no" prints the sentence, in words, the way Eriksson
|
||||
does.
|
||||
"""
|
||||
|
||||
# `panelanalysisform` carries no Revit import at module scope (see its own
|
||||
# docstring), so this succeeds on plain CPython under `tests/conftest.py`'s
|
||||
# trick and under IronPython 2.7 alike. If the sibling agent restructuring it
|
||||
# has, at some moment, left it unimportable, this module still has to load --
|
||||
# so the import itself is guarded, and every reader of `paf` below is guarded
|
||||
# again on top of that (see `_paf_field`).
|
||||
try:
|
||||
from vibe import panelanalysisform as paf
|
||||
except ImportError:
|
||||
paf = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- utilities
|
||||
|
||||
def _num(value):
|
||||
"""`value` (a form string, or already a number) as a float, or None.
|
||||
|
||||
None covers "blank", "not a number", and "not present" alike -- the
|
||||
caller only ever needs to know whether there is a usable number, and
|
||||
`validate()`-style error text is not this module's job (the button's
|
||||
completeness gate already owns that; this module only lays out what it
|
||||
is given).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_num(value):
|
||||
"""A float as the plain string this page prints -- '8', not '8.0'."""
|
||||
if value is None:
|
||||
return ''
|
||||
if float(value) == int(value):
|
||||
return str(int(value))
|
||||
return ('%.4f' % value).rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
# Eighths-of-an-inch -> the fraction word Eriksson prints. A precast
|
||||
# dimension finer than 1/8" is not meaningful, so `_feet_str` rounds to it
|
||||
# rather than pretending to more precision than the field carries.
|
||||
_EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2',
|
||||
5: '5/8', 6: '3/4', 7: '7/8'}
|
||||
|
||||
|
||||
def _feet_str(value):
|
||||
"""Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2".
|
||||
|
||||
Internal units are decimal feet throughout this codebase (`CLAUDE.md`
|
||||
§3), so a bare number arriving in a position/length field is read as
|
||||
feet and converted here to the feet-inches form Eriksson prints (`8"`,
|
||||
`2'-11.5"`). A value that is already a string is left untouched by the
|
||||
caller (`_dim_or_dash`) rather than reparsed here -- a sibling agent may
|
||||
hand this module an already-formatted dimension, and reformatting a
|
||||
string that already reads correctly is how a "2'-11.5\"" becomes a wrong
|
||||
"2'-11 1/2\"" glued onto a stray decimal.
|
||||
|
||||
Returns None only when `value` cannot be read as a number at all.
|
||||
"""
|
||||
try:
|
||||
feet = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
negative = feet < 0.0
|
||||
feet = abs(feet)
|
||||
whole_ft = int(feet)
|
||||
inches = (feet - whole_ft) * 12.0
|
||||
eighths_total = int(round(inches * 8.0))
|
||||
whole_in, eighths = divmod(eighths_total, 8)
|
||||
if whole_in >= 12:
|
||||
whole_ft += whole_in // 12
|
||||
whole_in = whole_in % 12
|
||||
frac = _EIGHTHS[eighths]
|
||||
if frac and whole_in:
|
||||
in_part = '{0} {1}'.format(whole_in, frac)
|
||||
elif frac:
|
||||
in_part = frac
|
||||
else:
|
||||
in_part = str(whole_in)
|
||||
if whole_ft > 0:
|
||||
text = u'{0}\'-{1}"'.format(whole_ft, in_part)
|
||||
else:
|
||||
text = u'{0}"'.format(in_part)
|
||||
return u'-' + text if negative else text
|
||||
|
||||
|
||||
def _dim_or_dash(value):
|
||||
"""A table cell for one dimension: Eriksson's own '-' for blank."""
|
||||
if value is None:
|
||||
return '-'
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return value if value else '-'
|
||||
text = _feet_str(value)
|
||||
return text if text is not None else '-'
|
||||
|
||||
|
||||
def _from_word(word):
|
||||
"""'start' -> 'Start'; blank/None -> ''. Never invents a datum word."""
|
||||
if not word:
|
||||
return ''
|
||||
word = str(word).strip()
|
||||
return word[:1].upper() + word[1:].lower() if word else ''
|
||||
|
||||
|
||||
def _pos_cell(pos, frm):
|
||||
"""A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'."""
|
||||
dim = _dim_or_dash(pos)
|
||||
if dim == '-':
|
||||
return '-'
|
||||
frm_text = _from_word(frm)
|
||||
return '{0} from {1}'.format(dim, frm_text) if frm_text else dim
|
||||
|
||||
|
||||
_TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal',
|
||||
'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'}
|
||||
|
||||
|
||||
def _type_label(value):
|
||||
"""The Type column's text -- a known word capitalised Eriksson's way,
|
||||
|
||||
or, for anything this table does not recognise, the entered text back
|
||||
in title case rather than a guessed category -- echoing what was typed
|
||||
is honest; picking the "closest" known type is not.
|
||||
"""
|
||||
if not value:
|
||||
return '-'
|
||||
key = str(value).strip().lower().replace('_', ' ')
|
||||
if key in _TYPE_LABELS:
|
||||
return _TYPE_LABELS[key]
|
||||
return str(value).strip().title() if str(value).strip() else '-'
|
||||
|
||||
|
||||
def _tri_bool(text):
|
||||
"""(True/False/None) for a checkbox's raw text. None = "not yet said".
|
||||
|
||||
Mirrors `panelanalysisform._parse_bool`'s own rule (blank is
|
||||
"unspecified", never "no") without importing that private helper across
|
||||
a module boundary neither module owns the other's internals of.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
t = str(text).strip().lower()
|
||||
if not t:
|
||||
return None
|
||||
if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'):
|
||||
return True
|
||||
if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded',
|
||||
'unchecked'):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------- panelanalysisform
|
||||
|
||||
def _paf_field(key):
|
||||
"""The raw field dict `panelanalysisform.FIELDS` has for `key`, or None.
|
||||
|
||||
None covers every way `paf` can fail to have the answer: the module
|
||||
failed to import, it has no `FIELDS` (mid-restructure), or `key` is
|
||||
simply not one it declares yet. All three are "not present", never an
|
||||
error -- see the module docstring.
|
||||
"""
|
||||
if paf is None:
|
||||
return None
|
||||
try:
|
||||
fields = paf.FIELDS
|
||||
except AttributeError:
|
||||
return None
|
||||
try:
|
||||
for f in fields:
|
||||
if f.get('key') == key:
|
||||
return f
|
||||
except (TypeError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _field(key, editor, fallback_label, fallback_symbol, fallback_hint,
|
||||
fallback_required, problems, choices=None):
|
||||
"""One `field` block, sourced from `panelanalysisform` when it has the
|
||||
key and from the fallback text when it does not.
|
||||
|
||||
A missing key still gets a field on the page -- the Eriksson layout this
|
||||
section is copying has a slot there -- but `problems` names it, because
|
||||
a value typed into a field `panelanalysisform` does not declare cannot
|
||||
be saved into the deck yet. That is the honest state of a form being
|
||||
restructured by another agent at the same time, not a bug in this file.
|
||||
"""
|
||||
spec = _paf_field(key)
|
||||
if spec is not None and paf is not None:
|
||||
try:
|
||||
label = paf.label_of(key)
|
||||
symbol = paf.symbol_of(key) or None
|
||||
hint = paf.help_of(key) or fallback_hint
|
||||
required = bool(spec.get('required'))
|
||||
except AttributeError:
|
||||
spec = None
|
||||
if spec is None:
|
||||
label = fallback_label
|
||||
symbol = fallback_symbol or None
|
||||
hint = fallback_hint
|
||||
required = fallback_required
|
||||
problems.append(
|
||||
"{0!r} is not a field vibe.panelanalysisform declares yet; "
|
||||
"shown here to match Eriksson's own Concrete Extents page, but "
|
||||
"a value typed into it will not be saved until that module "
|
||||
"adds this key.".format(key))
|
||||
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
|
||||
'hint': hint, 'required': required, 'editor': editor,
|
||||
'choices': choices}
|
||||
|
||||
|
||||
def _miter_sentence(values, key, side):
|
||||
"""Eriksson's own discipline: an OFF miter prints a sentence, not a gap.
|
||||
|
||||
Blank (unanswered) prints nothing here, same as Eriksson's page shows
|
||||
nothing extra for a miter nobody has touched yet -- only an explicit
|
||||
"no" earns the sentence, because blank means "not yet decided", not
|
||||
"excluded" (see `_tri_bool`).
|
||||
"""
|
||||
if _tri_bool(values.get(key)) is False:
|
||||
return {'kind': 'sentence',
|
||||
'text': '{0} miter not included in this panel\'s design.'
|
||||
.format(side)}
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- sub-sections
|
||||
|
||||
def _panel_geometry_columns(values, problems):
|
||||
"""The three columns Eriksson's own Panel Geometry block prints:
|
||||
|
||||
(Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) |
|
||||
(Width, Left Miter, Right Miter, Top In Form, Width, Height) |
|
||||
(Thickness).
|
||||
|
||||
`length_ft` (this codebase's name for the panel's long dimension) is
|
||||
what Eriksson calls "Height" on this page -- the same field, read
|
||||
through `panelanalysisform`'s own label rather than renamed here, so a
|
||||
reviewer sees the one name every section of the form uses for it.
|
||||
"""
|
||||
col1 = [
|
||||
_field('top_elevation_ft', 'num', 'Top elevation', 'ft',
|
||||
'How high the top edge of the panel sits above the '
|
||||
'project reference elevation. Leave blank if not yet '
|
||||
'known.', False, problems),
|
||||
_field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft',
|
||||
'How high the bottom edge of the panel sits above the '
|
||||
'project reference elevation. Leave blank if not yet '
|
||||
'known.', False, problems),
|
||||
_field('length_ft', 'num', 'Panel length', 'ft',
|
||||
"The panel's long dimension, end to end. Required -- "
|
||||
'everything downstream is measured against this span.',
|
||||
True, problems),
|
||||
_field('section_type', 'text', 'Section type', '',
|
||||
'A short description of the cross-section (solid, '
|
||||
'sandwich, ribbed, ...). Reference only.', False, problems),
|
||||
_field('top_in_form', 'text', 'Which face is up in the casting '
|
||||
'bed', 'Interior / Exterior',
|
||||
'Which face of the panel faces up while it is cast. '
|
||||
'Reference only.', False, problems,
|
||||
choices=['Interior', 'Exterior']),
|
||||
]
|
||||
|
||||
col2 = [
|
||||
_field('width_ft', 'num', 'Panel width', 'ft',
|
||||
"The panel's dimension across the span -- how wide it is. "
|
||||
'Required.', True, problems),
|
||||
_field('left_miter', 'check', 'Left miter', '',
|
||||
'Whether the left end of the panel is cut at an angle '
|
||||
'(mitered) instead of square. Leave unchecked if this has '
|
||||
'not been decided yet.', False, problems),
|
||||
]
|
||||
sentence = _miter_sentence(values, 'left_miter', 'Left')
|
||||
if sentence:
|
||||
col2.append(sentence)
|
||||
col2.append(
|
||||
_field('right_miter', 'check', 'Right miter', '',
|
||||
'Whether the right end of the panel is cut at an angle '
|
||||
'(mitered) instead of square. Leave unchecked if this has '
|
||||
'not been decided yet.', False, problems))
|
||||
sentence = _miter_sentence(values, 'right_miter', 'Right')
|
||||
if sentence:
|
||||
col2.append(sentence)
|
||||
col2.append(
|
||||
_field('top_in_form_check', 'check',
|
||||
'Top edge has a formed step', '',
|
||||
'Whether the top edge of the panel has its own formed '
|
||||
'step or insert built in, separate from which face faces '
|
||||
'up (see "Which face is up" at left). Leave unchecked if '
|
||||
'there is none.', False, problems))
|
||||
col2.append(
|
||||
_field('top_in_form_width_in', 'num', 'Formed step width', 'in',
|
||||
'How wide the formed step at the top edge is, if there is '
|
||||
'one. Leave blank if there is no step.', False, problems))
|
||||
col2.append(
|
||||
_field('top_in_form_height_in', 'num', 'Formed step height', 'in',
|
||||
'How tall the formed step at the top edge is, if there is '
|
||||
'one. Leave blank if there is no step.', False, problems))
|
||||
|
||||
col3 = [
|
||||
_field('thickness_in', 'num', 'Panel thickness', 'in',
|
||||
'How thick the panel is. Required.', True, problems),
|
||||
]
|
||||
|
||||
return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2},
|
||||
{'kind': 'columns', 'columns': [col1, col2, col3]}]
|
||||
|
||||
|
||||
# The grouped header the Openings / Reveals / Solid Zones table prints,
|
||||
# spanning the last 6 of its 7 columns: 'Type' stands alone at the left the
|
||||
# way Eriksson's own page leaves it, and (2, 4) is what leaves the group
|
||||
# spans adding up to exactly `len(headers) - 1`.
|
||||
_OPENINGS_HEADERS = ['Type', 'Position', 'Length', 'Horizontal Position',
|
||||
'Width', 'Vertical Position', 'Height']
|
||||
_OPENINGS_GROUPS = [('Position along Length', 2), ('Position in Cross '
|
||||
'Section', 4)]
|
||||
|
||||
|
||||
def _openings_row_cells(row):
|
||||
row = row or {}
|
||||
return [
|
||||
_type_label(row.get('type')),
|
||||
_pos_cell(row.get('pos_along'), row.get('pos_along_from')),
|
||||
_dim_or_dash(row.get('length')),
|
||||
_pos_cell(row.get('pos_cross'), row.get('pos_cross_from')),
|
||||
_dim_or_dash(row.get('width')),
|
||||
_dim_or_dash(row.get('pos_vert')),
|
||||
_dim_or_dash(row.get('height')),
|
||||
]
|
||||
|
||||
|
||||
def _openings_blocks(values, problems):
|
||||
"""The Openings, Reveals, and Solid Zones heading + table (or fallback).
|
||||
|
||||
`values['openings_rows']` is the structured shape a sibling agent is
|
||||
meant to be adding (see the module docstring); when it is missing, or
|
||||
is not a list, this degrades to the raw `values['openings']` text field
|
||||
and a `problems` entry -- never a guessed re-parse of that free text
|
||||
into the grouped shape, which would be inventing datum words
|
||||
("from Start" / "from Left") nobody has actually said.
|
||||
"""
|
||||
blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid '
|
||||
'Zones', 'level': 2}]
|
||||
rows = values.get('openings_rows') if values else None
|
||||
|
||||
if isinstance(rows, list):
|
||||
table_rows = [_openings_row_cells(r) for r in rows]
|
||||
blocks.append({'kind': 'table', 'headers': _OPENINGS_HEADERS,
|
||||
'header_groups': _OPENINGS_GROUPS,
|
||||
'rows': table_rows, 'row_key': None})
|
||||
if not rows:
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': 'No openings, reveals, or solid zones '
|
||||
'entered.'})
|
||||
return blocks
|
||||
|
||||
problems.append(
|
||||
"'openings_rows' is not present in the form values (or is not a "
|
||||
'list), so the grouped Position-along-Length / Position-in-'
|
||||
'Cross-Section table cannot be built; falling back to the raw '
|
||||
"'openings' text field.")
|
||||
raw = (values.get('openings') if values else None) or ''
|
||||
raw = raw.strip() if isinstance(raw, str) else str(raw).strip()
|
||||
if raw:
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': 'Openings were entered as plain text and '
|
||||
'cannot be shown as the grouped table yet: '
|
||||
'{0}'.format(raw)})
|
||||
else:
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': 'No openings, reveals, or solid zones '
|
||||
'entered.'})
|
||||
return blocks
|
||||
|
||||
|
||||
# Every Section Properties row Eriksson's own page prints. Only the first
|
||||
# three (`_DERIVABLE_PROPS`) are ever computed here -- see the module
|
||||
# docstring for why the rest are refused rather than guessed.
|
||||
_DERIVABLE_PROPS = ('Area, in²', 'Width, in', 'Height, in')
|
||||
_OMITTED_PROPS = ('Cx, in', 'Cy, in', 'Izz, in⁴', 'Iyy, in⁴',
|
||||
'Izy, in⁴', 'Sb, in³', 'St, in³')
|
||||
|
||||
|
||||
def _section_properties_column(values, problems):
|
||||
"""The Section Properties kv list -- Area / Width / Height ONLY.
|
||||
|
||||
Both come straight from what the user entered (panel width, panel
|
||||
thickness) with no engineering judgement in between, so they are the
|
||||
only rows honest to print. `Height` here is Eriksson's name for the
|
||||
section's own depth through the panel, i.e. the panel THICKNESS -- not
|
||||
the panel's long dimension, which the Panel Geometry block above already
|
||||
calls "Height" in its own different sense. Printing both under the same
|
||||
word in two different places on the same page is exactly Eriksson's own
|
||||
layout, confusing as that is; relabelling one of them would stop this
|
||||
matching the page the owner asked for.
|
||||
"""
|
||||
col = [{'kind': 'heading', 'text': 'Section Properties', 'level': 3}]
|
||||
width_ft = _num(values.get('width_ft'))
|
||||
thickness_in = _num(values.get('thickness_in'))
|
||||
if width_ft is not None and width_ft > 0 and \
|
||||
thickness_in is not None and thickness_in > 0:
|
||||
width_in = width_ft * 12.0
|
||||
area_in2 = width_in * thickness_in
|
||||
rows = [('Area, in²', _fmt_num(area_in2)),
|
||||
('Width, in', _fmt_num(width_in)),
|
||||
('Height, in', _fmt_num(thickness_in))]
|
||||
col.append({'kind': 'kv', 'rows': rows})
|
||||
problems.append(
|
||||
'{0} are not shown: deriving them honestly needs an axis '
|
||||
'convention and, wherever openings are present, a '
|
||||
'net-of-openings deduction this module does not compute -- a '
|
||||
'guessed section modulus is worse than none.'
|
||||
.format(', '.join(_OMITTED_PROPS)))
|
||||
else:
|
||||
col.append({'kind': 'sentence',
|
||||
'text': 'Section properties are waiting on Panel '
|
||||
'width and Panel thickness; nothing is shown '
|
||||
'until both are entered.'})
|
||||
problems.append(
|
||||
'{0} could not be derived -- Panel width and/or Panel '
|
||||
'thickness is missing.'.format(
|
||||
', '.join(_DERIVABLE_PROPS + _OMITTED_PROPS)))
|
||||
return col
|
||||
|
||||
|
||||
def _drawings_row(problems):
|
||||
"""The Side Elevation / Front Elevation / Section Properties row.
|
||||
|
||||
Both drawings name `source: 'elevation'` -- see the module docstring's
|
||||
"WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT" for
|
||||
why that is today's correct, if unsatisfying, answer rather than a name
|
||||
this module invents to look like two different pictures.
|
||||
"""
|
||||
problems.append(
|
||||
"Side Elevation and Front Elevation both use panelsketch."
|
||||
'elevation(), which currently draws the side and front views '
|
||||
'together as ONE picture (per that function\'s own docstring) '
|
||||
'rather than as two separate drawings; until panelsketch is split '
|
||||
'in two, both captions below show the same combined image.')
|
||||
side_col = [{'kind': 'drawing', 'source': 'elevation',
|
||||
'caption': 'Side Elevation', 'w': 260, 'h': 520}]
|
||||
front_col = [{'kind': 'drawing', 'source': 'elevation',
|
||||
'caption': 'Front Elevation', 'w': 420, 'h': 520}]
|
||||
return side_col, front_col
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ page()
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Concrete Extents section.
|
||||
|
||||
`values` is the form's current string-valued dict (plus, where a
|
||||
sibling agent has landed it, the structured `openings_rows` list -- see
|
||||
the module docstring). `reads` is what the button read straight off the
|
||||
live framing element, the same shape `panelanalysisform.prefill` takes;
|
||||
it is accepted for symmetry with the other section builders but not
|
||||
read here -- everything this section needs is already folded into
|
||||
`values` by the time `page` sees it, and reading `reads` again here
|
||||
would risk showing a MODEL value beside a form value the user has since
|
||||
typed over it, exactly the kind of stale-looking page the owner has
|
||||
complained about.
|
||||
|
||||
Never raises. Every field this module cannot place is a `problems`
|
||||
sentence, not a traceback -- see the module docstring for the two
|
||||
running examples (a `panelanalysisform` key that is not there yet, and
|
||||
an `openings_rows` shape that has not landed yet).
|
||||
"""
|
||||
values = values if values is not None else {}
|
||||
problems = []
|
||||
blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}]
|
||||
|
||||
blocks.extend(_panel_geometry_columns(values, problems))
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
blocks.extend(_openings_blocks(values, problems))
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
side_col, front_col = _drawings_row(problems)
|
||||
props_col = _section_properties_column(values, problems)
|
||||
blocks.append({'kind': 'columns',
|
||||
'columns': [side_col, front_col, props_col]})
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
blocks.append({'kind': 'drawing', 'source': 'section',
|
||||
'caption': 'Cross Section', 'w': 900, 'h': 260})
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,528 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The Panel Analysis form's "Handling" section, rendered as generic blocks.
|
||||
|
||||
WHY THIS EXISTS: the owner has now said three times that the Panel Analysis
|
||||
input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能
|
||||
看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂
|
||||
的样子", make every part of it something an ordinary person can understand. The
|
||||
"Handling" section is the worst offender: it used to be three lonely text
|
||||
boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson
|
||||
Wall's own printed page for this section is a whole per-stage MATRIX --
|
||||
eleven questions asked once per handling stage, plus lifter/dunnage tables and
|
||||
a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring
|
||||
covers that ruling and the Eriksson-parity one before it) and now exposes a
|
||||
real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`,
|
||||
`stage_field_key`). This module is the second half: turning that data plus
|
||||
the plain-language field metadata into the flat, renderer-agnostic BLOCK list
|
||||
a generic WPF page consumes, matching Eriksson's own page 25/26 layout
|
||||
(Stage Options matrix + legend + weight/centroid + plan drawing + lifter and
|
||||
dunnage tables + Lifting Hardware) as closely as the two modules' real data
|
||||
will honestly support.
|
||||
|
||||
THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this
|
||||
module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' /
|
||||
'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown
|
||||
`kind` renders as nothing, so a typo here is a silently blank section rather
|
||||
than a crash; `page()` is tested against exactly that vocabulary for that
|
||||
reason.
|
||||
|
||||
READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and
|
||||
was mid-restructure of exactly the stage matrix this file depends on while
|
||||
this file was written. Every accessor this module leans on --
|
||||
`STAGES` / `STAGE_OPTIONS` / `stage_field_key` / `FIELDS` / `label_of` /
|
||||
`symbol_of` / `help_of` -- is reached through `getattr`/`hasattr` or a
|
||||
try/except, never assumed. A shape that is not there yet (or has changed
|
||||
shape) becomes one plain-English sentence in `problems`, and the rest of the
|
||||
page still renders around the hole -- never a raised exception, and never an
|
||||
invented value standing in for the one that could not be read. This is the
|
||||
same "could not judge" vs "judged, found nothing" discipline as everywhere
|
||||
else in this codebase (see CLAUDE.md SS3): a missing accessor is reported by
|
||||
name, not silently skipped and not guessed at.
|
||||
|
||||
WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry
|
||||
instead of a fabricated block: Eriksson's own "Handling" pages carry several
|
||||
things `vibe.panelanalysisform` does not model yet --
|
||||
|
||||
* a per-lifter STRUCTURED row (quantity, longitudinal position, two
|
||||
transverse positions, a tick per stage, "on top face") for Face / Side
|
||||
Edge Lifters. The form only has `face_lifters` / `edge_lifters` as two
|
||||
free-text fields ("column @ fraction of width", one line per lifter), so
|
||||
the structured table is rendered as those two text fields instead of an
|
||||
invented set of per-row keys nothing in the form can parse or validate.
|
||||
* a dedicated "top edge lifters" field. There isn't one -- only
|
||||
`face_lifters` / `edge_lifters` exist -- so the section renders a
|
||||
sentence saying so, in the same voice Eriksson's own page uses for "no
|
||||
top edge lifters on this design" (never a blank, per CLAUDE.md SS3's
|
||||
"nothing here" vs "never asked" rule -- an omitted field and an empty
|
||||
one must not look the same).
|
||||
* a "Dunnage" field of any kind. Same treatment.
|
||||
* the computed Panel Weight / Centroid block. `weight_lb` is a real field
|
||||
(the user's override, or what the model could read) so its row renders
|
||||
from `values`/`reads`; the three centroid components (X from start, Y
|
||||
from bottom, Z from left) are not produced anywhere in this pipeline yet,
|
||||
so those rows say so in plain words rather than printing a fabricated
|
||||
0.000.
|
||||
* the "Lifting Configurations" sketches on Eriksson's second page (one
|
||||
small diagram per included stage, each with its own G/CG marker) and the
|
||||
"Face Lifters / Name / Capacity Method" block above them. No
|
||||
`panelsketch` entry point for either exists (only `plan` and
|
||||
`weight_diagram` do, per the sibling agent adding them), and no
|
||||
panelanalysisform field carries a lifter name or capacity-check method --
|
||||
so both become one `problems` entry apiece and a short explanatory
|
||||
sentence, not an invented drawing source or an invented field.
|
||||
|
||||
THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which
|
||||
questions make sense for which stage, not a report of which stage happens to
|
||||
be ticked on right now. Reading Eriksson's own page 25 by column: Rigging
|
||||
Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt
|
||||
Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the
|
||||
two erection stages (Primary, Secondary) and dashed for Stripping and
|
||||
Transportation in EVERY sample seen, independent of whether a given erection
|
||||
stage is itself switched on -- a crane pick has a rigging configuration and a
|
||||
tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below
|
||||
is that fixed structural map. It is deliberately NOT keyed off each stage's
|
||||
own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix`
|
||||
is that switching a stage off must never make its OTHER cells un-requestable
|
||||
or drop what the user already typed into them, and printing an em dash over a
|
||||
cell the user could still type into would do exactly that. So the only cells
|
||||
this module ever marks `{'na': True}` are ones no stage state can turn back
|
||||
on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide.
|
||||
|
||||
Standard-library only. No Revit, no WPF, no `panelsketch` import (a source
|
||||
name is enough -- see the block vocabulary's 'drawing' kind). Runs on
|
||||
IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings,
|
||||
no dataclasses/typing/enum, `'{0}'.format(...)` throughout.
|
||||
"""
|
||||
|
||||
# Editor kind a FIELDS-style `kind` renders as, for the plain fields this
|
||||
# section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb,
|
||||
# self_weight_factor, face_lifters, edge_lifters). Anything not listed here
|
||||
# falls back to a free-text box -- 'supports' / 'openings' / 'strands' are
|
||||
# all typed as structured-but-still-one-text-box fields on the real form, and
|
||||
# nothing in the Handling section uses them, but the fallback keeps this
|
||||
# honest if that ever changes.
|
||||
_EDITOR_OF_KIND = {
|
||||
'text': 'text',
|
||||
'num': 'num',
|
||||
'int': 'num',
|
||||
'supports': 'text',
|
||||
'openings': 'text',
|
||||
'strands': 'text',
|
||||
}
|
||||
|
||||
# Fallback choice vocabulary for the free-text stage-matrix options, used
|
||||
# ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no
|
||||
# choices slot of its own (see `_unpack_stage_option`). Once the row carries
|
||||
# its own choices, that is authoritative and this is never consulted -- two
|
||||
# sources of truth for the same list would drift. These are OFFERED choices,
|
||||
# never a default -- the current value still comes from whatever is in
|
||||
# `values[key]`, blank if nothing was typed. Picking one is the user's
|
||||
# decision; the owner's instruction is explicit that none of them stand in
|
||||
# as a default.
|
||||
_STAGE_CHOICES_FALLBACK = {
|
||||
'lift_type': ['Static Lift', 'Rolling Block'],
|
||||
'concrete_strength': ['Initial', 'In-Place'],
|
||||
'stress_limits': ['Handling', 'In-Place'],
|
||||
'rigging': ['Auto'],
|
||||
}
|
||||
|
||||
|
||||
def _unpack_stage_option(opt):
|
||||
"""(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS
|
||||
row, or None if the row cannot be read at all.
|
||||
|
||||
Defensive against exactly the kind of mid-refactor shape change this
|
||||
module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot
|
||||
on live disk between this module's first draft and this test run (a
|
||||
7-tuple with the new slot, where the 6-tuple it replaced had none). Both
|
||||
shapes are accepted; `choices` falls back to
|
||||
`_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]`
|
||||
for an option with no fixed vocabulary either way. `drives` (index 5 of
|
||||
the 7-tuple) is not needed here -- NA-ness is decided structurally by
|
||||
`_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked."""
|
||||
try:
|
||||
opt_key, label, symbol, help_text, kind, _drives, choices = opt
|
||||
choices = list(choices) if choices else None
|
||||
return opt_key, label, symbol, help_text, kind, choices
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
opt_key, label, symbol, help_text, kind, _drives = opt
|
||||
choices = _STAGE_CHOICES_FALLBACK.get(opt_key)
|
||||
return opt_key, label, symbol, help_text, kind, choices
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# The stage-matrix options that only make sense for a stage where the panel
|
||||
# is actually being picked up and set (Primary / Secondary Erection). See the
|
||||
# module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether
|
||||
# a given stage is ticked on.
|
||||
_ERECTION_ONLY = set([
|
||||
'rigging', 'sling_angle', 'ground_reaction',
|
||||
'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries',
|
||||
])
|
||||
_NOT_ERECTION_STAGES = set(['Stripping', 'Transportation'])
|
||||
|
||||
# Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS`
|
||||
# by key, grouped the way this page presents them. Every key here is checked
|
||||
# against the real module before use -- see `_field_block`.
|
||||
_LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor')
|
||||
_LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters')
|
||||
|
||||
|
||||
def _import_paf():
|
||||
"""The real `vibe.panelanalysisform`, or (None, reason).
|
||||
|
||||
A separate function so `page()` reads like the rest of this module: one
|
||||
early check, one plain-English reason, never a raised ImportError.
|
||||
"""
|
||||
try:
|
||||
from vibe import panelanalysisform as paf
|
||||
except ImportError as exc:
|
||||
return None, ('vibe.panelanalysisform could not be imported '
|
||||
'({0}); the Handling section cannot be built at '
|
||||
'all'.format(exc))
|
||||
return paf, None
|
||||
|
||||
|
||||
def _field_exists(paf, key):
|
||||
"""Whether `key` is a real entry in `paf.FIELDS` right now.
|
||||
|
||||
Never assumes `FIELDS` itself is present or list-shaped -- the module
|
||||
this reads from is being restructured by another agent as this is
|
||||
written.
|
||||
"""
|
||||
fields = getattr(paf, 'FIELDS', None)
|
||||
if not fields:
|
||||
return False
|
||||
try:
|
||||
for f in fields:
|
||||
if isinstance(f, dict) and f.get('key') == key:
|
||||
return True
|
||||
except TypeError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _field_kind(paf, key):
|
||||
for f in getattr(paf, 'FIELDS', None) or []:
|
||||
if isinstance(f, dict) and f.get('key') == key:
|
||||
return f.get('kind') or 'text', bool(f.get('required'))
|
||||
return 'text', False
|
||||
|
||||
|
||||
def _field_block(paf, key):
|
||||
"""A `{'kind': 'field', ...}` block for a real `panelanalysisform` key.
|
||||
|
||||
Caller must have already confirmed `_field_exists(paf, key)`. Labels,
|
||||
symbols and hints all come from the module's own accessors rather than
|
||||
being re-typed here, so this page can never drift from the field it is
|
||||
describing -- and those accessors already degrade to the bare key if
|
||||
something about the field record is missing, so this cannot raise.
|
||||
"""
|
||||
label = paf.label_of(key) if hasattr(paf, 'label_of') else key
|
||||
symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else ''
|
||||
hint = paf.help_of(key) if hasattr(paf, 'help_of') else ''
|
||||
kind, required = _field_kind(paf, key)
|
||||
editor = _EDITOR_OF_KIND.get(kind, 'text')
|
||||
return {
|
||||
'kind': 'field', 'key': key, 'label': label,
|
||||
'symbol': symbol or None, 'hint': hint or None,
|
||||
'required': required, 'editor': editor, 'choices': None,
|
||||
}
|
||||
|
||||
|
||||
def _row_label(label, symbol):
|
||||
"""'label — symbol', matching `panelanalysisform.display_label`'s own
|
||||
join -- the symbol rides beside the plain English, never replaces it."""
|
||||
if symbol:
|
||||
return u'{0} — {1}'.format(label, symbol)
|
||||
return label
|
||||
|
||||
|
||||
def _stage_options_editor(kind):
|
||||
if kind == 'bool':
|
||||
return 'check'
|
||||
if kind == 'num':
|
||||
return 'num'
|
||||
return 'text'
|
||||
|
||||
|
||||
def _stage_matrix_block(paf, problems):
|
||||
"""The Stage Options matrix block, or None if it cannot be built.
|
||||
|
||||
Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing
|
||||
or malformed piece is one `problems` sentence naming it, and a row that
|
||||
cannot be unpacked is skipped rather than raising, so a partial
|
||||
restructure of the source module degrades this one matrix instead of the
|
||||
whole page.
|
||||
"""
|
||||
stages = list(getattr(paf, 'STAGES', ()) or ())
|
||||
if not stages:
|
||||
problems.append('vibe.panelanalysisform has no STAGES list yet; '
|
||||
'the Stage Options matrix cannot be built')
|
||||
return None
|
||||
options = getattr(paf, 'STAGE_OPTIONS', None)
|
||||
if not options:
|
||||
problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list '
|
||||
'yet; the Stage Options matrix cannot be built')
|
||||
return None
|
||||
stage_field_key = getattr(paf, 'stage_field_key', None)
|
||||
if not callable(stage_field_key):
|
||||
problems.append('vibe.panelanalysisform has no stage_field_key() '
|
||||
'yet; the Stage Options matrix cells have no keys '
|
||||
'to write to')
|
||||
return None
|
||||
|
||||
rows = []
|
||||
for opt in options:
|
||||
unpacked = _unpack_stage_option(opt)
|
||||
if unpacked is None:
|
||||
problems.append('a Handling stage option row has an unexpected '
|
||||
'shape ({0!r}); skipped'.format(opt))
|
||||
continue
|
||||
opt_key, label, symbol, help_text, kind, choices = unpacked
|
||||
editor = _stage_options_editor(kind)
|
||||
cells = []
|
||||
for stage in stages:
|
||||
if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES:
|
||||
cells.append({'na': True})
|
||||
continue
|
||||
try:
|
||||
key = stage_field_key(stage, opt_key)
|
||||
except Exception as exc:
|
||||
# A broken key builder must not take the whole page down;
|
||||
# name it and leave that one cell blank instead.
|
||||
problems.append(
|
||||
'could not build the form key for {0} / {1} ({2}); that '
|
||||
'cell is left blank'.format(stage, label, exc))
|
||||
cells.append(None)
|
||||
continue
|
||||
cells.append({'editor': editor, 'key': key, 'choices': choices})
|
||||
rows.append({'label': _row_label(label, symbol),
|
||||
'hint': help_text or None, 'cells': cells})
|
||||
|
||||
if not rows:
|
||||
problems.append('every Handling stage option row was unreadable; '
|
||||
'the Stage Options matrix has nothing to show')
|
||||
return None
|
||||
return {'kind': 'matrix', 'columns': stages, 'rows': rows}
|
||||
|
||||
|
||||
def _legend_block():
|
||||
"""Eriksson's own two-column legend, redrawn generically.
|
||||
|
||||
Shape names ('triangle', 'bar') and fill names ('blank', 'light',
|
||||
'hatch', 'solid', 'tan') are markers for the renderer to draw, not
|
||||
engineering values -- there is nothing here for `panelanalysisform` to
|
||||
supply, so this block is static.
|
||||
"""
|
||||
return {
|
||||
'kind': 'legend',
|
||||
'groups': [
|
||||
('Shapes', [
|
||||
('triangle', 'Face lifter'),
|
||||
('bar', 'Dunnage'),
|
||||
]),
|
||||
('Fills', [
|
||||
('blank', 'Not specified'),
|
||||
('light', 'Stripping'),
|
||||
('hatch', 'Erection'),
|
||||
('solid', 'Stripping + Erection'),
|
||||
('tan', 'Transportation'),
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _weight_centroid_block(paf, values, reads, problems):
|
||||
"""The Panel Weight / Centroid kv block.
|
||||
|
||||
Weight comes from the real `weight_lb` field -- the user's override, or
|
||||
whatever the model could read, exactly like every other prefilled field
|
||||
on this form -- converted to kips to match Eriksson's own display. The
|
||||
three centroid components are not computed anywhere in this pipeline
|
||||
yet, so they say that in plain words rather than printing a fabricated
|
||||
0.000; see the module docstring.
|
||||
"""
|
||||
weight_row = ('Panel weight', 'not entered yet -- computed from the '
|
||||
'geometry above once you run the analysis')
|
||||
if _field_exists(paf, 'weight_lb'):
|
||||
text = (values.get('weight_lb') or '').strip()
|
||||
if not text and reads:
|
||||
text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \
|
||||
else str(reads.get('weight_lb') or '')
|
||||
if text:
|
||||
try:
|
||||
kips = float(text) / 1000.0
|
||||
weight_row = ('Panel weight',
|
||||
'{0:.3f} kips (as entered)'.format(kips))
|
||||
except (TypeError, ValueError):
|
||||
weight_row = ('Panel weight',
|
||||
'{0} lb (as entered, not a plain number)'
|
||||
.format(text))
|
||||
else:
|
||||
problems.append('vibe.panelanalysisform has no weight_lb field; '
|
||||
'panel weight cannot be shown')
|
||||
|
||||
not_tracked = 'not tracked on this form yet'
|
||||
problems.append('panel centroid (X from start / Y from bottom / Z '
|
||||
'from left) is not computed anywhere in this pipeline '
|
||||
'yet; the Panel Weight / Centroid block shows weight '
|
||||
'only')
|
||||
return {
|
||||
'kind': 'kv',
|
||||
'rows': [
|
||||
weight_row,
|
||||
('Centroid, X (from start)', not_tracked),
|
||||
('Centroid, Y (from bottom)', not_tracked),
|
||||
('Centroid, Z (from left)', not_tracked),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _lifter_section_blocks(paf, problems):
|
||||
"""Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three blocks.
|
||||
|
||||
Only `face_lifters` / `edge_lifters` are real fields; the structured
|
||||
per-lifter table Eriksson prints (qty, two positions, a tick per stage)
|
||||
has no backing data model here, and "top edge lifters" / "dunnage" have
|
||||
no field at all. See the module docstring for why each of those is a
|
||||
sentence and a `problems` entry rather than an invented table.
|
||||
"""
|
||||
blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters',
|
||||
'level': 2}]
|
||||
any_lifter_field = False
|
||||
for key in _LIFTER_TEXT_KEYS:
|
||||
if _field_exists(paf, key):
|
||||
blocks.append(_field_block(paf, key))
|
||||
any_lifter_field = True
|
||||
else:
|
||||
problems.append('vibe.panelanalysisform has no {0} field; that '
|
||||
'lifter position cannot be entered'.format(key))
|
||||
if any_lifter_field:
|
||||
blocks.append({
|
||||
'kind': 'sentence',
|
||||
'text': ('Eriksson prints one row per lifter with a quantity, '
|
||||
'two positions and a tick per stage; this form does '
|
||||
'not yet parse the text above into that shape, so '
|
||||
'each line is kept as typed.'),
|
||||
})
|
||||
|
||||
blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2})
|
||||
if _field_exists(paf, 'top_edge_lifters'):
|
||||
# Not expected today, but if a future restructure adds the field,
|
||||
# render it rather than silently keeping the old sentence.
|
||||
blocks.append(_field_block(paf, 'top_edge_lifters'))
|
||||
else:
|
||||
problems.append('vibe.panelanalysisform has no top edge lifters '
|
||||
'field; this section always reads as empty')
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': 'Design contains no top edge lifters.'})
|
||||
|
||||
blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2})
|
||||
if _field_exists(paf, 'dunnage'):
|
||||
blocks.append(_field_block(paf, 'dunnage'))
|
||||
else:
|
||||
problems.append('vibe.panelanalysisform has no dunnage field; '
|
||||
'this section always reads as empty')
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': 'Design contains no dunnage entered.'})
|
||||
return blocks
|
||||
|
||||
|
||||
def _lifting_hardware_blocks(problems):
|
||||
"""Eriksson page 26: the weight-distribution drawing plus a note on what
|
||||
could not be carried over (see the module docstring's last bullet)."""
|
||||
blocks = [
|
||||
{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2},
|
||||
{'kind': 'drawing', 'source': 'weight_diagram',
|
||||
'caption': 'PANEL WEIGHT DISTRIBUTION', 'w': 760, 'h': 220},
|
||||
]
|
||||
problems.append(
|
||||
'Eriksson also prints a small lifting-configuration sketch per '
|
||||
'included stage (a G/CG marker over each stage\'s lift points) '
|
||||
'and a Face Lifters name / capacity-check method block above the '
|
||||
'weight distribution; neither has a panelsketch drawing source or '
|
||||
'a panelanalysisform field yet, so neither is shown')
|
||||
return blocks
|
||||
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Handling section of the Panel Analysis form.
|
||||
|
||||
`values` is the form's current string-valued dict, read-only here -- this
|
||||
function never assigns into it, so the caller's dict comes back
|
||||
unchanged no matter what this renders. `reads` is the optional dict of
|
||||
what the model could read off the framing element (the same shape
|
||||
`panelanalysisform.prefill` takes); it is used only as a fallback for the
|
||||
weight row when `values` itself is blank, and its absence never raises.
|
||||
|
||||
Returns a flat list of block dicts and a list of plain-English problem
|
||||
sentences -- never raises, per the module docstring's "could not judge"
|
||||
discipline. An empty `problems` list means every accessor this section
|
||||
depends on was present and shaped as expected; a non-empty one names
|
||||
every gap, so the caller can decide whether to surface it to the user
|
||||
(a stale accessor is worth knowing about even though the page itself
|
||||
still renders around it).
|
||||
"""
|
||||
problems = []
|
||||
paf, reason = _import_paf()
|
||||
if paf is None:
|
||||
problems.append(reason)
|
||||
return ([{'kind': 'heading', 'text': 'Handling', 'level': 1},
|
||||
{'kind': 'sentence',
|
||||
'text': ('The Handling section could not be built -- see '
|
||||
'the problem above.')}],
|
||||
problems)
|
||||
|
||||
values = values or {}
|
||||
blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}]
|
||||
|
||||
# --- Stage Options matrix -------------------------------------------
|
||||
blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2})
|
||||
matrix_block = _stage_matrix_block(paf, problems)
|
||||
if matrix_block is not None:
|
||||
blocks.append(matrix_block)
|
||||
else:
|
||||
blocks.append({'kind': 'sentence',
|
||||
'text': ('The per-stage handling options could not '
|
||||
'be built -- see the problem above.')})
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
blocks.append(_legend_block())
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
# --- Panel Weight / Centroid ------------------------------------------
|
||||
blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid',
|
||||
'level': 3})
|
||||
blocks.append(_weight_centroid_block(paf, values, reads, problems))
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# --- Load factors (weight override / self-weight factor) --------------
|
||||
load_factor_blocks = []
|
||||
for key in _LOAD_FACTOR_KEYS:
|
||||
if _field_exists(paf, key):
|
||||
load_factor_blocks.append(_field_block(paf, key))
|
||||
else:
|
||||
problems.append('vibe.panelanalysisform has no {0} field'
|
||||
.format(key))
|
||||
if load_factor_blocks:
|
||||
blocks.append({'kind': 'heading', 'text': 'Load Factors',
|
||||
'level': 3})
|
||||
blocks.extend(load_factor_blocks)
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
# --- plan drawing -------------------------------------------------
|
||||
blocks.append({'kind': 'drawing', 'source': 'plan',
|
||||
'caption': 'LIFTER AND DUNNAGE DETAIL', 'w': 760,
|
||||
'h': 420})
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
# --- lifter / dunnage sections -----------------------------------
|
||||
blocks.extend(_lifter_section_blocks(paf, problems))
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# --- Lifting Hardware (page 26) ------------------------------------
|
||||
blocks.extend(_lifting_hardware_blocks(problems))
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,365 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Panel Analysis input form -- the Reinforcement section's block builder.
|
||||
|
||||
WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as
|
||||
one raw eccentricity number in an unlabelled box, and the owner has now said
|
||||
three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and
|
||||
finally "全部给我做成普通人能看懂的样子" -- make every part of it something an
|
||||
ordinary person can understand). This module rebuilds the Reinforcement half
|
||||
of that form to read like Eriksson Wall's own printed page (strands, rebar,
|
||||
mesh, interrupts, then the drawn cross-section), while staying an honest INPUT
|
||||
form rather than a finished report: every optional block that is empty says so
|
||||
in a sentence, exactly the way Eriksson prints "Design contains no rebar" /
|
||||
"no mesh" / "no prestressing" rather than leaving a blank space that could
|
||||
mean either "nothing here" or "nobody has typed it yet".
|
||||
|
||||
`page(values, reads=None)` returns `(blocks, problems)` and never raises.
|
||||
`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see
|
||||
the button's WPF renderer) -- 'heading', 'columns', 'field', 'table', 'kv',
|
||||
'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as
|
||||
nothing, so adding a new one here without also teaching the renderer is a
|
||||
silent no-op, not a crash -- worth remembering while extending this file.
|
||||
`problems` is the sentence list the button shows above the section: every
|
||||
field key this module could not find in `vibe.panelanalysisform`, every
|
||||
strand size with no known area, and every summary quantity (cgx, ex, ey) that
|
||||
this module refuses to compute rather than invent.
|
||||
|
||||
WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own
|
||||
rule for `strand_area_in2` (a strand size it does not recognise is refused BY
|
||||
NAME, never guessed, because an invented area is a fabricated capacity):
|
||||
|
||||
* A strand size with no published area never gets a made-up one -- the
|
||||
table shows the size, refuses the area, and `problems` names the size.
|
||||
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
|
||||
never printed. The per-strand `y` `_prestress_from` computes is an even
|
||||
spread across the panel WIDTH assumed for the Stage-0 solve, not a
|
||||
position the user typed or the model measured, so presenting it as cgx
|
||||
would dress up an assumption as a fact. Depth (cgy) IS printed, because
|
||||
depth-to-CG is exactly what the user types per strand group -- a
|
||||
count-weighted average of real entered numbers, not an invented one.
|
||||
* Horizontal strand spacing is not collected anywhere on this form, so no
|
||||
table here carries a "Spacing" column or any other layout that implies
|
||||
it is known -- Eriksson's own rebar table has one; this module's strand
|
||||
table deliberately does not, matching the real page 27 strand table
|
||||
(which has none either) rather than the rebar table on the same PDF.
|
||||
|
||||
WHY `strand_rows` IS READ DEFENSIVELY: a sibling agent is restructuring
|
||||
`panelanalysisform` to replace the single `ps_ecc_in` box with structured
|
||||
strand-group rows, exposed on `values['strand_rows']` as
|
||||
`[{'count', 'depth_in', 'pull_pct'}, ...]`. That key does not exist on
|
||||
`values` in every revision of that module while the restructure is in
|
||||
flight, so its absence is read as "waiting on input", never as an error --
|
||||
this module falls back to parsing the raw `ps_rows` text itself with
|
||||
`panelanalysisform.parse_strand_rows` (the same parser `validate()` uses) and
|
||||
says in `problems` that it had to.
|
||||
|
||||
No Revit import, no WPF import, no `panelsketch` import -- only
|
||||
`vibe.panelanalysisform`, for field names/labels/hints and the strand-area
|
||||
table, exactly as the rest of this input form does. Must run on IronPython
|
||||
2.7 in the button (`vibe.helpers` never reaches this module) AND on plain
|
||||
CPython under `WillTools.extension/tests/conftest.py`'s import trick: no
|
||||
f-strings, no typing, no dataclasses.
|
||||
"""
|
||||
|
||||
from vibe import panelanalysisform as pf
|
||||
|
||||
|
||||
# --------------------------------------------------------------- constants
|
||||
|
||||
# Section/field keys this module owns, all declared under the 'Reinforcement'
|
||||
# section in panelanalysisform.FIELDS. Kept as a list (not re-derived from
|
||||
# FIELDS by section name) so a key panelanalysisform drops or renames shows up
|
||||
# as an explicit MISSING in `problems` instead of just vanishing from the page.
|
||||
_STRAND_FIELD_KEYS = ('strand_rail_location', 'strand_rail_offset_in',
|
||||
'ps_dia_in', 'ps_pull_lb', 'ps_count', 'ps_loss_pct')
|
||||
_REBAR_FIELD_KEYS = ('mild_as',)
|
||||
_MESH_FIELD_KEYS = ('mesh',)
|
||||
|
||||
# panelanalysisform has no field for interrupts at all yet -- not "blank",
|
||||
# genuinely absent. Kept as its own name (rather than an empty tuple reused
|
||||
# for "no fields") so the one `problems` sentence that explains it is written
|
||||
# once, beside the constant it is about.
|
||||
_INTERRUPTS_MISSING = 'interrupts'
|
||||
|
||||
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
|
||||
# page speaks. Nothing here is 'check' or 'choice' -- every Reinforcement
|
||||
# field is free text or a number.
|
||||
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
|
||||
|
||||
|
||||
def _field_block(key, problems):
|
||||
"""A 'field' block for a panelanalysisform key, or None + a MISSING note.
|
||||
|
||||
Reads the label/symbol/hint/required-ness straight off panelanalysisform
|
||||
so this page can never drift from the form it is rendering -- a rename in
|
||||
that module shows up here as a MISSING problem instead of a stale label.
|
||||
"""
|
||||
spec = pf._spec(key)
|
||||
if spec is None:
|
||||
problems.append(
|
||||
"field {0!r} is not declared in panelanalysisform -- this page "
|
||||
"cannot show it (MISSING)".format(key))
|
||||
return None
|
||||
editor = _EDITOR_OF_KIND.get(spec['kind'], 'text')
|
||||
return {
|
||||
'kind': 'field', 'key': key, 'label': spec['label'],
|
||||
'symbol': spec['symbol'] or None, 'hint': spec['help'] or None,
|
||||
'required': spec['required'], 'editor': editor, 'choices': None,
|
||||
}
|
||||
|
||||
|
||||
def _text_of(values, key):
|
||||
"""The current string in `values` for `key`, '' if absent/None.
|
||||
|
||||
`values` is meant to be string-valued, but this page must never raise on
|
||||
a caller's mistake -- a non-string value (an int typed straight into a
|
||||
dict in a test, a stray object) is coerced rather than crashed on.
|
||||
"""
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
return ''
|
||||
if not hasattr(raw, 'strip'):
|
||||
try:
|
||||
raw = str(raw)
|
||||
except Exception:
|
||||
return ''
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _num_of(values, key):
|
||||
"""(float_or_None, error_or_None) for a numeric field's current text."""
|
||||
text = _text_of(values, key)
|
||||
if not text:
|
||||
return None, None
|
||||
try:
|
||||
return float(text), None
|
||||
except (TypeError, ValueError):
|
||||
return None, 'is not a number ({0!r})'.format(text)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ strands
|
||||
|
||||
def _strand_rows_from(values, problems):
|
||||
"""[{'count', 'depth_in', 'pull_pct'}, ...] read defensively.
|
||||
|
||||
Prefers the structured `values['strand_rows']` a sibling agent is adding;
|
||||
falls back to parsing the raw `ps_rows` text with panelanalysisform's own
|
||||
parser (the same one `validate()` uses) when that key is absent, and says
|
||||
so in `problems` -- never treats the absence as an error by itself.
|
||||
Genuinely blank input (`strand_rows` absent AND `ps_rows` blank) returns
|
||||
`[]` with no problem: "nobody has typed any strand groups yet" is a real,
|
||||
quiet state, not a fault.
|
||||
"""
|
||||
raw = values.get('strand_rows')
|
||||
if isinstance(raw, list):
|
||||
rows = []
|
||||
for entry in raw:
|
||||
try:
|
||||
count = int(entry['count'])
|
||||
depth_in = float(entry['depth_in'])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
problems.append(
|
||||
"a strand_rows entry from the form could not be read "
|
||||
"(missing or non-numeric count/depth_in) -- skipped it "
|
||||
"rather than guessing its numbers")
|
||||
continue
|
||||
pull_raw = entry.get('pull_pct') if hasattr(entry, 'get') else None
|
||||
pull_pct = None
|
||||
if pull_raw is not None:
|
||||
try:
|
||||
pull_pct = float(pull_raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(
|
||||
"a strand_rows entry's pull_pct could not be read "
|
||||
"({0!r}) -- treated as not given".format(pull_raw))
|
||||
rows.append({'count': count, 'depth_in': depth_in,
|
||||
'pull_pct': pull_pct})
|
||||
return rows
|
||||
|
||||
# Fall back to the raw text field this page also renders as a 'field'
|
||||
# block below, so the user always has somewhere to type even while the
|
||||
# structured key is not yet on `values`.
|
||||
text = _text_of(values, 'ps_rows')
|
||||
if not text:
|
||||
return []
|
||||
rows, parse_problems = pf.parse_strand_rows(text)
|
||||
if parse_problems:
|
||||
problems.append(
|
||||
"structured strand_rows was not supplied by the form; tried to "
|
||||
"read the strand-group text instead and could not: " +
|
||||
'; '.join(parse_problems))
|
||||
return []
|
||||
if rows:
|
||||
problems.append(
|
||||
"structured strand_rows was not supplied by the form yet; "
|
||||
"parsed the raw strand-group text (ps_rows) instead")
|
||||
return rows
|
||||
|
||||
|
||||
def _strand_block(values, problems):
|
||||
"""The whole Strands sub-block: rail fields, then table or sentence.
|
||||
|
||||
Matches the real page's own shape: the Rail Location / Rail Offset pair
|
||||
prints unconditionally, and what follows is either the strand table or
|
||||
the single sentence "Design contains no prestressing" -- never a table
|
||||
with a header row and no data, and never a blank space.
|
||||
"""
|
||||
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
|
||||
|
||||
rail_fields = []
|
||||
for key in ('strand_rail_location', 'strand_rail_offset_in'):
|
||||
fb = _field_block(key, problems)
|
||||
if fb is not None:
|
||||
rail_fields.append([fb])
|
||||
if rail_fields:
|
||||
blocks.append({'kind': 'columns', 'columns': rail_fields})
|
||||
|
||||
rows = _strand_rows_from(values, problems)
|
||||
if not rows:
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Design contains no prestressing'})
|
||||
return blocks
|
||||
|
||||
for key in ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_loss_pct'):
|
||||
fb = _field_block(key, problems)
|
||||
if fb is not None:
|
||||
blocks.append(fb)
|
||||
|
||||
dia_text = _text_of(values, 'ps_dia_in')
|
||||
area, area_err = pf.strand_area_in2(dia_text if dia_text else None)
|
||||
if area_err:
|
||||
problems.append(area_err)
|
||||
|
||||
pull_lb_text = _text_of(values, 'ps_pull_lb')
|
||||
|
||||
headers = ['Size', 'Area (in²)', 'Diameter', 'Quantity', '% Pull',
|
||||
'Depth to CG (in)']
|
||||
table_rows = []
|
||||
for row in rows:
|
||||
size_cell = dia_text if dia_text else '?'
|
||||
area_cell = ('%.4f' % area).rstrip('0').rstrip('.') if area is not \
|
||||
None else '?'
|
||||
if row['pull_pct'] is not None:
|
||||
pull_cell = ('%g%%' % row['pull_pct'])
|
||||
elif pull_lb_text:
|
||||
pull_cell = 'default pull ({0} lb)'.format(pull_lb_text)
|
||||
else:
|
||||
pull_cell = 'default pull (not entered)'
|
||||
depth_cell = ('%g"' % row['depth_in'])
|
||||
table_rows.append([size_cell, area_cell, size_cell,
|
||||
str(row['count']), pull_cell, depth_cell])
|
||||
blocks.append({'kind': 'table', 'headers': headers,
|
||||
'header_groups': None, 'rows': table_rows,
|
||||
'row_key': None})
|
||||
|
||||
# Strand summary. Aps and cgy are real derived numbers (a count-weighted
|
||||
# sum/average of what the user actually typed); cgx/ex/ey are refused --
|
||||
# see the module docstring for why.
|
||||
if area is not None:
|
||||
total_count = sum(r['count'] for r in rows)
|
||||
if total_count > 0:
|
||||
aps = total_count * area
|
||||
cgy = sum(r['count'] * r['depth_in'] for r in rows) / \
|
||||
float(total_count)
|
||||
kv_rows = [
|
||||
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.')),
|
||||
('cgy, in (depth to CG)',
|
||||
('%.4f' % cgy).rstrip('0').rstrip('.')),
|
||||
('cgx, in', 'not shown -- horizontal strand position is not '
|
||||
'collected on this form, only assumed for the '
|
||||
'solve'),
|
||||
('ex, in', 'not shown -- see cgx above'),
|
||||
('ey, in', 'not shown -- needs a defined section centroid, '
|
||||
'not derived by this page'),
|
||||
]
|
||||
blocks.append({'kind': 'kv', 'rows': kv_rows})
|
||||
problems.append(
|
||||
'strand summary: cgx and both eccentricities (ex, ey) are '
|
||||
'not computed -- horizontal strand position on this panel '
|
||||
'is an assumption made for the solve, not a real input, and '
|
||||
'printing it as cgx would dress up that assumption as a '
|
||||
'measured fact')
|
||||
else:
|
||||
problems.append(
|
||||
'strand summary (Aps, cgy) could not be computed -- the strand '
|
||||
'area is unknown (see the strand-size problem above)')
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
# --------------------------------------------------------------- rebar/mesh
|
||||
|
||||
def _rebar_block(values, problems):
|
||||
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
|
||||
fb = _field_block('mild_as', problems)
|
||||
if fb is not None:
|
||||
blocks.append(fb)
|
||||
if not _text_of(values, 'mild_as'):
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Design contains no rebar'})
|
||||
return blocks
|
||||
|
||||
|
||||
def _mesh_block(values, problems):
|
||||
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
|
||||
fb = _field_block('mesh', problems)
|
||||
if fb is not None:
|
||||
blocks.append(fb)
|
||||
if not _text_of(values, 'mesh'):
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Design contains no mesh'})
|
||||
return blocks
|
||||
|
||||
|
||||
def _interrupts_block(problems):
|
||||
"""The Interrupts sub-block -- no field exists on the form for this yet.
|
||||
|
||||
This is deliberately NOT "Design contains no interrupts": that sentence
|
||||
would assert a judged, empty answer, and nothing here has judged
|
||||
anything -- panelanalysisform simply has no key to read. "Could not
|
||||
judge" and "judged, found nothing" must never look alike (CLAUDE.md
|
||||
section 3), so this prints the honest could-not-judge sentence and names
|
||||
the gap in `problems` for whoever is meant to add the field.
|
||||
"""
|
||||
problems.append(
|
||||
"interrupts: panelanalysisform has no field for interrupts yet "
|
||||
"(MISSING) -- this page cannot say whether the design has any")
|
||||
return [
|
||||
{'kind': 'heading', 'text': 'Interrupts', 'level': 2},
|
||||
{'kind': 'sentence', 'text':
|
||||
'Could not judge -- this form does not yet collect interrupts '
|
||||
'data'},
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- public
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Reinforcement section of the input form.
|
||||
|
||||
`values` is the form's current string-valued dict (plus, once the
|
||||
sibling restructure lands, a `strand_rows` list -- see the module
|
||||
docstring). `reads` is accepted for signature parity with the rest of
|
||||
this form's per-section builders; nothing under Reinforcement is
|
||||
prefilled from the model (`panelanalysisform.PREFILLABLE` does not
|
||||
include any of these keys), so it is not read here. Never raises --
|
||||
every failure becomes a sentence in `problems` instead.
|
||||
"""
|
||||
values = values or {}
|
||||
problems = []
|
||||
|
||||
blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}]
|
||||
blocks.extend(_strand_block(values, problems))
|
||||
blocks.append({'kind': 'rule'})
|
||||
blocks.extend(_rebar_block(values, problems))
|
||||
blocks.append({'kind': 'rule'})
|
||||
blocks.extend(_mesh_block(values, problems))
|
||||
blocks.append({'kind': 'rule'})
|
||||
blocks.extend(_interrupts_block(problems))
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
blocks.append({
|
||||
'kind': 'drawing', 'source': 'section',
|
||||
'caption': 'Reinforcement Cross-Section', 'w': 480, 'h': 360,
|
||||
})
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,666 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Panel Analysis input form -- the "Structural Model" section, laid out.
|
||||
|
||||
WHY THIS EXISTS: the owner rejected the Panel Analysis form three times as
|
||||
unreadable ("太丑了", "你这么输入用户上哪儿能看懂") and finally ruled, 2026-09-02,
|
||||
"全部给我做成普通人能看懂的样子" -- make every part of it something an ordinary
|
||||
person can understand. The fix is not a paint job: `page()` below rebuilds
|
||||
the Structural Model section (restraints, bearing, releases, concentrated
|
||||
loads, and the wind / seismic / temperature / soil load families) to look
|
||||
like Eriksson Wall's own printed page for it, because that page is the
|
||||
document a reviewing engineer already knows how to read. Every field still
|
||||
carries the engineering `symbol` beside its plain-English `label` -- the
|
||||
Unicode notation is not decoration, it is what gets checked against
|
||||
Eriksson's own printout -- and every field carries a one-sentence `hint`
|
||||
saying what it is for and what a blank means, because a symbol alone
|
||||
("Vult, mph") is exactly the kind of label that got this form rejected.
|
||||
|
||||
`page(values, reads=None)` returns `(blocks, problems)` -- a flat list of
|
||||
BLOCK dicts from a small fixed vocabulary a generic WPF renderer consumes
|
||||
(an unrecognised `kind` renders as nothing, so this module can only ever
|
||||
degrade a layout, never crash one), plus a list of plain-English sentences
|
||||
describing anything that could not be built as designed. `page` never
|
||||
raises: a field the shared field model does not have yet, or a `values`
|
||||
dict missing a key entirely, becomes a `problems` sentence and a
|
||||
stand-in label, not an exception and not an invented value.
|
||||
|
||||
TWO RULES FROM CLAUDE.md THIS MODULE IS BUILT AROUND, section 3:
|
||||
|
||||
* "Could not judge" and "judged, found nothing" must never look alike.
|
||||
Every OPTIONAL block here (Releases, Concentrated Loads, the Wind /
|
||||
Seismic zone tables, both Soil Loads, Additional Self Weight, Design
|
||||
Strips) has to say something when it has nothing to show, and there
|
||||
are two different somethings: the key existing in `values` but blank
|
||||
means the user was asked and answered "none" (Eriksson's own wording,
|
||||
e.g. "Design contains no exterior soil loads" -- generic boilerplate,
|
||||
not a client's text, and kept because it is the plain-English answer a
|
||||
non-engineer would also write); the key not existing in `values` at
|
||||
all means this form has never asked yet, and the sentence says so
|
||||
instead of quietly agreeing with "none". Collapsing those two into one
|
||||
sentence is exactly the failure `knowledge.same_face()` returning
|
||||
`None` instead of `False` guards against elsewhere in this codebase.
|
||||
* Nothing here computes wind or seismic. The owner ruled it explicitly,
|
||||
2026-09-02: "风的计算还没有你就留着空着,让用户输入" -- the wind engine is a
|
||||
later stage, so every wind/seismic field is collected, labelled as not
|
||||
yet used by the analysis, and left blank. No pressure, no Kz, no
|
||||
exposure default, nothing invented to make the page look more finished
|
||||
than the analysis actually is.
|
||||
|
||||
WHERE THE LAYOUT COMES FROM: Eriksson Wall's own printed "Structural
|
||||
Model" page -- a Restraints table (foundation connection, one row per tie
|
||||
elevation with a gravity/thermal restraint flag and a stiffness) beside a
|
||||
Releases block, an elevation diagram, a Concentrated Loads table split into
|
||||
dead/live/roof/wind-pressure/wind-suction/seismic components, Load
|
||||
Options, then the Wind / Seismic / Temperature / Soil / Additional Self
|
||||
Weight load families and a Design Strips note. Only the STRUCTURE and
|
||||
generic field names are taken from that page -- no project, client,
|
||||
licensee, engineer, job number, file name or date from it appears
|
||||
anywhere below; every label is written from scratch in plain engineering
|
||||
English. See CLAUDE.md's "No client or job information in the repo. Ever."
|
||||
|
||||
READING `vibe.panelanalysisform` DEFENSIVELY: that module owns the actual
|
||||
key names and is being restructured by another agent while this one is
|
||||
being written. `page()` imports it, reads its `FIELDS` list once to find
|
||||
out which keys it already knows about, and for every key this section
|
||||
wants that it does NOT find there, it renders the field anyway (with a
|
||||
label and hint written locally, right here) and adds one sentence to
|
||||
`problems` naming the key and what it is for -- never a raised exception,
|
||||
never a guess passed off as the shared field model's answer. Some of the
|
||||
keys used below (`supports_ft`, `restraints`, `conc_loads`, every
|
||||
`wind_*` key, `seismic_cs`, `ext_soil_loads`, `int_soil_loads`) already
|
||||
exist in `panelanalysisform.FIELDS` today and are read from there;
|
||||
Eriksson's page has considerably more than that thin set, and the rest are
|
||||
new key names this module proposes (`restraint_rows`, `releases`,
|
||||
`wind_zone_rows`, `seismic_zone_rows`, `temp_ext_f`, ... -- see the field
|
||||
list inline) for the field-model agent to land.
|
||||
|
||||
Standard library only, no Revit, no WPF, no `panelsketch` import -- the
|
||||
`kind: 'drawing'` blocks below only NAME a `vibe.panelsketch` entry point
|
||||
as their `source`; a sibling agent owns what that module draws.
|
||||
|
||||
IronPython 2.7 throughout: no f-strings, `'{0}'.format(...)` everywhere,
|
||||
`# -*- coding: utf-8 -*-` up top, no `pathlib` / `dataclasses` / `typing`.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Small helpers shared by every block below. None of these touch `values` --
|
||||
# they only ever read it, and `page()` never assigns into a caller-owned
|
||||
# dict, so the same `values` a caller hands in comes back untouched.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _text(value):
|
||||
"""Coerce whatever sits in `values` to a string, defensively.
|
||||
|
||||
`values` is documented as string-valued, but this form is fed by a
|
||||
live model read and a WPF text box on one side and a mid-refactor
|
||||
field model on the other -- a stray non-string (an `int` typed
|
||||
straight into a dict during a test, a `None` a half-written prefill
|
||||
left behind) must degrade to an empty string, never raise.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
try:
|
||||
value.strip # duck-typed string check -- works for str/unicode both
|
||||
return value
|
||||
except AttributeError:
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _known_field_keys(paf):
|
||||
"""The set of keys `vibe.panelanalysisform.FIELDS` already defines.
|
||||
|
||||
Empty set (never an exception) if the module failed to import or its
|
||||
`FIELDS` list could not be read -- the caller has already logged why in
|
||||
`problems` by the time this runs, and an empty set makes every field
|
||||
below fall through to its local stand-in label, which is the correct
|
||||
degrade rather than a crash.
|
||||
"""
|
||||
if paf is None:
|
||||
return set()
|
||||
try:
|
||||
return set(f['key'] for f in paf.FIELDS)
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _field(paf, known, problems, key, required, editor, choices=None,
|
||||
fallback_label=None, fallback_symbol='', fallback_hint='',
|
||||
what=''):
|
||||
"""One `kind: 'field'` block, sourced from `panelanalysisform` when it
|
||||
already knows `key`, and from the fallback text passed in when it does
|
||||
not -- appending one `problems` sentence in that second case, naming
|
||||
the key and what it is for, so the field-model agent has a punch list
|
||||
rather than a form that silently drifted from `panelanalysisform`.
|
||||
"""
|
||||
if key in known:
|
||||
label = paf.label_of(key)
|
||||
symbol = paf.symbol_of(key) or None
|
||||
hint = paf.help_of(key) or None
|
||||
else:
|
||||
label = fallback_label or key
|
||||
symbol = fallback_symbol or None
|
||||
hint = fallback_hint or None
|
||||
problems.append(
|
||||
"'{0}' ({1}) has no entry yet in vibe.panelanalysisform -- "
|
||||
'shown with a stand-in label until the field model adds it.'
|
||||
.format(key, what or fallback_label or key))
|
||||
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
|
||||
'hint': hint, 'required': required, 'editor': editor,
|
||||
'choices': choices}
|
||||
|
||||
|
||||
def _optional_note(values, key, blank_sentence, missing_sentence, problems,
|
||||
what):
|
||||
"""(state, text) for one optional free-text block.
|
||||
|
||||
Three states, and the whole point of this helper is that the first two
|
||||
never look alike:
|
||||
|
||||
'missing' -- `key` is not in `values` at all. This form has not asked
|
||||
the question yet; `text` is `missing_sentence` and one
|
||||
line is appended to `problems` naming the gap. This is
|
||||
"could not judge", never "judged, found nothing".
|
||||
'blank' -- `key` is in `values` but empty/whitespace. The user was
|
||||
asked and the honest answer is none; `text` is
|
||||
`blank_sentence`. This IS "judged, found nothing".
|
||||
'filled' -- `key` has real text; `text` is that text, stripped, for
|
||||
the caller to parse into a table.
|
||||
"""
|
||||
if key not in values:
|
||||
problems.append(
|
||||
"'{0}' ({1}) is not collected by this form yet -- rendered as "
|
||||
"unknown, never silently as \"none\"."
|
||||
.format(key, what))
|
||||
return 'missing', missing_sentence
|
||||
text = _text(values.get(key)).strip()
|
||||
if not text:
|
||||
return 'blank', blank_sentence
|
||||
return 'filled', text
|
||||
|
||||
|
||||
def _rows_from_lines(text, extra_cols=0):
|
||||
"""[[id, col, col, ...], ...] -- one row per non-blank line, comma-split.
|
||||
|
||||
This is a DISPLAY parse, not `panelanalysisform`'s validating one: a
|
||||
line that does not split into the expected shape is shown with
|
||||
whatever columns it did produce rather than refused, because this
|
||||
table exists to show the user what they typed back at them, not to
|
||||
gate the Run button (that gate is `panelanalysisform.validate`'s job,
|
||||
on the raw field text, elsewhere).
|
||||
"""
|
||||
rows = []
|
||||
for i, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
cols = [p.strip() for p in line.split(',')]
|
||||
rows.append([str(i)] + cols)
|
||||
return rows
|
||||
|
||||
|
||||
def _restraint_rows_from(values, problems):
|
||||
"""(rows, state) for the Restraints table's ID/Elevation/Type/Gravity/
|
||||
Thermal/Stiffness columns.
|
||||
|
||||
Prefers the full `restraint_rows` key (one line per restraint: elevation,
|
||||
type, gravity y/n, thermal y/n, stiffness) once the field model carries
|
||||
it. Until then this falls back to the existing `restraints` key, which
|
||||
today is elevations only -- so the Elevation column still fills in from
|
||||
whatever the user already typed, and the Type/Gravity/Thermal/Stiffness
|
||||
columns show blank rather than inventing a restraint type nobody typed.
|
||||
Both cases add a `problems` line; only the outcome (which columns come
|
||||
from where) differs.
|
||||
"""
|
||||
if 'restraint_rows' in values:
|
||||
text = _text(values.get('restraint_rows')).strip()
|
||||
if not text:
|
||||
return [], 'blank'
|
||||
rows = []
|
||||
for i, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
parts = (parts + ['', '', '', '', ''])[:5]
|
||||
rows.append([str(i)] + parts)
|
||||
return rows, ('filled' if rows else 'blank')
|
||||
|
||||
problems.append(
|
||||
"'restraint_rows' (restraint type, gravity/thermal restraint flags "
|
||||
"and stiffness per elevation) has no entry yet in "
|
||||
"vibe.panelanalysisform -- only plain elevations ('restraints') "
|
||||
'are collected today, so those four columns show blank rather '
|
||||
'than an invented restraint type.')
|
||||
|
||||
if 'restraints' not in values:
|
||||
return [], 'missing'
|
||||
text = _text(values.get('restraints')).strip()
|
||||
if not text:
|
||||
return [], 'blank'
|
||||
toks = [t for t in re.split(r'[,\n]+', text) if t.strip()]
|
||||
rows = []
|
||||
for i, tok in enumerate(toks, 1):
|
||||
rows.append([str(i), tok.strip(), '', '', '', ''])
|
||||
return rows, 'filled'
|
||||
|
||||
|
||||
def _table_from_text(text, headers):
|
||||
return {'kind': 'table', 'headers': headers, 'header_groups': None,
|
||||
'rows': _rows_from_lines(text), 'row_key': None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# page()
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def page(values, reads=None):
|
||||
"""(blocks, problems) for the Structural Model section.
|
||||
|
||||
`values` is the form's current string-valued dict, keyed by
|
||||
`vibe.panelanalysisform` field names (plus new names this module
|
||||
proposes -- see the module docstring). Never mutated: every access
|
||||
below is `values.get(...)` or `key in values`, nothing is assigned
|
||||
into it. `reads` is accepted for symmetry with the rest of the form
|
||||
(a future model-read prefill for this section) but nothing here is on
|
||||
`panelanalysisform.PREFILLABLE` today, so it is not read.
|
||||
|
||||
Never raises. A field the shared field model does not know yet, an
|
||||
unreadable `panelanalysisform`, or `values` missing a key entirely all
|
||||
degrade to a `problems` sentence plus a reasonable stand-in in the
|
||||
layout -- never an exception, never a fabricated value.
|
||||
"""
|
||||
values = values if values is not None else {}
|
||||
problems = []
|
||||
blocks = []
|
||||
|
||||
paf = None
|
||||
try:
|
||||
from vibe import panelanalysisform as paf
|
||||
except Exception as exc:
|
||||
problems.append(
|
||||
'could not import vibe.panelanalysisform ({0}); every field '
|
||||
'below is shown with a locally-written label instead of the '
|
||||
'shared field model.'.format(exc))
|
||||
known = _known_field_keys(paf)
|
||||
|
||||
blocks.append({'kind': 'heading', 'text': 'Structural Model', 'level': 1})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'How this panel is held up, tied back and loaded once it is '
|
||||
'standing in place, separate from its geometry and how it is '
|
||||
'handled before it gets there.'})
|
||||
|
||||
# --------------------------------------------------------- Restraints
|
||||
restraints_col = [
|
||||
{'kind': 'heading', 'text': 'Restraints', 'level': 2},
|
||||
{'kind': 'sentence', 'text':
|
||||
'Where the panel is connected to the foundation, and then, '
|
||||
'going up, where it is tied back to each floor as the '
|
||||
'building is built around it.'},
|
||||
]
|
||||
restraints_col.append(_field(
|
||||
paf, known, problems, 'restraint_foundation_connection', False,
|
||||
'choice', choices=['Pin', 'Fixed', 'Roller', 'Slide'],
|
||||
fallback_label='Foundation connection type',
|
||||
fallback_hint='How the bottom of the panel is attached to the '
|
||||
'foundation: pinned (free to rotate), fixed, on a '
|
||||
'roller, or free to slide. Leave blank if this is '
|
||||
'not decided yet.',
|
||||
what='the foundation connection type'))
|
||||
restraints_col.append(_field(
|
||||
paf, known, problems, 'restraint_floor_tie', False, 'check',
|
||||
fallback_label='Add a floor tie',
|
||||
fallback_hint='Check this to add another restraint row above the '
|
||||
'foundation for a floor tying the panel back. Leave '
|
||||
'unchecked if there are no floor ties.',
|
||||
what='whether an extra floor-tie restraint row is added'))
|
||||
restraints_col.append({'kind': 'sentence', 'text':
|
||||
"Gravity restraint means that tie also carries the panel's own "
|
||||
'weight at that point; thermal restraint means it stops the '
|
||||
'panel from bowing out of plane if one face gets hotter than the '
|
||||
'other.'})
|
||||
rrows, rstate = _restraint_rows_from(values, problems)
|
||||
if rstate == 'missing':
|
||||
restraints_col.append({'kind': 'sentence', 'text':
|
||||
'No restraint elevations have been entered on this form yet '
|
||||
'-- this is unknown, not "no restraints".'})
|
||||
elif rstate == 'blank':
|
||||
restraints_col.append({'kind': 'sentence', 'text':
|
||||
'Design contains no restraints.'})
|
||||
else:
|
||||
restraints_col.append({'kind': 'table',
|
||||
'headers': ['ID', 'Elevation (ft)', 'Type', 'Gravity Rest.',
|
||||
'Thermal Rest.', 'Stiffness (k/in)'],
|
||||
'header_groups': None, 'rows': rrows, 'row_key': None})
|
||||
|
||||
# ------------------------------------------------------------ Releases
|
||||
releases_col = [
|
||||
{'kind': 'heading', 'text': 'Releases', 'level': 2},
|
||||
{'kind': 'sentence', 'text':
|
||||
'Any restraint above that is deliberately freed in one '
|
||||
'direction -- for example a tie that carries the weight but '
|
||||
'is left free to slide sideways.'},
|
||||
]
|
||||
rel_state, rel_text = _optional_note(
|
||||
values, 'releases',
|
||||
blank_sentence='Design contains no releases.',
|
||||
missing_sentence='Releases are not collected on this form yet -- '
|
||||
'this is unknown, not "no releases".',
|
||||
problems=problems, what='deliberately-freed restraint directions')
|
||||
if rel_state == 'filled':
|
||||
releases_col.append(_table_from_text(rel_text, ['ID', 'Release']))
|
||||
else:
|
||||
releases_col.append({'kind': 'sentence', 'text': rel_text})
|
||||
|
||||
blocks.append({'kind': 'columns',
|
||||
'columns': [restraints_col, releases_col]})
|
||||
|
||||
blocks.append({'kind': 'drawing', 'source': 'elevation',
|
||||
'caption': 'Restraint and support elevations',
|
||||
'w': 640, 'h': 160})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# -------------------------------------------------------------- Bearing
|
||||
blocks.append({'kind': 'heading', 'text': 'Bearing for this analysis',
|
||||
'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Which elevations actually hold the panel up for THIS run -- the '
|
||||
'analysis needs at least two, and they do not have to be every '
|
||||
'restraint listed above.'})
|
||||
blocks.append(_field(paf, known, problems, 'supports_ft', True, 'text',
|
||||
what='the bearing positions the self-weight solve uses'))
|
||||
|
||||
blocks.append({'kind': 'space', 'h': 8})
|
||||
|
||||
# ---------------------------------------------------- Concentrated Loads
|
||||
blocks.append({'kind': 'heading', 'text': 'Concentrated Loads',
|
||||
'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Extra point loads hung on the panel once it is standing -- a '
|
||||
'ledger, a beam pocket, attached equipment -- and where along the '
|
||||
'wall each one acts. A positive offset pulls toward the outside '
|
||||
'face of the wall.'})
|
||||
cl_state, cl_text = _optional_note(
|
||||
values, 'conc_loads',
|
||||
blank_sentence='No concentrated point loads entered -- design '
|
||||
'assumes none.',
|
||||
missing_sentence='Concentrated loads are not collected on this '
|
||||
'form yet -- this is unknown, not "no point '
|
||||
'loads".',
|
||||
problems=problems, what='point loads once the panel is in place')
|
||||
if cl_state == 'filled':
|
||||
blocks.append(_table_from_text(
|
||||
cl_text, ['ID', 'Elevation / offset / dead / live (as typed)']))
|
||||
problems.append(
|
||||
"'conc_loads' only carries elevation, offset, dead and live "
|
||||
"today; Eriksson's own table also splits each point load into "
|
||||
'roof, wind pressure, wind suction and seismic components -- '
|
||||
'those columns have no key yet in vibe.panelanalysisform.')
|
||||
else:
|
||||
blocks.append({'kind': 'sentence', 'text': cl_text})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# -------------------------------------------------------- Load Options
|
||||
blocks.append({'kind': 'heading', 'text': 'Load Options', 'level': 2})
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'load_opt_pattern_live', False, 'check',
|
||||
fallback_label='Pattern live loads for flexure',
|
||||
fallback_hint='Whether live load is applied in a checkerboard '
|
||||
'pattern (only some spans loaded at once) when '
|
||||
'checking bending, instead of everywhere at once at '
|
||||
'the same time. Leave unchecked if unsure.',
|
||||
what='whether live load is patterned for the bending check'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'load_opt_selfweight_ecc_in', False, 'num',
|
||||
fallback_label='Self-weight eccentricity', fallback_symbol='in',
|
||||
fallback_hint="How far the panel's own weight is assumed to act "
|
||||
'off its centerline. Leave blank to assume it acts '
|
||||
'right on the centerline.',
|
||||
what='the self-weight eccentricity'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'load_opt_offset_from', False, 'choice',
|
||||
choices=['Panel CG', 'Other'],
|
||||
fallback_label='Offset measured from',
|
||||
fallback_hint='What point the point-load offsets above are '
|
||||
"measured from. Leave blank to assume the panel's "
|
||||
'own center of gravity.',
|
||||
what='what point-load offsets are measured from'))
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# ------------------------------------------------------------- Wind
|
||||
blocks.append({'kind': 'heading', 'text': 'Wind', 'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Nothing in this tool computes a wind pressure yet -- that engine '
|
||||
'is a later stage. Every field below is collected so nothing '
|
||||
'typed here is lost, and every one of them is plainly not used '
|
||||
'by the analysis until the engine exists.'})
|
||||
wind_fields = (
|
||||
('wind_speed_vult', 'num', None, 'Design wind speed', 'Vult, mph'),
|
||||
('wind_risk_category', 'text', None, 'Risk category', ''),
|
||||
('wind_exposure', 'text', None, 'Exposure category', ''),
|
||||
('wind_enclosure', 'text', None, 'Enclosure classification', ''),
|
||||
('wind_asce_version', 'text', None, 'ASCE code version', ''),
|
||||
('wind_cc_chapter', 'text', None,
|
||||
'Components & Cladding chapter used', ''),
|
||||
('wind_mwfrs_chapter', 'text', None,
|
||||
'Main wind force resisting system chapter used', ''),
|
||||
('wind_pressure_psf', 'num', None,
|
||||
'Wind pressure (typed directly)', 'psf'),
|
||||
('wind_suction_psf', 'num', None,
|
||||
'Wind suction (typed directly)', 'psf'),
|
||||
)
|
||||
for key, editor, choices, flabel, fsym in wind_fields:
|
||||
blocks.append(_field(
|
||||
paf, known, problems, key, False, editor, choices=choices,
|
||||
fallback_label=flabel, fallback_symbol=fsym,
|
||||
fallback_hint='Not yet used by the solve -- nothing computes '
|
||||
'wind pressure yet.', what=flabel.lower()))
|
||||
wz_state, wz_text = _optional_note(
|
||||
values, 'wind_zone_rows',
|
||||
blank_sentence='No wind pressure zones entered.',
|
||||
missing_sentence='Wind pressure zones (start/end elevation, '
|
||||
'pressure or suction, magnitude) are not '
|
||||
'collected on this form yet -- this is unknown, '
|
||||
'not "no wind zones".',
|
||||
problems=problems, what='wind pressure zones along the height')
|
||||
if wz_state == 'filled':
|
||||
blocks.append(_table_from_text(
|
||||
wz_text,
|
||||
['ID', 'Start / end / type / magnitude (as typed)']))
|
||||
else:
|
||||
blocks.append({'kind': 'sentence', 'text': wz_text})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# ----------------------------------------------------------- Seismic
|
||||
blocks.append({'kind': 'heading', 'text': 'Seismic', 'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Nothing in this tool computes a seismic force yet either -- the '
|
||||
'same later-stage engine as wind. Shown here so it is not lost, '
|
||||
'not used by the analysis.'})
|
||||
blocks.append(_field(paf, known, problems, 'seismic_cs', False, 'num',
|
||||
what='the seismic coefficient, as a percent of dead load'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'seismic_zone_location', False, 'text',
|
||||
fallback_label='Seismic zone location(s)',
|
||||
fallback_hint='Which part of the panel height the seismic load '
|
||||
'applies over (e.g. full height). Not yet used by '
|
||||
'the solve.',
|
||||
what='where along the panel the seismic load applies'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'seismic_apply_to', False, 'text',
|
||||
fallback_label='Apply seismic multiplier to',
|
||||
fallback_hint='Which loads the seismic coefficient multiplies '
|
||||
'(dead load, self weight, or both). Not yet used '
|
||||
'by the solve.',
|
||||
what='what the seismic coefficient is applied to'))
|
||||
sz_state, sz_text = _optional_note(
|
||||
values, 'seismic_zone_rows',
|
||||
blank_sentence='No seismic load zones entered.',
|
||||
missing_sentence='Seismic load zones (start/end position, load '
|
||||
'percentage) are not collected on this form yet '
|
||||
'-- this is unknown, not "no seismic zones".',
|
||||
problems=problems, what='seismic load zones along the height')
|
||||
if sz_state == 'filled':
|
||||
blocks.append(_table_from_text(
|
||||
sz_text, ['ID', 'Start / end / seismic load % (as typed)']))
|
||||
else:
|
||||
blocks.append({'kind': 'sentence', 'text': sz_text})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# ------------------------------------------------------- Temperature
|
||||
blocks.append({'kind': 'heading', 'text': 'Temperature Loads',
|
||||
'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'How much hotter or colder one face of the panel gets than the '
|
||||
'other, and how stiff the concrete is treated as being while it '
|
||||
'moves. Not yet used by the solve.'})
|
||||
temp_fields = (
|
||||
('temp_ext_f', 'Exterior temperature', 'deg F'),
|
||||
('temp_int_f', 'Interior temperature', 'deg F'),
|
||||
('temp_elasticity_mult', 'Thermal elasticity multiplier', ''),
|
||||
('temp_alpha_t', 'Coefficient of thermal expansion', u'αT × 10⁻⁶'),
|
||||
)
|
||||
for key, flabel, fsym in temp_fields:
|
||||
blocks.append(_field(
|
||||
paf, known, problems, key, False, 'num',
|
||||
fallback_label=flabel, fallback_symbol=fsym,
|
||||
fallback_hint='Not yet used by the solve.',
|
||||
what=flabel.lower()))
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# ------------------------------------------------------------ Soil
|
||||
blocks.append({'kind': 'heading', 'text': 'Soil Loads', 'level': 2})
|
||||
|
||||
ext_col = [{'kind': 'heading', 'text': 'Exterior Soil Loads',
|
||||
'level': 3}]
|
||||
es_state, es_text = _optional_note(
|
||||
values, 'ext_soil_loads',
|
||||
blank_sentence='Design contains no exterior soil loads.',
|
||||
missing_sentence='Exterior soil loads are not collected on this '
|
||||
'form yet -- this is unknown, not "no exterior '
|
||||
'soil loads".',
|
||||
problems=problems, what='soil pressing on the outside face')
|
||||
ext_col.append({'kind': 'sentence', 'text': es_text})
|
||||
if es_state == 'filled':
|
||||
ext_col.append(_field(
|
||||
paf, known, problems, 'ext_soil_elevation_in', False, 'num',
|
||||
fallback_label='Elevation', fallback_symbol='in',
|
||||
fallback_hint='Where the exterior soil load starts, measured '
|
||||
'up from the bottom of the panel. Not yet used '
|
||||
'by the solve.', what='exterior soil elevation'))
|
||||
ext_col.append(_field(
|
||||
paf, known, problems, 'ext_soil_surcharge_psf', False, 'num',
|
||||
fallback_label='Surcharge', fallback_symbol='psf',
|
||||
fallback_hint='How much extra pressure the soil (plus '
|
||||
'anything sitting on top of it) puts on the '
|
||||
'panel. Not yet used by the solve.',
|
||||
what='exterior soil surcharge pressure'))
|
||||
|
||||
int_col = [{'kind': 'heading', 'text': 'Interior Soil Loads',
|
||||
'level': 3}]
|
||||
is_state, is_text = _optional_note(
|
||||
values, 'int_soil_loads',
|
||||
blank_sentence='Design contains no interior soil loads.',
|
||||
missing_sentence='Interior soil loads are not collected on this '
|
||||
'form yet -- this is unknown, not "no interior '
|
||||
'soil loads".',
|
||||
problems=problems, what='soil pressing on the inside face')
|
||||
int_col.append({'kind': 'sentence', 'text': is_text})
|
||||
if is_state == 'filled':
|
||||
int_col.append(_field(
|
||||
paf, known, problems, 'int_soil_elevation_in', False, 'num',
|
||||
fallback_label='Elevation', fallback_symbol='in',
|
||||
fallback_hint='Where the interior soil load starts, measured '
|
||||
'up from the bottom of the panel. Not yet used '
|
||||
'by the solve.', what='interior soil elevation'))
|
||||
int_col.append(_field(
|
||||
paf, known, problems, 'int_soil_surcharge_psf', False, 'num',
|
||||
fallback_label='Surcharge', fallback_symbol='psf',
|
||||
fallback_hint='How much extra pressure the soil (plus '
|
||||
'anything sitting on top of it) puts on the '
|
||||
'panel. Not yet used by the solve.',
|
||||
what='interior soil surcharge pressure'))
|
||||
|
||||
blocks.append({'kind': 'columns', 'columns': [ext_col, int_col]})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# ------------------------------------------------- Additional self weight
|
||||
blocks.append({'kind': 'heading', 'text': 'Additional Self Weight',
|
||||
'level': 2})
|
||||
asw_state, asw_text = _optional_note(
|
||||
values, 'addl_sw_magnitude_plf',
|
||||
blank_sentence='No additional self weight entered -- the '
|
||||
"analysis uses only the panel's own concrete "
|
||||
'weight.',
|
||||
missing_sentence='Additional self weight is not collected on '
|
||||
'this form yet -- this is unknown, not "none".',
|
||||
problems=problems, what='extra weight added over part of the panel')
|
||||
if asw_state == 'missing':
|
||||
blocks.append({'kind': 'sentence', 'text': asw_text})
|
||||
else:
|
||||
if asw_state == 'blank':
|
||||
blocks.append({'kind': 'sentence', 'text': asw_text})
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'addl_sw_start_in', False, 'num',
|
||||
fallback_label='Start from bottom', fallback_symbol='in',
|
||||
fallback_hint='Where the extra weight starts, measured up '
|
||||
'from the bottom of the panel. Not yet used by '
|
||||
'the solve.', what='additional self weight start'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'addl_sw_end_in', False, 'num',
|
||||
fallback_label='End from top', fallback_symbol='in',
|
||||
fallback_hint='Where the extra weight ends, measured down '
|
||||
'from the top of the panel. Not yet used by '
|
||||
'the solve.', what='additional self weight end'))
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'addl_sw_magnitude_plf', False, 'num',
|
||||
fallback_label='Magnitude', fallback_symbol='plf',
|
||||
fallback_hint='How much extra weight per foot of height is '
|
||||
'added over that stretch. Leave blank for none. '
|
||||
'Not yet used by the solve.',
|
||||
what='additional self weight magnitude'))
|
||||
|
||||
blocks.append({'kind': 'drawing', 'source': 'weight_diagram',
|
||||
'caption': 'Point loads and self weight along the wall',
|
||||
'w': 640, 'h': 200})
|
||||
|
||||
blocks.append({'kind': 'rule'})
|
||||
|
||||
# -------------------------------------------------------- Design Strips
|
||||
blocks.append({'kind': 'heading', 'text': 'Design Strips', 'level': 2})
|
||||
blocks.append({'kind': 'sentence', 'text':
|
||||
'Whether the panel is analysed as one member across its full '
|
||||
'width, or split into narrower strips first.'})
|
||||
blocks.append(_field(
|
||||
paf, known, problems, 'design_strips_method', False, 'choice',
|
||||
choices=['Single Member', 'Design Strips'],
|
||||
fallback_label='Analysis method',
|
||||
fallback_hint='Whether the whole cross section is analysed as one '
|
||||
'member, or split into design strips. Blank uses '
|
||||
'the whole cross section. Not yet used by the '
|
||||
'solve.', what='the design-strip analysis method'))
|
||||
ds_state, ds_text = _optional_note(
|
||||
values, 'design_strips_rows',
|
||||
blank_sentence='No design strips are defined; the entire cross '
|
||||
'section will be used.',
|
||||
missing_sentence='Design strips are not collected on this form '
|
||||
'yet -- this is unknown, not "no strips".',
|
||||
problems=problems, what='the design strip boundaries')
|
||||
if ds_state == 'filled':
|
||||
blocks.append(_table_from_text(ds_text, ['ID', 'Strip (as typed)']))
|
||||
else:
|
||||
blocks.append({'kind': 'sentence', 'text': ds_text})
|
||||
|
||||
return blocks, problems
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Where the PLANT knowledge layer lives on disk.
|
||||
|
||||
Split out of `designspec` for one reason: `designspec` imports the Revit API
|
||||
at module scope (evidence scraping needs a live document), and choosing the
|
||||
plant knowledge file is pure path arithmetic that a host CPython test can and
|
||||
should exercise directly. `designspec` re-exports every name here, so nothing
|
||||
that already says `designspec.plant_knowledge_path()` had to change.
|
||||
|
||||
THE LAYOUT, in one line:
|
||||
|
||||
<root>/knowledge.yaml the machine-wide plant file
|
||||
<root>/<plant>/knowledge.yaml one folder per plant
|
||||
|
||||
A job's own knowledge file names its plant in `job.plant`; `knowledge.load()`
|
||||
reads that name BEFORE it merges anything and passes it here. A job that
|
||||
names no plant reads the machine-wide file, which is how every install worked
|
||||
before per-plant folders existed and still does.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
import os
|
||||
import re
|
||||
|
||||
from vibe.compat import string_types
|
||||
|
||||
# The plant-wide knowledge file: one per machine, above every project.
|
||||
PLANT_FOLDER = 'WillTools'
|
||||
PLANT_ENV = 'WILLTOOLS_KNOWLEDGE'
|
||||
|
||||
# What a plant id may be made of, because it is used as a PATH COMPONENT.
|
||||
# It arrives from a YAML file, so `..`, a slash or a drive letter in it would
|
||||
# be a directory traversal written by whoever edits a job file. Anything
|
||||
# outside this set is refused outright rather than scrubbed into something
|
||||
# else: a plant id that quietly became a different folder name is exactly the
|
||||
# silence this codebase keeps paying for.
|
||||
_PLANT_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
|
||||
|
||||
|
||||
def valid_plant_id(plant):
|
||||
"""Is this string usable as a plant id, i.e. as one folder name?
|
||||
|
||||
Returns False for None, for the empty string, and for anything carrying a
|
||||
separator, a drive letter or a dot-dot. See _PLANT_ID_RE.
|
||||
"""
|
||||
if not plant or not isinstance(plant, string_types):
|
||||
return False
|
||||
return bool(_PLANT_ID_RE.match(plant.strip()))
|
||||
|
||||
|
||||
def plant_root():
|
||||
"""The folder the plant knowledge lives in -- one file, or one per plant.
|
||||
|
||||
THE ROOT IS A FOLDER AND THE MACHINE-WIDE FILE IS `knowledge.yaml` INSIDE
|
||||
IT. That relationship has always held (%APPDATA%/WillTools/knowledge.yaml)
|
||||
and is what lets WILLTOOLS_KNOWLEDGE keep meaning what it always meant
|
||||
while also naming a root:
|
||||
|
||||
set to a FOLDER that folder is the root. Nothing else to say.
|
||||
set to a FILE that file is the machine-wide plant file, so its
|
||||
DIRECTORY is the root, by the same relationship.
|
||||
Not a guess -- the definition, read backwards.
|
||||
unset %APPDATA%/WillTools, as ever.
|
||||
|
||||
Which is why a machine already pointing the variable at a single file
|
||||
picks up per-plant folders with no change to the variable: the folders
|
||||
are siblings of the file it already names.
|
||||
|
||||
A path that is set but exists as neither is treated as a FILE, because
|
||||
that is what the variable has meant for its whole life and the operator
|
||||
almost certainly meant a file that has moved -- the missing path is then
|
||||
reported by name instead of a different, existing path being read in
|
||||
silence.
|
||||
"""
|
||||
override = os.environ.get(PLANT_ENV)
|
||||
if not override:
|
||||
base = os.environ.get('APPDATA') or os.path.expanduser('~')
|
||||
return os.path.join(base, PLANT_FOLDER)
|
||||
if os.path.isdir(override):
|
||||
return override
|
||||
return os.path.dirname(override) or override
|
||||
|
||||
|
||||
def plant_knowledge_path(plant=None):
|
||||
"""The plant knowledge file this run should read.
|
||||
|
||||
No plant named -- the ordinary case, and every install that predates the
|
||||
key -- returns the machine-wide file exactly as this function always has:
|
||||
WILLTOOLS_KNOWLEDGE if it names one, else <root>/knowledge.yaml.
|
||||
|
||||
A plant named (`job.plant` in the job layer) returns
|
||||
<root>/<plant>/knowledge.yaml. A SECOND PLANT IS A NEW FOLDER, never an
|
||||
edit to the first one's file. See plant_root() for what <root> is and why
|
||||
a machine already pointing the variable at a single file needs no change.
|
||||
|
||||
The per-plant FOLDER and the machine-wide FILE share a root and cannot
|
||||
collide: one is a directory, the other is called `knowledge.yaml`.
|
||||
|
||||
An INVALID plant id -- a separator, a `..`, an empty string -- resolves to
|
||||
NOTHING and returns None. load() reports it. Scrubbing a malformed name
|
||||
into some nearby folder is how a run ends up on another plant's rules
|
||||
while looking entirely normal.
|
||||
"""
|
||||
if plant is None:
|
||||
override = os.environ.get(PLANT_ENV)
|
||||
if override and not os.path.isdir(override):
|
||||
return override # the single-file arrangement, untouched
|
||||
return os.path.join(plant_root(), 'knowledge.yaml')
|
||||
if not valid_plant_id(plant):
|
||||
return None
|
||||
return os.path.join(plant_root(), plant.strip(), 'knowledge.yaml')
|
||||
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Reading the model for the reinforcing index. The Revit half only.
|
||||
|
||||
The split is the one `ticketpage.py` / `ticketchecks.py` already models:
|
||||
everything that can be decided without a document lives in
|
||||
`vibe.reinforcecore` and is unit-tested on host CPython; this file does the
|
||||
gathering and nothing else. It is READ ONLY -- no Transaction, no parameter
|
||||
write, no delete, anywhere in it, and there is no reason for that to change:
|
||||
the feature writes nothing to the model.
|
||||
|
||||
WHERE THE CACHE LIVES
|
||||
---------------------
|
||||
Beside the model, under the same `_ticketqc` root the ticket evidence and
|
||||
specs already use (`designspec.root_dir`), in `reinforce/index.json`. It
|
||||
holds job geometry, so it may not enter the code repository; that root is
|
||||
gitignored and, when `WILLTOOLS_JOBS` is set, is not inside the repository at
|
||||
all. Nothing about this index is portable between models, so the model's own
|
||||
folder is also where it is actually useful.
|
||||
|
||||
WHAT THE COST ACTUALLY IS -- measured, this model, 2026-08-30
|
||||
-------------------------------------------------------------
|
||||
755 assemblies collected + both flags read 0.29 s
|
||||
every assembly's cheap signature 0.71 s
|
||||
full geometry build, all 690 eligible donors 6.9 s (10 ms each)
|
||||
slowest single piece 0.061 s
|
||||
serialise + write the finished index (983 kB) 2.0 s
|
||||
---- what a button press actually costs ----
|
||||
full rebuild, cold cache 10.1 s
|
||||
full rebuild, first press after a Revit restart 16.2 s
|
||||
refresh with nothing changed 2.3 s
|
||||
read the index without refreshing 1.4 s
|
||||
|
||||
---- schema 5, same model, measured 2026-08-30 ----
|
||||
assemblies in the model 755
|
||||
eligible donors 690
|
||||
in-scope pieces added as TARGETS 28 (WP, not donors)
|
||||
pieces in the file 718
|
||||
index size 1.01 MB
|
||||
|
||||
Quote a rebuild as "~10-16 s", not as ten: the lower number was measured in a
|
||||
session that had been open for hours, and the first press after a restart is
|
||||
the one the owner actually experiences.
|
||||
|
||||
That is not the 30-60 s per piece this feature was warned about, and the
|
||||
difference is worth stating because it moves the design: the expensive read
|
||||
in this project is GENERATING A VIEW -- what `read_ticket` pays when it walks
|
||||
a ticket sheet -- and nothing here touches a view. `concrete_solid` asks the
|
||||
panel element for its own geometry and `_openings` reads faces off that
|
||||
solid. So the index is not expensive to build; it is still cached, because
|
||||
the owner's constraint is that a button press must not re-analyse the model,
|
||||
and 6 s of frozen UI per click is 6 s too many.
|
||||
|
||||
Honesty about that number: this session was already open when it was
|
||||
measured. Only a Revit restart restores a truly cold cache and a restart is
|
||||
the owner's to make. The structural argument -- no view is generated -- is
|
||||
what the number rests on, not a claim that the session was cold.
|
||||
"""
|
||||
# pylint: disable=import-error,broad-except,invalid-name
|
||||
import os
|
||||
import time
|
||||
|
||||
from vibe import reinforcecore as core
|
||||
|
||||
FOLDER = 'reinforce'
|
||||
FILENAME = 'index.json'
|
||||
|
||||
# The two instance parameters on the assembly that decide donor eligibility.
|
||||
# Named here rather than in the pure module because they are this job's
|
||||
# ticketing-system parameters, read by name off the element.
|
||||
P_REINFORCED = 'TICKET_IS_REINFORCED'
|
||||
P_ON_HOLD = 'ON_HOLD'
|
||||
|
||||
# ------------------------------------------------------- the review signal
|
||||
#
|
||||
# WHERE 'MASTER' ACTUALLY LIVES -- measured on this model, 2026-08-30, before
|
||||
# anything was written to match on it, because a guess about a hand-typed
|
||||
# field is the kind of thing that quietly matches nothing.
|
||||
#
|
||||
# The owner said "identity description". Four candidate free-text fields were
|
||||
# scanned across all 755 assemblies -- the assembly's identity comment, the
|
||||
# panel instance's identity comment, and two identity fields on the panel
|
||||
# type and instance. Only the first two ever contain the word:
|
||||
#
|
||||
# assembly identity comment 18 pieces <- what this reads
|
||||
# panel-instance identity comment 16 pieces (a strict subset of it)
|
||||
# panel-type identity field 0 -- 5 distinct values, all of the
|
||||
# form 'PRECAST CONCRETE WALL
|
||||
# PANEL'; a product class, not a
|
||||
# review state
|
||||
# instance short identity field 0 -- a category stub per piece
|
||||
#
|
||||
# The assembly field is a SUPERSET of the instance field on this model: every
|
||||
# one of the 18 carries it on the assembly, two carry it ONLY there, and none
|
||||
# carries it only on the instance. So this reads the assembly and nothing
|
||||
# else -- one parameter read, no member walk, and the same source in the cheap
|
||||
# signature pass as in the full extract, which is what keeps the staleness
|
||||
# stamp honest about this field.
|
||||
#
|
||||
# THE PARAMETER IS NAMED BY ROLE, NEVER BY LITERAL. This extension is the
|
||||
# shareable half of the project and a shared-parameter name is plant
|
||||
# knowledge: another office's template calls the same field something else,
|
||||
# and a name written into code is a plant that cannot be served without
|
||||
# editing the code. `parameters.member.identity_description` is the role;
|
||||
# `knowledge.param_names` resolves it to the office's real name(s), tried in
|
||||
# order.
|
||||
#
|
||||
# UNMAPPED MEANS NOT RUN, NEVER A FALLBACK. With no name behind the role the
|
||||
# signal STANDS DOWN: every piece gets `master = None`, which is the project's
|
||||
# could-not-judge value and is not False. It must not quietly guess a name --
|
||||
# that would work in this office and silently do nothing in every other one,
|
||||
# while the report went on printing "no donor here is reviewed" as though
|
||||
# somebody had looked. A plant that genuinely has no such field says so the
|
||||
# way every other role does, under `parameters.not_applicable`.
|
||||
R_IDENTITY = 'member.identity_description'
|
||||
|
||||
def master_token(know=None):
|
||||
"""The plant's own word for a reviewed template.
|
||||
|
||||
The default lives in `reinforcecore.DEFAULT_MASTER_TOKEN`, so a plant
|
||||
that maps the field but uses another word only has to set the key.
|
||||
|
||||
The path is written out as a LITERAL rather than through a module
|
||||
constant on purpose: `tools/knowledge_drift.py` matches literal paths and
|
||||
a key reached through a variable lands in its blind-spot list instead of
|
||||
being credited with a reader -- which is how a key ends up reported as
|
||||
declared-and-never-read while a check is quietly using it.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
return knowledge.get(know, 'reinforce.master_token',
|
||||
core.DEFAULT_MASTER_TOKEN)
|
||||
|
||||
|
||||
def identity_params(know=None):
|
||||
"""The office's name(s) for the identity field, or [] when unmapped."""
|
||||
from vibe import knowledge
|
||||
return knowledge.param_names(know, R_IDENTITY)
|
||||
|
||||
|
||||
def master_of(el, names, token):
|
||||
"""(master, note) off one element. `master` is None when NOT RUN.
|
||||
|
||||
None is could-not-judge -- the role is unmapped and nothing was looked at.
|
||||
False is judged: the field was read and does not carry the token.
|
||||
"""
|
||||
if not names:
|
||||
return None, None
|
||||
for name in names:
|
||||
note = _text(el, name)
|
||||
if note:
|
||||
return core.is_master_text(note, token), note
|
||||
return False, None
|
||||
|
||||
|
||||
# ------------------------------------------------- the elevation accelerator
|
||||
#
|
||||
# THE OPTIONAL FAST PATH, NEVER THE SOURCE OF TRUTH. The source of truth for
|
||||
# same-elevation is geometric (`_elevation_band_of`, below, in the expensive
|
||||
# pass) and is computed for every piece regardless of this section. This is
|
||||
# only a shortcut: a plant-declared GROUPING KEY a wall may already carry
|
||||
# for an unrelated reason -- MC's is `Tagged_Sheet`, the drafting sheet a
|
||||
# wall's elevation is drawn on. Owner, 2026-09-02: "对MC而言,所有的墙应该都
|
||||
# 有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个
|
||||
# elevation,不用跑哪个几何引擎。" Two panels sharing a non-empty value here
|
||||
# are the same elevation with no geometry involved.
|
||||
#
|
||||
# ABSENCE IS THE NORMAL STATE, not a stand-down worth reporting. Owner, the
|
||||
# same day, correcting the scope: other plants carry no such parameter at
|
||||
# all, and even on MC it exists only on complex jobs -- a simple job with a
|
||||
# handful of elevations carries none, by a deliberate human-vs-computer
|
||||
# workload balance. So an unmapped role, or a blank value on a piece, is
|
||||
# expected far more often than not, and `reinforcecore.elevation_match`
|
||||
# simply falls through to the geometric floor without comment.
|
||||
#
|
||||
# Read off the PANEL, exactly as `design_number` is (by role, never a
|
||||
# literal name) -- `parameters.panel.elevation_group`.
|
||||
R_ELEVATION_GROUP = 'panel.elevation_group'
|
||||
|
||||
|
||||
def _panel_member(doc, asm):
|
||||
"""The Structural Framing member of one assembly, cheaply -- no geometry.
|
||||
|
||||
The same walk `extract()` does to find the panel it measures, pulled out
|
||||
so the CHEAP pass can find it too: reading `elevation_group` needs the
|
||||
panel, and the signature has to see the same source `extract()` records
|
||||
or the two could disagree about what changed.
|
||||
"""
|
||||
try:
|
||||
member_ids = list(asm.GetMemberIds())
|
||||
except Exception:
|
||||
return None
|
||||
for mid in member_ids:
|
||||
member = doc.GetElement(mid)
|
||||
if member is None:
|
||||
continue
|
||||
try:
|
||||
cat = member.Category.Name if member.Category else ''
|
||||
except Exception:
|
||||
cat = ''
|
||||
if cat == 'Structural Framing':
|
||||
return member
|
||||
return None
|
||||
|
||||
|
||||
def elevation_group_of(doc, asm, know=None):
|
||||
"""The plant's elevation-grouping text off one assembly's panel, or None.
|
||||
|
||||
None means could-not-judge, for any of: no Structural-Framing member to
|
||||
read it off, the role is unmapped (the common case -- see the section
|
||||
note above), or the field is blank on this piece. `Q.rstr` already
|
||||
returns None for an unmapped role, the same stand-down `design_number`
|
||||
uses. Never raises: a piece this cannot be read for still has the
|
||||
geometric floor to fall back on.
|
||||
"""
|
||||
panel = _panel_member(doc, asm)
|
||||
if panel is None:
|
||||
return None
|
||||
from vibe import ticketqc as Q
|
||||
try:
|
||||
return Q.rstr(panel, know, R_ELEVATION_GROUP)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def index_path(doc):
|
||||
from vibe import designspec
|
||||
return os.path.join(designspec.sub_dir(doc, FOLDER), FILENAME)
|
||||
|
||||
|
||||
def index_path_in(job_folder):
|
||||
"""The index inside one model's job folder. THE OFFLINE RESOLVER.
|
||||
|
||||
It takes a folder rather than a document, so nothing on this path imports
|
||||
Revit -- `index_path` reaches `designspec`, which imports the Revit API at
|
||||
module scope, and that single line is what would otherwise drag the whole
|
||||
API into an offline run. It also does not CREATE anything: reading an
|
||||
index must never leave a folder behind on a machine that has no model.
|
||||
|
||||
The layout is `designspec`'s and is repeated here rather than shared,
|
||||
because the doc-side function is the one that knows about the model-folder
|
||||
and APPDATA fallbacks. When WILLTOOLS_JOBS is set -- the case that matters
|
||||
offline -- the whole path is `<jobs root>/<model title>/reinforce/index.json`
|
||||
and `jobs_folder()` builds the first half of it.
|
||||
"""
|
||||
return os.path.join(job_folder, FOLDER, FILENAME)
|
||||
|
||||
|
||||
def jobs_folder(jobs_root, model):
|
||||
"""`<jobs root>/<model title>` -- what WILLTOOLS_JOBS means, offline."""
|
||||
return os.path.join(jobs_root, model or 'untitled')
|
||||
|
||||
|
||||
def doc_key(doc):
|
||||
"""Which model a cache belongs to, without writing a path into the repo."""
|
||||
try:
|
||||
return doc.Title or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the cheap pass
|
||||
|
||||
def _name_of(el):
|
||||
from Autodesk.Revit.DB import Element
|
||||
try:
|
||||
return Element.Name.GetValue(el)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _text(el, pname):
|
||||
"""A string parameter's value, or None. Never raises. READ ONLY."""
|
||||
try:
|
||||
p = el.LookupParameter(pname)
|
||||
except Exception:
|
||||
return None
|
||||
if p is None:
|
||||
return None
|
||||
try:
|
||||
value = p.AsString()
|
||||
except Exception:
|
||||
value = None
|
||||
if not value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _flag(el, pname):
|
||||
"""1 / 0 / None. None means the parameter is not on this element."""
|
||||
try:
|
||||
p = el.LookupParameter(pname)
|
||||
except Exception:
|
||||
return None
|
||||
if p is None:
|
||||
return None
|
||||
try:
|
||||
return int(p.AsInteger())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def assemblies(doc):
|
||||
"""Every assembly instance, as (name, element). Scoped collector only."""
|
||||
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
|
||||
out = []
|
||||
for a in (FilteredElementCollector(doc)
|
||||
.OfClass(AssemblyInstance)
|
||||
.WhereElementIsNotElementType()
|
||||
.ToElements()):
|
||||
name = _name_of(a)
|
||||
if name:
|
||||
out.append((name, a))
|
||||
return out
|
||||
|
||||
|
||||
def live_signatures(doc, elements=None, know=None):
|
||||
"""{name: (signature, reinforced, on_hold)} for every assembly.
|
||||
|
||||
No geometry is read here. This is the pass that decides what has to be
|
||||
rebuilt, so it has to stay cheap enough to run on every button press --
|
||||
measured at 0.23 s over 755 assemblies. The MASTER flag joined it in
|
||||
2026-08-30 at the cost of one string parameter read per assembly:
|
||||
measured over the same 755 assemblies, reading the identity field and
|
||||
five other string fields together took 1.07 s including the member walk,
|
||||
so one field on its own is well inside the same budget. It has to be here --
|
||||
see `reinforcecore.signature` for why a hand-typed field that is not in
|
||||
the stamp is a field that never takes effect.
|
||||
|
||||
THE GEOMETRIC ELEVATION FLOOR IS NOT HERE, and it briefly stood in this
|
||||
spot under a different name for one day (2026-09-02): the owner's
|
||||
ruling made it a purely GEOMETRIC fact, and geometry needs the panel's
|
||||
measured solid -- unaffordable in a pass that has to stay cheap enough
|
||||
to run on every button press. See `reinforcecore.elevation_band`'s
|
||||
module note for the full reasoning; the band is captured only in
|
||||
`extract()`, alongside `thickness_in` and `features`, and never feeds
|
||||
this cheap stamp.
|
||||
|
||||
THE ELEVATION ACCELERATOR IS HERE, and costs about what MASTER does:
|
||||
reading `elevation_group` needs the PANEL, not the assembly, so
|
||||
`elevation_group_of` pays its own member walk per assembly
|
||||
(`_panel_member`) rather than one parameter read off `a` directly. That
|
||||
walk is the same one `extract()` already pays to find the panel it
|
||||
measures -- it is not new work, only moved earlier. On most jobs the
|
||||
role is unmapped (see the section note above `R_ELEVATION_GROUP`), so
|
||||
this is one more `LookupParameter` call that returns nothing -- cheap
|
||||
even when it never answers.
|
||||
"""
|
||||
token = master_token(know)
|
||||
names = identity_params(know)
|
||||
out = {}
|
||||
for name, a in (elements if elements is not None else assemblies(doc)):
|
||||
reinforced = _flag(a, P_REINFORCED)
|
||||
on_hold = _flag(a, P_ON_HOLD)
|
||||
master = master_of(a, names, token)[0]
|
||||
elevation_group = elevation_group_of(doc, a, know)
|
||||
try:
|
||||
member_ids = [_int_id(i) for i in a.GetMemberIds()]
|
||||
except Exception:
|
||||
member_ids = []
|
||||
out[name] = (core.signature(reinforced, on_hold, member_ids, master,
|
||||
elevation_group),
|
||||
reinforced, on_hold)
|
||||
return out
|
||||
|
||||
|
||||
def _int_id(element_id):
|
||||
try:
|
||||
return element_id.IntegerValue
|
||||
except Exception:
|
||||
return element_id.Value
|
||||
|
||||
|
||||
# ------------------------------------------------------- the expensive pass
|
||||
|
||||
def _global_box_of_solid(solid):
|
||||
"""The solid's own bounding box, in RAW GLOBAL (model) coordinates.
|
||||
|
||||
`Solid.GetBoundingBox()` over the solid's 8 box corners, mirroring
|
||||
`ticketqc._one_solid_box`'s reasoning exactly: a curved edge's two
|
||||
"endpoints" coincide at the seam, so walking `solid.Edges` directly
|
||||
collapses a round feature to a sliver -- `Solid.GetBoundingBox()` does
|
||||
not have that failure. Unlike `_one_solid_box` this stops at the
|
||||
solid's own `box.Transform` and never applies the panel's inverse
|
||||
afterward: a building elevation is measured in the MODEL's frame, not
|
||||
the panel's own local one.
|
||||
|
||||
Returns `(lo, hi)`, each an (x, y, z) list, or `(None, None)`.
|
||||
"""
|
||||
if solid is None:
|
||||
return None, None
|
||||
try:
|
||||
from Autodesk.Revit.DB import XYZ
|
||||
box = solid.GetBoundingBox()
|
||||
if box is None:
|
||||
return None, None
|
||||
local = box.Transform
|
||||
lo = [None, None, None]
|
||||
hi = [None, None, None]
|
||||
for i in (0, 1):
|
||||
for j in (0, 1):
|
||||
for k in (0, 1):
|
||||
corner = XYZ(box.Min.X if i else box.Max.X,
|
||||
box.Min.Y if j else box.Max.Y,
|
||||
box.Min.Z if k else box.Max.Z)
|
||||
q = local.OfPoint(corner)
|
||||
for axis, value in enumerate((q.X, q.Y, q.Z)):
|
||||
if lo[axis] is None or value < lo[axis]:
|
||||
lo[axis] = value
|
||||
if hi[axis] is None or value > hi[axis]:
|
||||
hi[axis] = value
|
||||
if lo[0] is None:
|
||||
return None, None
|
||||
return lo, hi
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _elevation_band_of(panel, extents, solid):
|
||||
"""`core.elevation_band(...)` off the panel already being measured.
|
||||
|
||||
`extents` is `Q.concrete_extents(panel, frame)`'s (lo, hi) LOCAL-frame
|
||||
feet triples -- the same measurement `thickness_in` already comes from,
|
||||
read again here rather than reconverted from `lo_in` / `hi_in` to avoid
|
||||
a needless feet -> inches -> feet round trip. `solid` is the same one
|
||||
`extract()` already paid for to build `features` -- no second geometry
|
||||
pass; only `panel.GetTransform()` is read again, and that is a
|
||||
placement property, not a solve.
|
||||
|
||||
Never raises: any failure reading the transform or the box is
|
||||
could-not-judge, exactly what `core.elevation_band` itself returns for
|
||||
an ambiguous or off-axis panel -- a ranking signal going quiet, never a
|
||||
reason to fail the whole piece.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
try:
|
||||
spans_ft = [extents[1][i] - extents[0][i] for i in (0, 1, 2)]
|
||||
transform = panel.GetTransform()
|
||||
basis = (transform.BasisX, transform.BasisY, transform.BasisZ)
|
||||
basis_xyz = [(b.X, b.Y, b.Z) for b in basis]
|
||||
except Exception:
|
||||
return None
|
||||
box_lo, box_hi = _global_box_of_solid(solid)
|
||||
try:
|
||||
return core.elevation_band(
|
||||
spans_ft, basis_xyz[0], basis_xyz[1], basis_xyz[2],
|
||||
box_lo, box_hi,
|
||||
near_square_ratio=knowledge.UP_END_NEAR_SQUARE_RATIO)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract(doc, name, asm, sig, know=None):
|
||||
"""One piece's index record. Never raises: a failure becomes `error`.
|
||||
|
||||
Every early return is a could-not-judge with a reason attached, never an
|
||||
empty feature list. A piece whose solid would not read and a piece with
|
||||
no openings are different answers.
|
||||
"""
|
||||
from vibe import ticketqc as Q, liftexport as L
|
||||
|
||||
reinforced = _flag(asm, P_REINFORCED)
|
||||
on_hold = _flag(asm, P_ON_HOLD)
|
||||
# The review signal. Read here as well as in the cheap pass, and from the
|
||||
# same parameter, so a record and its own signature can never disagree
|
||||
# about it. A piece that could not be measured still carries it -- being
|
||||
# unreadable does not un-review a piece.
|
||||
master, note = master_of(asm, identity_params(know), master_token(know))
|
||||
|
||||
def fail(why, **extra):
|
||||
return core.piece_record(name, _int_id(asm.Id), sig,
|
||||
reinforced, on_hold, error=why,
|
||||
master=master, master_note=note, **extra)
|
||||
|
||||
panel = None
|
||||
families = {}
|
||||
try:
|
||||
member_ids = list(asm.GetMemberIds())
|
||||
except Exception as exc:
|
||||
return fail('members unreadable: {0}'.format(exc))
|
||||
|
||||
for mid in member_ids:
|
||||
member = doc.GetElement(mid)
|
||||
if member is None:
|
||||
continue
|
||||
try:
|
||||
fam = member.Symbol.Family.Name
|
||||
except Exception:
|
||||
continue
|
||||
families[fam] = families.get(fam, 0) + 1
|
||||
# NOT filtered on SuperComponent -- but the reason written here
|
||||
# first was wrong in both halves, and it was corrected 2026-08-30
|
||||
# against the live model:
|
||||
#
|
||||
# * SuperComponent set means the element IS A CHILD; it holds the
|
||||
# PARENT. Measured: SP03 members return
|
||||
# CONNX_HPKM39_COLUMN_SHOE(10 ANCHOR BOLTS) as their super
|
||||
# component. The old comment had it backwards, claiming the
|
||||
# property marks an element that HAS children.
|
||||
# * It is not "nearly every member". Over 25 WP assemblies,
|
||||
# 553 of 2755 members carry one -- 20.1%.
|
||||
#
|
||||
# So filtering would not empty the count. It would remove a fifth of
|
||||
# it, and that fifth is nested children that a naive count counts
|
||||
# twice: once as themselves and once inside their parent.
|
||||
#
|
||||
# The inventory is still recorded RAW and deliberately so -- this is
|
||||
# an index, and a consumer that needs a de-duplicated count should
|
||||
# decide its own rule and say which it used. But a consumer that
|
||||
# counts `families` without de-duplicating is over-counting by
|
||||
# roughly that 20%, and it should know that rather than inherit a
|
||||
# comment saying the property is useless.
|
||||
try:
|
||||
cat = member.Category.Name if member.Category else ''
|
||||
except Exception:
|
||||
cat = ''
|
||||
if cat == 'Structural Framing' and panel is None:
|
||||
panel = member
|
||||
|
||||
if panel is None:
|
||||
return fail('no structural-framing member to measure',
|
||||
families=families)
|
||||
|
||||
try:
|
||||
design_number = Q.rstr(panel, know, 'panel.design_number')
|
||||
except Exception:
|
||||
design_number = None
|
||||
|
||||
# THE ELEVATION ACCELERATOR -- read the same way and at the same point
|
||||
# as design_number, since both are hand-set fields off the same panel.
|
||||
# None the vast majority of the time (unmapped role, or blank on this
|
||||
# piece) is expected, not a fault -- the geometric floor below is what
|
||||
# actually answers on most jobs. See `reinforcecore.elevation_match`'s
|
||||
# module note.
|
||||
try:
|
||||
elevation_group = Q.rstr(panel, know, R_ELEVATION_GROUP)
|
||||
except Exception:
|
||||
elevation_group = None
|
||||
|
||||
try:
|
||||
frame = Q._panel_frame(panel)
|
||||
except Exception as exc:
|
||||
return fail('frame failed: {0}'.format(exc), families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group)
|
||||
if frame is None:
|
||||
return fail('no panel frame', families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group)
|
||||
|
||||
try:
|
||||
extents = Q.concrete_extents(panel, frame)
|
||||
except Exception as exc:
|
||||
return fail('extents failed: {0}'.format(exc), families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group)
|
||||
if extents is None:
|
||||
return fail('no concrete extents', families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group)
|
||||
|
||||
# concrete_extents is in FEET; _openings and opening_penetration both want
|
||||
# INCHES. Getting this backwards is a 12x error, which is the single most
|
||||
# expensive mistake this project has made.
|
||||
lo_in = [v * 12.0 for v in extents[0]]
|
||||
hi_in = [v * 12.0 for v in extents[1]]
|
||||
axes = L._axes(lo_in, hi_in)
|
||||
length_axis, width_axis, thick_axis = axes
|
||||
length_ft = (hi_in[length_axis] - lo_in[length_axis]) / 12.0
|
||||
width_ft = (hi_in[width_axis] - lo_in[width_axis]) / 12.0
|
||||
thickness_in = hi_in[thick_axis] - lo_in[thick_axis]
|
||||
|
||||
try:
|
||||
solid = L.concrete_solid(panel)
|
||||
except Exception as exc:
|
||||
return fail('solid failed: {0}'.format(exc), families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group,
|
||||
length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in)
|
||||
if solid is None:
|
||||
return fail('no concrete solid', families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group,
|
||||
length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in)
|
||||
|
||||
# THE ELEVATION FLOOR -- purely geometric, owner's ruling 2026-09-02;
|
||||
# see `reinforcecore.elevation_band`'s module note for the definition.
|
||||
# Computed for EVERY piece regardless of `elevation_group` above -- it
|
||||
# is the answer a job with no grouping parameter has, and the one that
|
||||
# keeps working if the parameter is added or dropped later. Off the
|
||||
# SAME solid `features` is about to be measured from -- no second
|
||||
# geometry pass -- plus the panel's own placement transform, which is a
|
||||
# cheap property read, not a solve. A failure here is a ranking signal
|
||||
# that could not be determined, never a reason to fail the whole piece:
|
||||
# `thickness_in` and `features` are still worth having even when the
|
||||
# panel turns out to be near-square or skewed off every global axis.
|
||||
elevation_band = _elevation_band_of(panel, extents, solid)
|
||||
|
||||
try:
|
||||
rects = L._openings(solid, frame, lo_in, hi_in, axes)
|
||||
except Exception as exc:
|
||||
return fail('openings failed: {0}'.format(exc), families=families,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
elevation_group=elevation_group,
|
||||
elevation_band=elevation_band,
|
||||
length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in)
|
||||
|
||||
# Penetration is a separate solve and can fail on its own. When it does,
|
||||
# the rectangles are still real -- so the features are kept and `through`
|
||||
# goes to None on each, which is the could-not-judge value, not False.
|
||||
try:
|
||||
pen = L.opening_penetration(panel, frame, solid, rects, axes,
|
||||
lo_in, hi_in)
|
||||
except Exception:
|
||||
pen = []
|
||||
|
||||
features = []
|
||||
for i, rect in enumerate(rects):
|
||||
p = pen[i] if i < len(pen) else {}
|
||||
features.append(core.feature(
|
||||
name, i, rect[0], rect[1], rect[2], rect[3],
|
||||
p.get('through'), p.get('concrete_in'),
|
||||
length_ft, width_ft, thickness_in))
|
||||
|
||||
return core.piece_record(
|
||||
name, _int_id(asm.Id), sig, reinforced, on_hold,
|
||||
design_number=design_number, panel_id=_int_id(panel.Id),
|
||||
length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in,
|
||||
families=families, features=features,
|
||||
master=master, master_note=note,
|
||||
elevation_band=elevation_band, elevation_group=elevation_group)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ refresh
|
||||
|
||||
def scope_of(know):
|
||||
"""The declared scope, or an undeclared one. Never raises on a bad file."""
|
||||
from vibe import scope as scopelib
|
||||
if not know:
|
||||
return scopelib.Scope()
|
||||
try:
|
||||
return scopelib.of(know)
|
||||
except Exception:
|
||||
return scopelib.Scope()
|
||||
|
||||
|
||||
def scope_in(cache):
|
||||
"""(Scope, stamped) out of a cache. NO DOCUMENT, NO KNOWLEDGE FILE.
|
||||
|
||||
`stamped` is False when the index carries no scope at all -- built before
|
||||
the stamp existed, or by a caller that had no knowledge layer. That is not
|
||||
the same as "no scope was declared", and an offline reader has to say
|
||||
which: the first means nobody knows what the pool was bounded by, the
|
||||
second means it was deliberately unbounded. Both come back as an
|
||||
undeclared `Scope()`; only this flag tells them apart.
|
||||
"""
|
||||
from vibe import scope as scopelib
|
||||
stamp = (cache or {}).get('scope')
|
||||
if not isinstance(stamp, dict):
|
||||
return scopelib.Scope(), False
|
||||
return scopelib.Scope(prefixes=stamp.get('prefixes') or (),
|
||||
note=stamp.get('note'),
|
||||
declared_by=stamp.get('declared_by'),
|
||||
source='the index, built ' +
|
||||
str((cache or {}).get('built_at'))), True
|
||||
|
||||
|
||||
def refresh(doc, budget_s=None, limit=None, rebuild_all=False, know=None,
|
||||
on_piece=None, targets=True):
|
||||
"""Bring the index up to date and return (cache, report). READ ONLY.
|
||||
|
||||
`budget_s` stops the build partway and leaves the rest for the next call.
|
||||
That is what makes a cold index usable immediately: the first press
|
||||
indexes what it can afford, answers with what it has, and says what it
|
||||
could not see. `report['coverage']['missing']` is that list and a caller
|
||||
that drops it is claiming a completeness the index does not have.
|
||||
|
||||
`rebuild_all` ignores the cached signatures. It is the escape hatch for
|
||||
the thing signatures cannot see -- an opening moved or resized without
|
||||
the member set changing -- and it is cheap: a full build of this model's
|
||||
690 eligible donors measured 6.07 s (16.2 s on the first build after a
|
||||
Revit restart, measured 2026-08-30).
|
||||
|
||||
`targets` (schema 5, default ON) also measures every piece INSIDE THE
|
||||
DECLARED SCOPE that is not an eligible donor. Those are the pieces
|
||||
somebody actually wants suggestions for -- not yet reinforced, so donor
|
||||
eligibility left them out of the file altogether and the target had to be
|
||||
measured through a live document on every press. With them in the file the
|
||||
whole chain runs off the snapshot and `tools/reinforce_offline.py` can
|
||||
answer with no Revit at all.
|
||||
|
||||
IT IS THE SCOPE THAT BOUNDS THIS, NOT A PREFIX IN THE CODE. The scope is
|
||||
read from the job knowledge (`scope.mark_prefixes`), which is where this
|
||||
project already keeps the answer to "whose pieces are these". An
|
||||
UNDECLARED scope includes everything, by `vibe.scope`'s standing rule that
|
||||
no declaration means judge everything -- which here costs measuring the
|
||||
whole model rather than the donors, roughly one extra second per hundred
|
||||
assemblies, and produces a `features=None` record for anything with no
|
||||
structural-framing member to measure. Pass `targets=False` for the
|
||||
donors-only index schema 4 built.
|
||||
"""
|
||||
started = time.time()
|
||||
path = index_path(doc)
|
||||
cache = core.load(path)
|
||||
if cache.get('doc_key') not in (None, doc_key(doc)):
|
||||
cache = core.new_cache(doc_key(doc))
|
||||
cache['doc_key'] = doc_key(doc)
|
||||
|
||||
the_scope = scope_of(know)
|
||||
# Stamped into the file so the offline reader, which cannot load a
|
||||
# knowledge layer without a model, gets the same scope the button had.
|
||||
cache['scope'] = {'prefixes': list(the_scope.prefixes),
|
||||
'note': the_scope.note,
|
||||
'declared_by': the_scope.declared_by}
|
||||
# And the nomenclature, for the same reason and to the same reader. A
|
||||
# donor must be the SAME DESIGN as its target (owner ruling, 2026-09-01),
|
||||
# and reducing two design numbers to one family is done with the plant's
|
||||
# `nomenclature.design_number_re`. The offline reader has no knowledge
|
||||
# layer to get that from, so it is stamped here alongside the scope.
|
||||
# Additive: a file written before this key falls back exactly as before.
|
||||
from vibe import knowledge as K
|
||||
cache['nomenclature'] = {
|
||||
'design_number_re': K.get(know, 'nomenclature.design_number_re', None)
|
||||
if know else None}
|
||||
|
||||
elements = assemblies(doc)
|
||||
by_name = dict(elements)
|
||||
t_sig = time.time()
|
||||
live = live_signatures(doc, elements, know=know)
|
||||
seconds_signatures = time.time() - t_sig
|
||||
|
||||
if rebuild_all:
|
||||
cache['pieces'] = {}
|
||||
the_plan = core.plan(cache, live,
|
||||
include=the_scope.includes if targets else None)
|
||||
|
||||
todo = the_plan['build']
|
||||
if limit is not None:
|
||||
todo = todo[:limit]
|
||||
|
||||
records = []
|
||||
t_build = time.time()
|
||||
ran_out = False
|
||||
for name in todo:
|
||||
if budget_s is not None and (time.time() - t_build) > budget_s:
|
||||
ran_out = True
|
||||
break
|
||||
asm = by_name.get(name)
|
||||
if asm is None:
|
||||
continue
|
||||
sig = live[name][0]
|
||||
records.append(extract(doc, name, asm, sig, know=know))
|
||||
if on_piece is not None:
|
||||
on_piece(name)
|
||||
seconds_build = time.time() - t_build
|
||||
|
||||
# Rewriting an unchanged cache costs a full serialise for nothing, and the
|
||||
# commonest press of all is the one where nothing moved. Measured: writing
|
||||
# the 690-piece file back is the single largest slice of a no-change run.
|
||||
changed = bool(records) or bool(the_plan['drop'])
|
||||
cache = core.apply_plan(cache, the_plan, records,
|
||||
built_at=_stamp() if changed else None)
|
||||
|
||||
# MAINTAIN THE FEATURE VOCABULARY on every refresh (schema 6). The registry
|
||||
# lives on the cache and is NEVER reset by `rebuild_all` -- only
|
||||
# `cache['pieces']` is cleared above -- so `get_or_add` keeps every existing
|
||||
# id stable and only assigns ids for shapes genuinely seen for the first
|
||||
# time. Registering the freshly-built records is sufficient: an unchanged
|
||||
# (fresh) piece's descriptors were registered when it was first built and
|
||||
# its ids never move. `_pack`/`_unpack` carry the registry to disk, so the
|
||||
# offline reader gets the same vocabulary this build produced.
|
||||
if records:
|
||||
core.register_features(cache, records)
|
||||
|
||||
if changed:
|
||||
core.save(path, cache)
|
||||
|
||||
report = {
|
||||
'path': path,
|
||||
'assemblies': len(elements),
|
||||
'scope': the_scope.label(),
|
||||
'targets_wanted': len(the_plan.get('targets') or ()),
|
||||
'seconds_signatures': round(seconds_signatures, 3),
|
||||
'seconds_build': round(seconds_build, 3),
|
||||
'seconds_total': round(time.time() - started, 3),
|
||||
'built': len(records),
|
||||
'reused': len(the_plan['fresh']),
|
||||
'dropped': len(the_plan['drop']),
|
||||
'stopped_early': ran_out or (limit is not None
|
||||
and len(the_plan['build']) > len(todo)),
|
||||
'coverage': core.coverage(cache, the_plan),
|
||||
}
|
||||
return cache, report
|
||||
|
||||
|
||||
def _stamp():
|
||||
return time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def read(doc):
|
||||
"""The index as it stands, without rebuilding anything. READ ONLY.
|
||||
|
||||
For a caller that only wants to search: `refresh` first if you mean to be
|
||||
current, `read` if you mean to be fast. Neither touches the model.
|
||||
"""
|
||||
return core.load(index_path(doc))
|
||||
@@ -0,0 +1,805 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Owner, verbatim (2026-08-30): "我想 reinforce 一个 piece,然后 AI 帮我找最
|
||||
# 合适的几个母版 ... 这几个母版拼吧拼吧能把这个 panel 给拼出来 ... 每一个细节
|
||||
# 都要照顾到,但是尽量选少的母版,覆盖多的细节。"
|
||||
# Owner, verbatim (2026-08-30), on the measurement unit: "实际测量全部用英寸,
|
||||
# 带两位小数。"
|
||||
# Owner, verbatim (2026-08-30), on exactness ranking, not classifying:
|
||||
# "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排在后
|
||||
# 面。"
|
||||
# Owner, verbatim (2026-08-30), on the donor cap: "一个方案,只要找到足够覆盖
|
||||
# 全部特征的母版,就够了。我期待是 1-4 个,最多不超过 4 个。"
|
||||
"""The reinforcing-suggestion matcher: a set-cover solver over opaque features.
|
||||
|
||||
WHAT THIS ANSWERS
|
||||
-----------------
|
||||
Will, 2026-08-30: "I want to reinforce a piece, and have AI find me the
|
||||
best-fitting master(s) ... these masters, pieced together, can reconstruct
|
||||
this panel ... every detail has to be accounted for, but pick as few
|
||||
masters as possible, covering the most detail."
|
||||
|
||||
That is not a similarity ranking (the 08-26 attempt built one of those, and it
|
||||
answers a different question). It is SET COVER: every feature of the target
|
||||
panel must be accounted for by some donor, and the number of donors must be as
|
||||
small as possible. Donors may overlap; overlap is expected and is not a
|
||||
conflict (ruling, 2026-08-30). Coverage is counted BY FEATURE, not by area --
|
||||
the target's small end features appear identically in both candidate donors of
|
||||
the worked example, so an area weighting would let two big openings drown them
|
||||
out, while counting features keeps each one a thing that must be covered.
|
||||
|
||||
THE SEAM, AND WHY IT IS HERE ON PURPOSE
|
||||
---------------------------------------
|
||||
This module never reads a field off a feature record. Not one. Every
|
||||
comparison of two features happens inside a single injected callable, `covers`,
|
||||
whose contract is stated below. Everything else in this file -- coverage, set
|
||||
selection, ranking, tie-breaking -- works on opaque records and on the numbers
|
||||
that callable returns.
|
||||
|
||||
That boundary is not a scheduling convenience, and it should survive after the
|
||||
index lands. Comparing two precast features is the one part of this problem
|
||||
that needs engineering judgement about geometry: what makes a blockout "the
|
||||
same" blockout, whether a notch at the same station but a different Z band is
|
||||
the same feature, how a through opening compares to a blind one. That judgement
|
||||
is exactly the thing this project has been burned by before -- the shim check
|
||||
that measured the wrong axis, the clash check that reported nothing because a
|
||||
cylinder's edge curves close on themselves. Isolating it in one small callable
|
||||
means it can be argued about, measured, and replaced without touching a line of
|
||||
the selection logic, and means the selection logic can be tested to death on
|
||||
synthetic data with no model, no licence and no Revit.
|
||||
|
||||
So: do not add a fallback here that peeks at a field "just in case". If the
|
||||
matcher needs something `covers` cannot give it, that is a finding to report,
|
||||
not a thing to work around.
|
||||
|
||||
THE `covers` CONTRACT
|
||||
---------------------
|
||||
covers(donor_feature, target_feature) -> None or float
|
||||
|
||||
None this donor feature does not cover that target feature at all.
|
||||
0.0 exact match.
|
||||
> 0.0 covers it; the number is how far from exact, smaller being closer.
|
||||
|
||||
Requirements on any implementation:
|
||||
|
||||
1. TOTAL. It must accept any pair of records the caller passes and must not
|
||||
raise. A pair it cannot judge is `None`, not an exception.
|
||||
2. PURE AND DETERMINISTIC. Same two records, same answer, every time, with no
|
||||
dependence on call order or on any global. The matcher's determinism rests
|
||||
entirely on this.
|
||||
3. DIRECTIONAL. `covers(a, b)` need not equal `covers(b, a)`. A donor feature
|
||||
may stand in for a target feature without the reverse being true.
|
||||
4. COMMENSURABLE, IN INCHES. The returned distances are compared and sorted
|
||||
ACROSS DIFFERENT FEATURE PAIRS and across different feature kinds. A
|
||||
distance of 0.4 on a notch must mean the same amount of "off" as 0.4 on a
|
||||
blockout, or the ranking is comparing apples to oranges. ONE UNIT FOR ALL
|
||||
KINDS, and that unit is INCHES (owner's ruling, 2026-08-30:
|
||||
"all actual measurements use inches, to two decimal places"). The owner answered a UNIT question, not a
|
||||
WEIGHTING one -- there is no per-kind weight here and none is invented; an
|
||||
inch off on a blockout is an inch off on a notch.
|
||||
|
||||
THE STORED GEOMETRY IS STILL FEET and must stay that way: feet is Revit's
|
||||
internal unit and `reinforcecore` caches feet. The production `covers`
|
||||
(`reinforcebind.covers`) is the single place the crossing happens, via
|
||||
`reinforcecore.inches()`. Nothing in THIS module converts anything; it
|
||||
sorts numbers and does not know what they measure.
|
||||
|
||||
PRECISION IS NOT THE SAME AS DISPLAY. Two-decimal inches is 0.01", an
|
||||
order of magnitude finer than any tolerance this tool uses (dimensions
|
||||
3/8", interference 1/8"). The production `covers` therefore returns
|
||||
distances QUANTIZED TO 1/8 in so the ranking cannot be steered by
|
||||
differences the measurement cannot support. This module imposes nothing:
|
||||
it will rank whatever grain it is handed. See `reinforcebind.GRAIN_IN`.
|
||||
5. ABSOLUTE, NOT PROPORTIONAL. Positions compare as absolute distance from the
|
||||
start of the panel, never as a fraction of panel length. Measured: the two
|
||||
big openings of the worked example land at the identical feet-from-start
|
||||
rectangle on panels of two different lengths, and a proportional comparison
|
||||
would have missed the match entirely. Nothing in this module normalises by
|
||||
length, and nothing in `covers` should either.
|
||||
6. TWO-DIMENSIONAL. The same measurement showed two donor features sharing an
|
||||
x-range while sitting in different Z bands. A single-axis comparison calls
|
||||
those the same feature. They are not.
|
||||
7. CHEAP. It is called exactly once per (donor, donor feature, target
|
||||
feature) triple -- the matcher builds the whole comparison table up front
|
||||
and never calls it again -- but that is still on the order of a few
|
||||
thousand calls per run.
|
||||
|
||||
Note what the contract does NOT contain: a similarity threshold. There is none,
|
||||
anywhere in this module, by ruling -- "if there is a completely identical
|
||||
shape, that ranks at the front of the recommendation; if it is only similar,
|
||||
it goes further back." Exactness RANKS, it does not CLASSIFY. The
|
||||
matcher never asks "are these two the same?", only "which of these is closer?",
|
||||
which is arithmetic rather than an unfalsifiable judgement call. The one
|
||||
classification that does exist -- covers-at-all vs. does-not -- lives inside
|
||||
`covers`, where the geometry knowledge is, and it is the only place it may live.
|
||||
|
||||
WHAT THE CALLER PASSES
|
||||
----------------------
|
||||
suggest(target_features, donors, covers, ...)
|
||||
|
||||
`target_features` is a sequence of opaque feature records. `donors` is a
|
||||
sequence of `(key, feature_list)` pairs -- the key is any hashable value that
|
||||
identifies the donor to the caller (an element id, a mark, a tuple); this
|
||||
module treats it as a label and nothing else. Feature records are whatever the
|
||||
index produces; this module only ever hands them to `covers`.
|
||||
|
||||
SAME ELEVATION, RULED 2026-09-02 -- superseding the note this module used to
|
||||
carry about donor order being the lever for this. WP425 ranked five donors in
|
||||
a dead tie on every criterion below, and WP234 -- which shares WP425's own
|
||||
building elevation -- printed last, by mark order, because nothing here knew
|
||||
elevation existed: owner, "WP234明明在同一个elevation,为什么排名没有提前?"
|
||||
Approved as briefed the same day ("同意,就这么做"): elevation is an AFFINITY,
|
||||
the LAST tie-break -- after fewest donors, most exact, most reviewed and
|
||||
total distance, before mark order -- never a filter, exactly the shape
|
||||
Will's standing correction on donor order already described (a cross-design
|
||||
or cross-fit exclusion is not what this is; a better-shaped cross-elevation
|
||||
donor still wins). It is now an explicit ranking term, `same_elevation`,
|
||||
rather than a reordering of the input list: see `Proposal.same_elevation`
|
||||
and the ranking notes below. Donor input order is still the true last
|
||||
resort -- it decides a tie elevation cannot, same as it always has.
|
||||
|
||||
WHY A TERM AND NOT THE ORDERING TRICK THIS MODULE USED TO SUGGEST. Sorting
|
||||
same-elevation donors to the front of the input list would have worked for
|
||||
the reported case (a single donor covers everything, so the tie-break really
|
||||
is just "which donor is listed first"), but it hides the reason inside sort
|
||||
position -- a report that wants to say WHY WP234 won would have to
|
||||
recompute the same ordering itself. An explicit term, exactly like `masters`
|
||||
below, is a fact the caller can read straight off the winning `Proposal` and
|
||||
print.
|
||||
|
||||
AT MOST FOUR DONORS IN A PROPOSAL
|
||||
---------------------------------
|
||||
Owner's ruling, 2026-08-30: "for one proposal, it is enough to find masters
|
||||
that cover all the features between them. I'd expect 1-4, never more than
|
||||
4." `DEFAULT_MAX_DONORS = 4`, and it is a HARD
|
||||
cap on the donors WITHIN one proposal. Do not confuse it with `max_proposals`,
|
||||
which is a different axis entirely: `max_proposals` is how many alternative
|
||||
answers get printed, `max_donors` is how big any one answer may be.
|
||||
|
||||
WHAT HAPPENS WHEN FOUR CANNOT COVER EVERYTHING -- INFERRED, NOT STATED. The
|
||||
owner ruled the cap; he did not rule the overflow. The reading implemented
|
||||
here is that the cap is hard: the search returns the best partial cover of
|
||||
four donors and NAMES what it could not reach, rather than relaxing to a
|
||||
fifth. That is already this module's established behaviour for the other
|
||||
reason a feature goes uncovered (nobody has it at all), it is the answer the
|
||||
first live run gave, and a suggestion that quietly grew to six donors would
|
||||
not be the thing that was asked for. The two causes are reported separately
|
||||
and never merged -- see `CAPPED` below.
|
||||
|
||||
Everything here is IronPython 2.7 compatible: no f-strings, no pathlib, no
|
||||
modern typing, no Python 3 `functools`.
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Outcome vocabulary. "Could not judge" and "judged and found nothing" are
|
||||
# different answers and must read differently -- the standing convention on
|
||||
# this project, learned from a check that reported a clean result when it had
|
||||
# in fact measured nothing.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
NO_DONORS = 'no_donors' # nothing eligible was offered; nothing was judged
|
||||
NO_COVERAGE = 'no_coverage' # donors were judged; not one covers any feature
|
||||
PARTIAL = 'partial' # some feature no donor covers, in any combination
|
||||
CAPPED = 'capped' # every feature IS coverable; four donors cannot reach them all
|
||||
COMPLETE = 'complete' # every target feature is covered
|
||||
|
||||
# PARTIAL and CAPPED are different findings and must never be collapsed.
|
||||
# PARTIAL means nobody in the pool has that feature -- the engineer details it
|
||||
# from scratch. CAPPED means somebody does have it, but not within the four
|
||||
# donors a proposal is allowed; the engineer can go and find that donor
|
||||
# himself. When both are true the status is PARTIAL (the harder fact) and
|
||||
# `Suggestion.capped_out` still names the second set.
|
||||
|
||||
# `suggest` returns None -- not an empty result -- when the input cannot be
|
||||
# judged at all (no target features). An empty `proposals` with a status is
|
||||
# always the result of an actual judgement.
|
||||
|
||||
DEFAULT_MAX_PROPOSALS = 5
|
||||
|
||||
# How many donors ONE proposal may contain. The owner's number. This is not
|
||||
# `max_proposals` -- see the module docstring; conflating the two axes gives
|
||||
# you either one giant answer or four tiny ones.
|
||||
DEFAULT_MAX_DONORS = 4
|
||||
|
||||
|
||||
class Proposal(object):
|
||||
"""One set of donors, and what each target feature got from it."""
|
||||
|
||||
def __init__(self, donor_indexes, donor_keys, assignment, uncovered,
|
||||
master_keys=(), same_elevation_keys=()):
|
||||
self.donor_indexes = tuple(donor_indexes)
|
||||
self.donors = tuple(donor_keys)
|
||||
# Which of THIS proposal's donors are reviewed templates, in the same
|
||||
# order as `donors`. A label and a tie-break, never a filter.
|
||||
self.masters = tuple(k for k in self.donors if k in set(master_keys))
|
||||
self.master_count = len(self.masters)
|
||||
# Which of THIS proposal's donors share the target's building
|
||||
# elevation, same shape as `masters` and ranked directly below it --
|
||||
# see the module docstring's 2026-09-02 note and the ranking
|
||||
# comment above `_proposal_sort_key`. A label and a tie-break,
|
||||
# never a filter: a donor whose elevation is unknown, or differs, is
|
||||
# simply absent from `same_elevation_keys` and this stays empty.
|
||||
self.same_elevation = tuple(
|
||||
k for k in self.donors if k in set(same_elevation_keys))
|
||||
self.elevation_count = len(self.same_elevation)
|
||||
# assignment[i] is None (feature i uncovered by this proposal) or a
|
||||
# 3-tuple (donor_index, donor_feature_index, distance).
|
||||
self.assignment = tuple(assignment)
|
||||
self.uncovered = tuple(uncovered)
|
||||
|
||||
dists = [a[2] for a in self.assignment if a is not None]
|
||||
# Ascending: the ranking key. See `_proposal_sort_key`.
|
||||
self.distances = tuple(sorted(dists))
|
||||
self.exact_count = len([d for d in self.distances if d == 0.0])
|
||||
self.covered_count = len(self.distances)
|
||||
self.total_distance = sum(self.distances) if self.distances else 0.0
|
||||
self.is_exact = (self.covered_count > 0 and
|
||||
self.exact_count == self.covered_count)
|
||||
|
||||
def donor_for(self, feature_index):
|
||||
"""(donor_key, donor_feature_index, distance) for one target feature.
|
||||
|
||||
None when this proposal does not cover that feature.
|
||||
"""
|
||||
got = self.assignment[feature_index]
|
||||
if got is None:
|
||||
return None
|
||||
return (self.donors[self.donor_indexes.index(got[0])], got[1], got[2])
|
||||
|
||||
def is_master(self, donor_key):
|
||||
return donor_key in self.masters
|
||||
|
||||
def __repr__(self):
|
||||
return ('<Proposal {0} exact={1}/{2} master={3} elevation={4} '
|
||||
'total={5:.4f}>').format(
|
||||
list(self.donors), self.exact_count, self.covered_count,
|
||||
self.master_count, self.elevation_count, self.total_distance)
|
||||
|
||||
|
||||
class Suggestion(object):
|
||||
"""The whole answer: ranked proposals plus what could not be covered."""
|
||||
|
||||
def __init__(self, status, proposals, uncovered, donor_count,
|
||||
target_feature_count, donors_needed, cover_count,
|
||||
donor_cap=None, capped_out=()):
|
||||
self.status = status
|
||||
self.proposals = tuple(proposals)
|
||||
# Target feature indexes that NO donor covers, in any combination.
|
||||
self.uncovered = tuple(uncovered)
|
||||
# The donor cap this answer was found under, and the features that
|
||||
# SOME donor covers but the best proposal could not reach inside it.
|
||||
# Disjoint from `uncovered` by construction: a feature nobody has is
|
||||
# not a feature the cap kept out.
|
||||
self.donor_cap = donor_cap
|
||||
self.capped_out = tuple(capped_out)
|
||||
self.donor_count = donor_count
|
||||
self.target_feature_count = target_feature_count
|
||||
# Size of the smallest cover FOUND. Not proven minimal -- the search
|
||||
# is greedy; see the note above `_greedy_covers`. There is no
|
||||
# `minimal` flag because the answer is always the same: no.
|
||||
self.donors_needed = donors_needed
|
||||
self.cover_count = cover_count # how many covers of that size exist
|
||||
|
||||
@property
|
||||
def best(self):
|
||||
return self.proposals[0] if self.proposals else None
|
||||
|
||||
def summary(self):
|
||||
"""One sentence a button can print without knowing the vocabulary."""
|
||||
n = self.target_feature_count
|
||||
if self.status == NO_DONORS:
|
||||
return ('No eligible donors were offered, so nothing was judged. '
|
||||
'{0} target feature(s) unexamined.'.format(n))
|
||||
if self.status == NO_COVERAGE:
|
||||
return ('Judged {0} donor(s): not one of them covers any of the '
|
||||
'{1} target feature(s).'.format(self.donor_count, n))
|
||||
k = self.donors_needed
|
||||
if self.status == CAPPED:
|
||||
return ('At most {0} donor(s) per proposal; {1} of them cover {2} '
|
||||
'of {3} target feature(s). {4} feature(s) are covered by '
|
||||
'some donor but not by any {0}-donor set: {5}.'.format(
|
||||
self.donor_cap, k, n - len(self.capped_out), n,
|
||||
len(self.capped_out), list(self.capped_out)))
|
||||
if self.status == COMPLETE:
|
||||
return ('{0} donor(s) cover all {1} target feature(s); {2} such '
|
||||
'cover(s) found, {3} shown.'.format(
|
||||
k, n, self.cover_count, len(self.proposals)))
|
||||
tail = ''
|
||||
if self.capped_out:
|
||||
tail = (' A further {0} feature(s) are covered by some donor but '
|
||||
'not within the {1}-donor cap: {2}.'.format(
|
||||
len(self.capped_out), self.donor_cap,
|
||||
list(self.capped_out)))
|
||||
return ('{0} donor(s) cover {1} of {2} target feature(s); {3} '
|
||||
'feature(s) no donor covers at all: {4}.{5}'.format(
|
||||
k, n - len(self.uncovered) - len(self.capped_out), n,
|
||||
len(self.uncovered), list(self.uncovered), tail))
|
||||
|
||||
def __repr__(self):
|
||||
return '<Suggestion {0} donors={1} features={2} proposals={3}>'.format(
|
||||
self.status, self.donor_count, self.target_feature_count,
|
||||
len(self.proposals))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The coverage table. This is the only place `covers` is called.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _coverage(target_features, donors, covers):
|
||||
"""Build, for every donor, the best it can do on every target feature.
|
||||
|
||||
Returns (best, masks) where
|
||||
|
||||
best[d][i] = None, or (donor_feature_index, distance)
|
||||
masks[d] = bitmask over target feature indexes donor d covers
|
||||
|
||||
A donor may carry several features that cover one target feature (two
|
||||
notches near one blockout, say). The best one wins: lowest distance, and
|
||||
among equal distances the lowest donor-feature index, so the choice does
|
||||
not depend on iteration luck.
|
||||
|
||||
An exception raised by `covers` is NOT caught. A comparison that blows up
|
||||
is a bug in the comparison, and swallowing it here would turn it into a
|
||||
quiet "this donor covers nothing" -- a confident wrong answer, which is
|
||||
the exact failure mode this project exists to prevent. A contract-breaking
|
||||
return value (negative, NaN, not a number) is raised on, naming the pair,
|
||||
for the same reason.
|
||||
"""
|
||||
best = []
|
||||
masks = []
|
||||
for d_index in range(len(donors)):
|
||||
donor_features = donors[d_index][1] or []
|
||||
row = [None] * len(target_features)
|
||||
mask = 0
|
||||
for f_index in range(len(donor_features)):
|
||||
df = donor_features[f_index]
|
||||
for t_index in range(len(target_features)):
|
||||
got = covers(df, target_features[t_index])
|
||||
if got is None:
|
||||
continue
|
||||
dist = _checked_distance(got, d_index, f_index, t_index)
|
||||
current = row[t_index]
|
||||
if current is None or dist < current[1]:
|
||||
row[t_index] = (f_index, dist)
|
||||
mask |= (1 << t_index)
|
||||
best.append(row)
|
||||
masks.append(mask)
|
||||
return best, masks
|
||||
|
||||
|
||||
def _checked_distance(value, d_index, f_index, t_index):
|
||||
try:
|
||||
dist = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
'covers() returned a non-number {0!r} for donor {1} feature {2} '
|
||||
'vs target feature {3}; the contract is None or a float >= 0'
|
||||
.format(value, d_index, f_index, t_index))
|
||||
if dist != dist: # NaN, which compares false with itself
|
||||
raise ValueError(
|
||||
'covers() returned NaN for donor {0} feature {1} vs target '
|
||||
'feature {2}'.format(d_index, f_index, t_index))
|
||||
if dist < 0.0:
|
||||
raise ValueError(
|
||||
'covers() returned a negative distance {0!r} for donor {1} '
|
||||
'feature {2} vs target feature {3}; 0.0 is exact and there is '
|
||||
'nothing closer than exact'.format(value, d_index, f_index,
|
||||
t_index))
|
||||
return dist
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Set cover, by greedy with restarts.
|
||||
#
|
||||
# WHY GREEDY, AND WHERE IT IS WRONG.
|
||||
#
|
||||
# Set cover is NP-hard, so this does not solve it; it approximates it, and the
|
||||
# approximation is chosen deliberately over an exhaustive search. A panel
|
||||
# carries on the order of five to ten features, an exhaustive search over
|
||||
# subsets of that universe would also be affordable, and the honest reason to
|
||||
# take greedy anyway is that it is twenty lines instead of a hundred, has no
|
||||
# recursion, no node budget and no depth cap to get wrong, and can be read in
|
||||
# one sitting by whoever inherits it. If evidence ever turns up that it is
|
||||
# picking badly on real panels, replacing it is a local change: everything
|
||||
# else in this module works off the donor sets it returns.
|
||||
#
|
||||
# WHERE IT CAN RETURN A WORSE ANSWER THAN OPTIMAL. Greedy takes the donor that
|
||||
# covers the most still-uncovered features, and that first bite can be the
|
||||
# wrong one. Nine features 1..9, and donors
|
||||
# D = {3, 6, 9} A = {1, 2, 3} B = {4, 5, 6} C = {7, 8, 9}
|
||||
# Plain greedy takes D first (it is tied for the biggest bite and happens to
|
||||
# be listed first), and then still needs all three of A, B and C to finish:
|
||||
# four donors where {A, B, C} covers everything with three. In general greedy
|
||||
# can return a factor of ln(n) more donors than the minimum, and a suggestion
|
||||
# that says "you need four donors" when three would do is exactly the kind of
|
||||
# wrong that gets a tool stopped being used.
|
||||
#
|
||||
# Restarting from every possible first donor (below) removes that whole family
|
||||
# of failures -- the walk seeded from A finds {A, B, C} -- and at five-to-ten
|
||||
# features it is hard to construct a case that beats it. It is still not a
|
||||
# proof, there is no guarantee here, and nothing in the result claims one. If
|
||||
# a real panel ever shows this picking badly, swap `_greedy_covers` for an
|
||||
# exhaustive search over subsets: the universe is the target's features, which
|
||||
# is small enough that a complete search is affordable, and everything else in
|
||||
# this module works off the donor sets that function returns.
|
||||
#
|
||||
# THE RESTARTS. Plain greedy gives one answer, and one answer is not a
|
||||
# suggestion -- the engineer needs to see the second and third options and be
|
||||
# able to disagree with the ranking. So greedy is run once per donor, each run
|
||||
# forced to start from that donor and then filling greedily; the resulting
|
||||
# covers are deduplicated, the smallest size wins (fewest donors is the
|
||||
# objective), and exactness ranks what is left. It costs D passes over D
|
||||
# donors, which at these sizes is nothing.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _popcount(mask):
|
||||
n = 0
|
||||
while mask:
|
||||
mask &= mask - 1
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _greedy_fill(masks, full, chosen, covered, max_donors=None):
|
||||
"""Extend `chosen` greedily until nothing more can be added.
|
||||
|
||||
Takes the donor adding the most still-uncovered features; ties go to the
|
||||
lowest donor index, so the walk is fixed and the whole module stays
|
||||
reproducible run to run.
|
||||
|
||||
`max_donors` stops the walk once the proposal is that big, even if
|
||||
features remain. That is the owner's cap, and it is enforced HERE rather
|
||||
than by trimming a finished cover -- trimming would drop whichever donor
|
||||
happened to be last, while stopping early means every donor in the four
|
||||
was chosen for being the biggest remaining bite.
|
||||
"""
|
||||
chosen = list(chosen)
|
||||
while covered != full:
|
||||
if max_donors is not None and len(chosen) >= max_donors:
|
||||
break
|
||||
pick = None
|
||||
gain = 0
|
||||
for d in range(len(masks)):
|
||||
if d in chosen:
|
||||
continue
|
||||
added = _popcount(masks[d] & ~covered)
|
||||
if added > gain:
|
||||
pick, gain = d, added
|
||||
if pick is None:
|
||||
break
|
||||
chosen.append(pick)
|
||||
covered |= masks[pick]
|
||||
return tuple(sorted(chosen)), covered
|
||||
|
||||
|
||||
def _greedy_covers(masks, full, max_donors=None):
|
||||
"""Candidate covers: greedy restarted from every donor that covers anything.
|
||||
|
||||
Returns (k, covers, reached) -- the smallest cover size found among the
|
||||
covers that get FURTHEST, every distinct cover of that size, and the union
|
||||
of what they reach. `k` is None and `covers` empty when no donor covers
|
||||
anything.
|
||||
|
||||
REACH BEFORE SIZE, and only because of the cap. Without a cap every greedy
|
||||
walk ends at `full` (it stops only when no donor adds anything, and every
|
||||
feature in `full` has some donor), so all candidates reach equally far and
|
||||
this ordering is the old "fewest donors wins" unchanged. With a cap the
|
||||
walks can end short, and then a two-donor set covering four features must
|
||||
not beat a four-donor set covering eighteen: covering the target is the
|
||||
objective, fewest donors is the tie-break under it.
|
||||
"""
|
||||
if full == 0:
|
||||
return None, [], 0
|
||||
|
||||
seen = {}
|
||||
for d in range(len(masks)):
|
||||
if not masks[d]:
|
||||
continue
|
||||
cover, covered = _greedy_fill(masks, full, (d,), masks[d], max_donors)
|
||||
seen[cover] = covered
|
||||
|
||||
if not seen:
|
||||
return None, [], 0
|
||||
|
||||
reach = max([_popcount(m) for m in seen.values()])
|
||||
best = [c for c in seen if _popcount(seen[c]) == reach]
|
||||
k = min([len(c) for c in best])
|
||||
keep = sorted([c for c in best if len(c) == k])
|
||||
reached = 0
|
||||
for c in keep:
|
||||
reached |= seen[c]
|
||||
return k, keep, reached
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ranking.
|
||||
#
|
||||
# Fewest donors is the objective and it is not negotiable; exactness only
|
||||
# breaks ties between covers of equal size. Within one size, the question is
|
||||
# how per-feature distances combine into something comparable.
|
||||
#
|
||||
# NOT BY SUM. Summing loses precisely the information the owner's ruling is
|
||||
# about. A cover that is exact on four features and 2.0 off on the fifth, and
|
||||
# a cover that is 0.4 off on all five, have the same sum of 2.0 -- so a sum
|
||||
# calls them equal, when the owner said plainly that the exact shapes go in
|
||||
# front: "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排
|
||||
# 在后面。"
|
||||
#
|
||||
# THE KEY IS THE SORTED DISTANCE VECTOR, ASCENDING, COMPARED LEXICOGRAPHICALLY.
|
||||
# Sort each cover's per-feature distances smallest-first and compare position
|
||||
# by position. Since exact matches are 0.0 and sort to the front, this makes
|
||||
# "has more exact features" the first thing that decides, then "its next-best
|
||||
# feature is closer", and so on down. It is the owner's rule written as
|
||||
# arithmetic, and it needs no weights, no normalisation and no threshold.
|
||||
#
|
||||
# WHAT THAT COSTS, STATED PLAINLY. It is a strict preference for exactness
|
||||
# over evenness, and it will sometimes look odd. Cover A = [0.0, 5.0] beats
|
||||
# cover B = [0.01, 0.02], because A is exact on one feature and B is exact on
|
||||
# none, even though B is obviously the better fit overall. That is the chosen
|
||||
# behaviour, not an oversight: it is what "完全一样的形状排在前面" means, and
|
||||
# the alternative (rank by the worst feature, i.e. compare the vectors
|
||||
# descending) is the "mediocre everywhere wins" rule the brief warns against.
|
||||
# `Proposal.total_distance` carries the sum anyway, so a UI can show the
|
||||
# overall figure next to the ranking without it steering the ranking.
|
||||
#
|
||||
# THE MASTER TIE-BREAK, AND WHERE IT SITS.
|
||||
#
|
||||
# Some donors are marked MASTER by hand in the model, and the owner's ruling
|
||||
# (2026-08-30) is that those have been REVIEWED: "某一些模板带有 MASTER,给这些
|
||||
# MASTER 当母版的优先级提升到最高。他们是被审核过的。" It is the only provenance
|
||||
# signal this feature has -- `TICKET_IS_REINFORCED` says somebody drew bars,
|
||||
# not that anybody checked them.
|
||||
#
|
||||
# Asked directly how it interacts with exactness -- a loosely-fitting MASTER
|
||||
# against a perfectly exact non-MASTER -- the owner ruled: 拟合更好的排在前面.
|
||||
# FIT WINS. So MASTER sits BELOW the distance vector and ABOVE input order.
|
||||
# It cannot reorder anything the distance vector has already separated; it
|
||||
# decides only between covers the geometry has declared indistinguishable,
|
||||
# and that is exactly where "who checked this?" is the right question.
|
||||
#
|
||||
# Counted, not flagged: a cover with two masters ranks above a cover with one,
|
||||
# because more of it has been reviewed. Negated in the key so that MORE sorts
|
||||
# FIRST while everything else still sorts ascending.
|
||||
#
|
||||
# THE SAME-ELEVATION TIE-BREAK, AND WHERE IT SITS.
|
||||
#
|
||||
# Owner, 2026-09-02, on WP425: five donors tied on every criterion above --
|
||||
# 1 donor, 5/5 covered, 4 exact, 0 reviewed, 0.04 in total distance, all
|
||||
# five identical -- and the tie fell through to mark order, so WP133
|
||||
# printed first though WP234 shares WP425's own building elevation:
|
||||
# "WP234明明在同一个elevation,为什么排名没有提前?" Ruled the same day,
|
||||
# approved as briefed ("同意,就这么做"): elevation is an AFFINITY, and it
|
||||
# sits LAST, below MASTER -- after fewest donors, most exact, most reviewed
|
||||
# and total distance, and above only the donors' input order. It is ranked
|
||||
# below MASTER on the same reasoning MASTER is ranked below the distance
|
||||
# vector: a fact about WHERE a donor sits does not say whether it is a good
|
||||
# template, and a piece somebody has actually reviewed is worth more than
|
||||
# one that merely happens to be on the right facade. Nothing here reorders
|
||||
# what MASTER or the distance vector have already separated -- it only
|
||||
# decides among covers every criterion above it left dead level, exactly
|
||||
# the case WP425 hit.
|
||||
#
|
||||
# Counted the same way `master_count` is, for the same reason: a cover with
|
||||
# two same-elevation donors ranks above a cover with one. Negated in the key
|
||||
# so MORE sorts FIRST.
|
||||
#
|
||||
# UNKNOWN IS NEVER A MATCH. `same_elevation_keys` (built by
|
||||
# `reinforcebind.propose`) never contains a donor whose own elevation could
|
||||
# not be read, and it is empty outright when the TARGET's elevation could
|
||||
# not be read -- an unread elevation is could-not-judge, not a wildcard that
|
||||
# matches everything. See `reinforcecore.elevation_key`.
|
||||
#
|
||||
# LAST TIE-BREAK: the donors' input order. Two covers with identical distance
|
||||
# vectors, master counts and elevation counts are, on the evidence available
|
||||
# here, equally good, and the matcher has nothing further to say. Falling
|
||||
# back to input order makes the answer reproducible -- an engineer who runs
|
||||
# it twice and gets two different answers will not trust it a third time --
|
||||
# and it is now genuinely the LAST word: mark order, nothing more, no
|
||||
# remaining lever a caller is expected to pull by reordering donors.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _proposal_sort_key(proposal):
|
||||
# `-covered_count` first, and it is a NO-OP everywhere except under the
|
||||
# donor cap: uncapped, every candidate cover reaches the same feature set,
|
||||
# so this term is constant and the ranking is exactly what it always was
|
||||
# (fewest donors, then the distance vector, then MASTER, then same
|
||||
# elevation, then input order). Capped, it is the difference between
|
||||
# "eighteen of nineteen" and "four of nineteen, but with only two
|
||||
# donors".
|
||||
return (-proposal.covered_count,
|
||||
len(proposal.donor_indexes),
|
||||
proposal.distances,
|
||||
-proposal.master_count,
|
||||
-proposal.elevation_count,
|
||||
proposal.donor_indexes)
|
||||
|
||||
|
||||
def _build_proposal(donor_indexes, donors, best, target_count,
|
||||
master_keys=(), same_elevation_keys=()):
|
||||
assignment = []
|
||||
uncovered = []
|
||||
for t_index in range(target_count):
|
||||
pick = None
|
||||
for d_index in donor_indexes:
|
||||
got = best[d_index][t_index]
|
||||
if got is None:
|
||||
continue
|
||||
if pick is None or got[1] < pick[2]:
|
||||
pick = (d_index, got[0], got[1])
|
||||
if pick is None:
|
||||
uncovered.append(t_index)
|
||||
assignment.append(pick)
|
||||
keys = [donors[d][0] for d in donor_indexes]
|
||||
return Proposal(donor_indexes, keys, assignment, uncovered, master_keys,
|
||||
same_elevation_keys)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The entry point.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def suggest(target_features, donors, covers,
|
||||
max_proposals=DEFAULT_MAX_PROPOSALS, masters=None,
|
||||
max_donors=DEFAULT_MAX_DONORS, same_elevation=None):
|
||||
"""Rank the smallest sets of donors that between them cover the target.
|
||||
|
||||
target_features -- sequence of opaque feature records for the panel being
|
||||
reinforced. Order matters only as identity: every index
|
||||
in the result refers back into this sequence.
|
||||
donors -- sequence of (key, feature_list) pairs. `key` is opaque
|
||||
and is only ever used as a label and, at the very end,
|
||||
as the source of the input order that breaks a dead-even
|
||||
tie. See the module docstring on why that is a lever.
|
||||
covers -- the comparison callable. Contract in the module
|
||||
docstring. This is the only thing in this module that
|
||||
may read a feature's fields.
|
||||
masters -- the donor keys that are REVIEWED templates, as any
|
||||
iterable of keys. Purely a tie-break, ranked below the
|
||||
distance vector: see the ranking notes above. Keys not
|
||||
present among `donors` are ignored, and the default of
|
||||
none is a matcher that ranks exactly as it did before.
|
||||
same_elevation -- the donor keys that share the TARGET's building
|
||||
elevation, as any iterable of keys. Purely a
|
||||
tie-break, ranked below `masters` and above input
|
||||
order -- see the ranking notes above and
|
||||
`Proposal.same_elevation`. Keys not present among
|
||||
`donors` are ignored; the default of none ranks
|
||||
exactly as before this term existed.
|
||||
max_proposals -- how many ranked proposals to return. Five by default:
|
||||
enough that the engineer can see the second and third
|
||||
options and disagree with the ranking, few enough to
|
||||
read. `Suggestion.cover_count` reports how many there
|
||||
actually were.
|
||||
max_donors -- how many donors ONE proposal may contain. FOUR by the
|
||||
owner's ruling, and a different axis from
|
||||
`max_proposals`: this bounds the size of an answer, that
|
||||
bounds the number of answers. `None` removes the cap.
|
||||
|
||||
When four donors cannot reach every coverable feature,
|
||||
the search returns the best four and names what it could
|
||||
not reach in `Suggestion.capped_out`, with status
|
||||
`CAPPED`. It does NOT relax to a fifth. That overflow
|
||||
rule is inferred, not ruled -- see the module docstring.
|
||||
|
||||
Returns a `Suggestion`, or None when the input cannot be judged at all --
|
||||
that is, when there are no target features. No features means there is
|
||||
nothing to cover and no basis for any suggestion; that is "could not
|
||||
judge", and it must not be confused with the empty-but-real results this
|
||||
returns when donors were examined and found wanting. `Suggestion.status`
|
||||
separates those: NO_DONORS (nothing eligible was offered, so nothing was
|
||||
judged) from NO_COVERAGE (donors were examined; none covers anything).
|
||||
|
||||
PARTIAL COVERS ARE RETURNED, deliberately. When some feature is covered by
|
||||
no donor at all, "nothing found" and "here is a cover of nine of your ten
|
||||
features, and this is the one nobody has" are very different answers, and
|
||||
only the second is any use: it tells the engineer which detail they have to
|
||||
reinforce by hand, which is a much shorter job than the whole panel. So the
|
||||
search runs over the coverable features, the proposals cover all of those,
|
||||
and `Suggestion.uncovered` names the rest by index. `status` is PARTIAL so
|
||||
no caller can mistake it for a complete answer.
|
||||
"""
|
||||
if not callable(covers):
|
||||
raise ValueError('suggest() needs a covers(donor_feature, '
|
||||
'target_feature) callable; got {0!r}'.format(covers))
|
||||
|
||||
if not target_features:
|
||||
return None # could not judge -- see docstring
|
||||
|
||||
target_count = len(target_features)
|
||||
all_features = tuple(range(target_count))
|
||||
donors = list(donors or [])
|
||||
master_keys = frozenset(masters or ())
|
||||
same_elevation_keys = frozenset(same_elevation or ())
|
||||
|
||||
if not donors:
|
||||
return Suggestion(NO_DONORS, [], all_features, 0, target_count,
|
||||
None, 0)
|
||||
|
||||
best, masks = _coverage(target_features, donors, covers)
|
||||
full = 0
|
||||
for m in masks:
|
||||
full |= m
|
||||
|
||||
if full == 0:
|
||||
return Suggestion(NO_COVERAGE, [], all_features, len(donors),
|
||||
target_count, None, 0)
|
||||
|
||||
uncovered = tuple([i for i in all_features if not (full & (1 << i))])
|
||||
|
||||
k, cover_sets, _reached = _greedy_covers(masks, full, max_donors)
|
||||
proposals = [_build_proposal(cs, donors, best, target_count, master_keys,
|
||||
same_elevation_keys)
|
||||
for cs in cover_sets]
|
||||
proposals.sort(key=_proposal_sort_key)
|
||||
|
||||
# What the CAP kept out, read off the proposal that will be shown first
|
||||
# rather than off the union: the engineer acts on the answer he is given.
|
||||
# A feature nobody has is never counted here -- that is `uncovered`, a
|
||||
# different finding with a different remedy.
|
||||
nobody = set(uncovered)
|
||||
capped_out = tuple([i for i in (proposals[0].uncovered if proposals else ())
|
||||
if i not in nobody])
|
||||
|
||||
if uncovered:
|
||||
status = PARTIAL
|
||||
elif capped_out:
|
||||
status = CAPPED
|
||||
else:
|
||||
status = COMPLETE
|
||||
return Suggestion(status, proposals[:max_proposals], uncovered,
|
||||
len(donors), target_count, k, len(cover_sets),
|
||||
donor_cap=max_donors, capped_out=capped_out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A reference `covers`, for tests and for reading.
|
||||
#
|
||||
# THIS IS NOT THE PRODUCTION COMPARISON and must not be mistaken for one. It
|
||||
# makes no claim about the index's feature schema -- it operates on a bare
|
||||
# 4-tuple chosen because it is the smallest thing that can express the worked
|
||||
# example, and it encodes no precast engineering judgement whatsoever. Its job
|
||||
# is to be a fixed, legible stand-in so the selection logic above can be tested
|
||||
# exhaustively before the real comparison exists, and to be an executable
|
||||
# statement of the contract.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def rect_distance(donor_rect, target_rect):
|
||||
"""Reference `covers` over rectangles `(x0, x1, y0, y1)`, unit-agnostic.
|
||||
|
||||
Coordinates are absolute, measured from the start of the piece -- never a
|
||||
fraction of its length.
|
||||
|
||||
Covers-at-all: each rectangle must contain the other's centre. That is a
|
||||
two-dimensional test on purpose, and it is why it is written as a mutual
|
||||
condition rather than a range overlap: in the worked example two donor
|
||||
features share an x-range with a target feature while sitting in a
|
||||
different Z band, and a single-axis check calls those the same feature.
|
||||
They are not.
|
||||
|
||||
Distance: the sum of the four absolute corner offsets, IN WHATEVER UNIT
|
||||
THE COORDINATES ARE IN. Zero for identical rectangles, and one unit for
|
||||
every kind of feature, as the contract requires.
|
||||
|
||||
DELIBERATELY UNIT-AGNOSTIC, unlike the production comparison. This is a
|
||||
stand-in over bare tuples for testing the selection logic; it declares no
|
||||
unit because its inputs declare none, and it applies no quantization. The
|
||||
production `covers` -- `reinforcebind.covers` -- reads feet off index
|
||||
records and returns INCHES on a 1/8 in grain, which is where the owner's
|
||||
unit ruling actually lands. Do not read this function as the unit
|
||||
contract; read requirement 4 above.
|
||||
"""
|
||||
try:
|
||||
dx0, dx1, dy0, dy1 = [float(v) for v in donor_rect]
|
||||
tx0, tx1, ty0, ty1 = [float(v) for v in target_rect]
|
||||
except (TypeError, ValueError):
|
||||
return None # not judgeable -> not covered
|
||||
|
||||
def contains(x0, x1, y0, y1, cx, cy):
|
||||
return x0 <= cx <= x1 and y0 <= cy <= y1
|
||||
|
||||
if not contains(dx0, dx1, dy0, dy1, (tx0 + tx1) / 2.0, (ty0 + ty1) / 2.0):
|
||||
return None
|
||||
if not contains(tx0, tx1, ty0, ty1, (dx0 + dx1) / 2.0, (dy0 + dy1) / 2.0):
|
||||
return None
|
||||
|
||||
return (abs(dx0 - tx0) + abs(dx1 - tx1) +
|
||||
abs(dy0 - ty0) + abs(dy1 - ty1))
|
||||
@@ -0,0 +1,422 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The DECLARED SCOPE: which pieces of a job this engineer is answerable for.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Several engineers work one job. The owner of this tool details the wall
|
||||
panels; the beams, the slabs and the columns belong to other people. The
|
||||
plant-level knowledge every check in here runs on -- naming, hardware,
|
||||
bed layout, tolerances -- was validated against the panels and NOT against
|
||||
the rest, and the owner cannot validate it against the rest, because he does
|
||||
not hold the job knowledge for somebody else's pieces.
|
||||
|
||||
So a tool that judges all of them is not being thorough. It is producing
|
||||
verdicts on work it has no basis to judge, in the same report and the same
|
||||
typeface as the verdicts it can stand behind.
|
||||
|
||||
WHY THIS IS NOT AN IGNORE LIST
|
||||
------------------------------
|
||||
An exclusion that silently drops things is the exact failure this project has
|
||||
already paid for: one unauthorised exclusion hid forty tickets and every
|
||||
check that ran came back clean. Nothing here may repeat it, so:
|
||||
|
||||
* OUT OF SCOPE IS A THIRD STATE. Not a pass, not a failure, not an absence.
|
||||
Every report that leaves work alone says so, in a line, naming how much
|
||||
and of what kind.
|
||||
|
||||
* THE SCOPE IS DECLARED, NEVER INFERRED. It is read from the JOB knowledge
|
||||
layer -- `scope.mark_prefixes` -- because who is responsible for what is
|
||||
a fact about this job and this office, not about the plant and certainly
|
||||
not about the code. No prefix is written into this file.
|
||||
|
||||
* NO DECLARATION MEANS JUDGE EVERYTHING. An undeclared scope is not an
|
||||
empty scope. `Scope()` includes every piece, and its report line says
|
||||
that nothing was set aside AND that the knowledge behind those verdicts
|
||||
may never have been validated outside the usual work.
|
||||
|
||||
* A PIECE WHOSE KIND CANNOT BE READ IS NEVER SET ASIDE. If a mark carries
|
||||
no readable letter prefix, the tool does not know whether it is in scope,
|
||||
and "I could not tell" may only ever resolve towards judging it. Those
|
||||
are counted and named on the same line.
|
||||
|
||||
* A NEAR MISS IS REPORTED, NEVER RESOLVED. See below.
|
||||
|
||||
THE NEAR MISS, AND WHY THE MATCH IS NOT WIDENED
|
||||
------------------------------------------------
|
||||
`prefix_of` reads the WHOLE leading letter run, so a declaration of `AAA`
|
||||
does not reach a mark `AAAB001` -- its prefix is `AAAB`, which is not `AAA`,
|
||||
and the piece is set aside as somebody else's. That is a real hazard: on a
|
||||
job whose marks carry a suffix letter for a variant, a genuine piece of this
|
||||
engineer's is quietly handed to another engineer.
|
||||
|
||||
The obvious repair -- match a declared prefix as a PREFIX, so `AAA` reaches
|
||||
`AAAB` -- is not made here, and deliberately:
|
||||
|
||||
* It would also reach every OTHER letter run that starts the same way,
|
||||
pulling pieces that really are somebody else's INTO the checked set.
|
||||
* A false in-scope is worse than a false out-of-scope. Out of scope is
|
||||
reported, in a line, by kind. In scope is judged silently against
|
||||
knowledge that was never validated for it -- which is the exact failure
|
||||
this module exists to prevent.
|
||||
* Which of the two a given job means is a JOB FACT. It belongs in the job
|
||||
file, declared by a person, not in a rule this code guessed.
|
||||
|
||||
So the behaviour is unchanged and the near miss is REPORTED: a set-aside
|
||||
kind that is a prefix of a declared one, or that a declared one is a prefix
|
||||
of, is named on the report with both spellings and the sentence saying the
|
||||
tool did not guess. A human widens the declaration -- by adding the longer
|
||||
prefix to `scope.mark_prefixes` -- or leaves it alone. Nothing here decides.
|
||||
|
||||
WHAT THE SCOPE IS EXPRESSED IN, AND WHY
|
||||
---------------------------------------
|
||||
The MARK PREFIX -- the letters a piece mark starts with. Reasons, in order:
|
||||
|
||||
* Every entry point already has a mark, or an assembly name that is the
|
||||
mark, in its hand. Sheets are named for their mark too. One rule reaches
|
||||
all of them.
|
||||
* The prefix is the ONE stable classifier. A design number moves -- new
|
||||
designs are added to a live job weekly, and a scope listed as design
|
||||
numbers goes stale silently, which is the failure mode this file exists
|
||||
to prevent.
|
||||
* It is what the reinforcing donor filter already discriminated on, so
|
||||
adopting it is not a new concept in this codebase, it is the existing
|
||||
one lifted out of the code and into the job file.
|
||||
|
||||
A job whose marks do NOT encode the discipline in their prefix cannot use
|
||||
this key, and must not be given a guessed answer: it declares nothing, gets
|
||||
"no scope declared", and every report says every piece was judged. Adding a
|
||||
second expression -- design numbers, a category, an explicit mark list -- is
|
||||
a new key beside this one, never a reinterpretation of this one.
|
||||
|
||||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||||
"""
|
||||
import re
|
||||
|
||||
from vibe.compat import string_types
|
||||
|
||||
# Where the declaration lives. JOB layer -- see the module docstring.
|
||||
KEY = 'scope.mark_prefixes'
|
||||
NOTE_KEY = 'scope.note'
|
||||
BY_KEY = 'scope.declared_by'
|
||||
|
||||
# The leading letters of a mark. Deliberately letters-only: a mark is
|
||||
# <letters><number> at every plant seen so far, and anything else is
|
||||
# 'could not read', which is a reported state rather than a guess.
|
||||
_PREFIX_RE = re.compile(r'^([A-Za-z]+)')
|
||||
|
||||
# Letters followed by a number: the shape of a piece mark or a design number.
|
||||
# Used where a caller holds a name that MIGHT be a mark -- a sheet name, say
|
||||
# -- and must not treat 'the general arrangement drawing' as somebody else's
|
||||
# piece. Anything that is not this shape is left alone by the scope entirely.
|
||||
_MARK_RE = re.compile(r'^[A-Za-z]+\s*\d')
|
||||
|
||||
# What a piece with no readable prefix is called on a report line.
|
||||
UNREADABLE = '(no readable mark prefix)'
|
||||
|
||||
|
||||
def prefix_of(name):
|
||||
"""The leading letters of a mark, upper case, or None if there are none."""
|
||||
if not isinstance(name, string_types):
|
||||
name = '' if name is None else str(name)
|
||||
m = _PREFIX_RE.match(name.strip())
|
||||
return m.group(1).upper() if m else None
|
||||
|
||||
|
||||
def looks_like_mark(name):
|
||||
"""True when this text has the shape of a piece mark or design number.
|
||||
|
||||
A caller holding something that is only SOMETIMES a mark (a sheet name)
|
||||
passes names through this first, so a drawing that is not a piece is
|
||||
never counted as another engineer's piece.
|
||||
"""
|
||||
if not isinstance(name, string_types):
|
||||
name = '' if name is None else str(name)
|
||||
return bool(_MARK_RE.match(name.strip()))
|
||||
|
||||
|
||||
def as_mark(name):
|
||||
"""The name if it reads as a mark, else '' -- which every predicate here
|
||||
treats as 'could not tell', i.e. left in."""
|
||||
return name if looks_like_mark(name) else ''
|
||||
|
||||
|
||||
class Scope(object):
|
||||
"""The declared scope as a predicate. Construct with no argument for
|
||||
'nobody has declared one', which includes everything."""
|
||||
|
||||
def __init__(self, prefixes=(), note=None, declared_by=None,
|
||||
source='job knowledge: ' + KEY):
|
||||
cleaned = []
|
||||
for p in (prefixes or ()):
|
||||
text = ('' if p is None else str(p)).strip().upper()
|
||||
if text and text not in cleaned:
|
||||
cleaned.append(text)
|
||||
self.prefixes = tuple(cleaned)
|
||||
self.note = note or None
|
||||
self.declared_by = declared_by or None
|
||||
self.source = source
|
||||
|
||||
# -------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def is_declared(self):
|
||||
return bool(self.prefixes)
|
||||
|
||||
def label(self):
|
||||
"""What the scope IS, in words a reader can check against reality."""
|
||||
if not self.is_declared:
|
||||
return 'no scope declared'
|
||||
return 'piece marks beginning {0}'.format(
|
||||
' or '.join(self.prefixes))
|
||||
|
||||
# ---------------------------------------------------------- predicate
|
||||
|
||||
def includes(self, name):
|
||||
"""True when this mark is the engineer's to judge.
|
||||
|
||||
True for everything when no scope is declared, and true for a mark
|
||||
whose prefix cannot be read -- 'I could not tell' never removes work.
|
||||
"""
|
||||
if not self.is_declared:
|
||||
return True
|
||||
prefix = prefix_of(name)
|
||||
if prefix is None:
|
||||
return True
|
||||
return prefix in self.prefixes
|
||||
|
||||
def is_unreadable(self, name):
|
||||
return prefix_of(name) is None
|
||||
|
||||
def kind(self, name):
|
||||
"""The group a set-aside item is reported under."""
|
||||
return prefix_of(name) or UNREADABLE
|
||||
|
||||
# ------------------------------------------------------------ splits
|
||||
|
||||
def split(self, items, key=None):
|
||||
"""(inside, outside) preserving order. `key` reads the mark."""
|
||||
key = key or (lambda x: x)
|
||||
inside, outside = [], []
|
||||
for item in (items or []):
|
||||
(inside if self.includes(key(item)) else outside).append(item)
|
||||
return inside, outside
|
||||
|
||||
def split_names(self, names):
|
||||
return self.split(list(names or []))
|
||||
|
||||
def tally(self, names):
|
||||
"""[(kind, count)] for the things set aside, biggest first."""
|
||||
counts = {}
|
||||
for name in (names or []):
|
||||
kind = self.kind(name)
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
|
||||
# -------------------------------------------------------- the near miss
|
||||
|
||||
def nearly(self, kind):
|
||||
"""The declared prefix `kind` NEARLY is, or None.
|
||||
|
||||
'Nearly' is deliberately narrow: one of the two spellings is a
|
||||
prefix of the other. `AAAB` nearly matches a declared `AAA`, and a
|
||||
bare `AAA` nearly matches a declared `AAAB` -- either way somebody
|
||||
wrote one and meant, perhaps, the other. `BBB` nearly matches
|
||||
nothing. An exact match is not a near miss, it is a match.
|
||||
"""
|
||||
if not self.is_declared or not kind or kind == UNREADABLE:
|
||||
return None
|
||||
for declared in self.prefixes:
|
||||
if kind == declared:
|
||||
return None
|
||||
if kind.startswith(declared) or declared.startswith(kind):
|
||||
return declared
|
||||
return None
|
||||
|
||||
def near_misses(self, names):
|
||||
"""[(kind, count, declared)] for set-aside kinds that nearly match.
|
||||
|
||||
Biggest first. Empty when the scope is undeclared, when nothing was
|
||||
set aside, or when nothing set aside resembles the declaration --
|
||||
which is the ordinary case and prints nothing.
|
||||
"""
|
||||
out = []
|
||||
for kind, count in self.tally(names):
|
||||
declared = self.nearly(kind)
|
||||
if declared:
|
||||
out.append((kind, count, declared))
|
||||
return out
|
||||
|
||||
def near_miss_line(self, outside_names, unit='piece'):
|
||||
"""The sentence a human has to read, or None.
|
||||
|
||||
Says both spellings and says the tool did not choose between them.
|
||||
Never a verdict: a near miss is a question for the person who holds
|
||||
the job knowledge, and the only two answers -- widen the declaration
|
||||
or leave it -- are both his.
|
||||
"""
|
||||
misses = self.near_misses(outside_names)
|
||||
if not misses:
|
||||
return None
|
||||
bits = ', '.join(
|
||||
'{0} {1}(s) with mark prefix {2} (the declaration says {3})'
|
||||
.format(count, unit, kind, declared)
|
||||
for kind, count, declared in misses)
|
||||
return ('NEAR MISS -- SOMEBODY SHOULD LOOK: {0} were set aside. A '
|
||||
'prefix that only NEARLY matches is either work of yours the '
|
||||
'declaration does not reach, or somebody else\'s work that '
|
||||
'happens to start the same way, and this tool will not guess '
|
||||
'which -- guessing wrong towards "yours" would judge another '
|
||||
'engineer\'s piece silently. If they are yours, add the '
|
||||
'longer prefix to the declaration ({1}); if they are not, '
|
||||
'nothing needs doing and this line is the record that it was '
|
||||
'looked at.'.format(bits, self.source))
|
||||
|
||||
# ------------------------------------------------------------ report
|
||||
|
||||
def line(self, outside_names, total=None, unit='piece',
|
||||
unreadable_names=None):
|
||||
"""ONE line saying what was left alone and why. Never a pass.
|
||||
|
||||
`outside_names` are the marks that were NOT judged. `total` is how
|
||||
many there were altogether, so the reader can see the proportion;
|
||||
omit it and the line simply does not claim one.
|
||||
|
||||
A NEAR MISS RIDES ON THIS LINE rather than on a line of its own, so
|
||||
that every consumer of `line()` reports it without having to know it
|
||||
exists. A report that wants it on a shut fold's summary as well asks
|
||||
for `near_miss_line()` directly.
|
||||
"""
|
||||
outside = list(outside_names or [])
|
||||
unreadable = list(unreadable_names or [])
|
||||
tail = ''
|
||||
if unreadable:
|
||||
tail = (' {0} {1}(s) carry no readable mark prefix; they could '
|
||||
'not be placed inside or outside the scope and were '
|
||||
'judged rather than set aside.'
|
||||
.format(len(unreadable), unit))
|
||||
near = self.near_miss_line(outside, unit=unit)
|
||||
if near:
|
||||
tail += ' ' + near
|
||||
|
||||
if not self.is_declared:
|
||||
return ('NOT JUDGED, OUT OF SCOPE: none set aside. No scope is '
|
||||
'declared for this job ({0} is empty), so every {1} was '
|
||||
'judged -- including work this plant knowledge has never '
|
||||
'been validated against. Declare a scope to be told what '
|
||||
'is being taken on trust.'.format(self.source, unit)
|
||||
+ tail)
|
||||
|
||||
if not outside:
|
||||
# Deliberately does NOT say 'everything here was in scope'. The
|
||||
# user may have selected an out-of-scope piece himself, which is
|
||||
# judged and annotated rather than set aside; this line's job is
|
||||
# only to say that nothing was removed from the run.
|
||||
return ('NOT JUDGED, OUT OF SCOPE: none -- nothing was set aside '
|
||||
'from this run. The declared scope is {0} ({1}).'
|
||||
.format(self.label(), self.source) + tail)
|
||||
|
||||
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
|
||||
of_total = ' of {0}'.format(total) if total else ''
|
||||
return ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) were left alone -- '
|
||||
'{3}. The declared scope is {4} ({5}); everything else '
|
||||
'belongs to another engineer and the knowledge this tool '
|
||||
'runs on is not validated for it. Not judged is not clean, '
|
||||
'and not a failure either.'
|
||||
.format(len(outside), of_total, unit, kinds, self.label(),
|
||||
self.source) + tail)
|
||||
|
||||
def short_line(self, outside_names, total=None, unit='piece'):
|
||||
"""The same three states in ONE SHORT CLAUSE, for a console.
|
||||
|
||||
`line()` is the sentence; this is the headline. It exists because
|
||||
some consumers truncate -- `tools/sweep.py` prints the first 200
|
||||
characters of what the in-Revit half returns -- and a scope line
|
||||
that gets cut off the end of a progress line is a scope line nobody
|
||||
read. It says the COUNT, which is what tells a reader there is a
|
||||
full sentence somewhere worth opening.
|
||||
|
||||
It never replaces `line()`. Anything printing this must also write
|
||||
`line()` where the reader can reach it.
|
||||
"""
|
||||
outside = list(outside_names or [])
|
||||
if not self.is_declared:
|
||||
return ('SCOPE: none declared -- every {0} was judged'
|
||||
.format(unit))
|
||||
of_total = ' of {0}'.format(total) if total else ''
|
||||
if not outside:
|
||||
return ('SCOPE {0}: no {1}{2} was set aside'
|
||||
.format(self.label(), unit, of_total))
|
||||
kinds = self.tally(outside)
|
||||
shown = ', '.join('{0} {1}'.format(k, n) for k, n in kinds[:3])
|
||||
if len(kinds) > 3:
|
||||
shown += ', +{0} more'.format(len(kinds) - 3)
|
||||
text = ('NOT JUDGED, OUT OF SCOPE: {0}{1} {2}(s) left alone [{3}]'
|
||||
.format(len(outside), of_total, unit, shown))
|
||||
if self.near_misses(outside):
|
||||
text += ' -- NEAR MISS'
|
||||
return text
|
||||
|
||||
def annotation(self, outside_names, unit='piece'):
|
||||
"""The line for work the USER picked that falls outside the scope.
|
||||
|
||||
Nothing the user selected is ever dropped -- see `honoured_selection`
|
||||
in the design notes. It is judged, and this says the verdict rests on
|
||||
knowledge nobody has validated for that kind of piece.
|
||||
"""
|
||||
outside = list(outside_names or [])
|
||||
if not self.is_declared or not outside:
|
||||
return None
|
||||
kinds = ', '.join('{0} {1}'.format(k, n) for k, n in self.tally(outside))
|
||||
# A near miss matters MORE here than on `line()`: the reader is being
|
||||
# told his own selection rests on unvalidated knowledge, and if the
|
||||
# prefix only nearly missed, that warning may be the wrong one.
|
||||
near = self.near_miss_line(outside, unit=unit)
|
||||
return ('JUDGED OUTSIDE THE DECLARED SCOPE, AT YOUR REQUEST: {0} '
|
||||
'{1}(s) -- {2}. You selected them, so nothing was dropped; '
|
||||
'but the declared scope is {3}, and these verdicts rest on '
|
||||
'knowledge that has not been validated for those pieces. '
|
||||
'Read them as opinions, not as checks.'
|
||||
.format(len(outside), unit, kinds, self.label())
|
||||
+ ((' ' + near) if near else ''))
|
||||
|
||||
def declaration_lines(self):
|
||||
"""Who declared it and what they said, when they said anything."""
|
||||
out = []
|
||||
if self.declared_by:
|
||||
out.append('scope declared by {0}'.format(self.declared_by))
|
||||
if self.note:
|
||||
# A YAML folded scalar (`note: >`) arrives with a trailing
|
||||
# newline, which puts a blank line into every report that prints
|
||||
# it. Stripped here rather than at four call sites.
|
||||
out.append(' '.join(str(self.note).split()))
|
||||
return out
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, string_types):
|
||||
return [value]
|
||||
return list(value)
|
||||
|
||||
|
||||
def of(know, get=None):
|
||||
"""The declared scope out of a loaded knowledge dict.
|
||||
|
||||
`get` is `knowledge.get`. It is a parameter so a test can pass its own
|
||||
and this module never has to import `knowledge` at module scope; the
|
||||
ordinary path imports it lazily, and reads the three keys through the
|
||||
alias `K` with LITERAL paths so `tools/knowledge_drift.py` can see that
|
||||
they are genuinely read. A key nobody can prove is read is a key the
|
||||
file only claims to honour.
|
||||
"""
|
||||
if not know:
|
||||
return Scope()
|
||||
if get is None:
|
||||
from vibe import knowledge as K
|
||||
return Scope(prefixes=_as_list(K.get(know, 'scope.mark_prefixes', None)),
|
||||
note=K.get(know, 'scope.note', None),
|
||||
declared_by=K.get(know, 'scope.declared_by', None))
|
||||
return Scope(prefixes=_as_list(get(know, 'scope.mark_prefixes', None)),
|
||||
note=get(know, 'scope.note', None),
|
||||
declared_by=get(know, 'scope.declared_by', None))
|
||||
@@ -0,0 +1,720 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Naming, finding and vouching for model snapshots. NO REVIT, NO DOCUMENT.
|
||||
|
||||
This is the half of the snapshot feature that can be reasoned about without a
|
||||
licence: what a timestamped detached copy is called, which one is newest, what
|
||||
a snapshot has to say about where it came from, and what the run log means.
|
||||
The Revit half is the button
|
||||
(`WillTools.tab/Tools.panel/SnapshotClose.pushbutton` -- a large button on the
|
||||
Tools panel since 2026-08-30, a small one in a stack before that) and
|
||||
the deferred runner (`WillTools.extension/runners/detach_snapshot.py`); both
|
||||
import this and neither duplicates any of it.
|
||||
|
||||
IronPython 2.7 on the extension side, CPython 3.12 in `tools/` -- so no
|
||||
f-strings, no pathlib, no dataclasses, and `os.path` throughout.
|
||||
|
||||
|
||||
THE THREE PHASES, because every function here is named after one
|
||||
-----------------------------------------------------------------
|
||||
The owner's flow is a once-a-day close-model ritual, and the ordering is what
|
||||
makes it cheap:
|
||||
|
||||
(he syncs, himself, outside all of this)
|
||||
A press the button -> index the OPEN document. The model is already in
|
||||
RAM and already paid for. Seconds.
|
||||
B close Revit. The press IS the authorisation; that is the button's name.
|
||||
C AFTER Revit has exited and the 10 GB is free, a deferred host process
|
||||
launches one Revit, writes the timestamped detached copy, and quits.
|
||||
|
||||
|
||||
THE ONE-MODEL PREMISE, AND WHY IT NO LONGER RULES THE DESIGN
|
||||
------------------------------------------------------------
|
||||
Everything above was built on "the machine holds ONE model at a time, ever":
|
||||
a 300+ MB file costing 10+ GB open, so Phase C had to wait until the
|
||||
engineer's Revit was gone. **That premise is empirically false and was
|
||||
retired on 2026-09-01** -- the live cloud session and a detached sandbox
|
||||
session ran side by side for a whole morning on this machine. What is still
|
||||
true is the weaker statement, and it is the one the code now enforces:
|
||||
|
||||
NEVER THREE. Two Revits are fine. A third is the one nobody measured.
|
||||
|
||||
So `tools/snapshot_cycle.py` sequences rather than waits: it closes the
|
||||
sandbox BEFORE it launches the Revit that writes the new copy, and launches
|
||||
the new sandbox only once that one has exited. At every instant there are at
|
||||
most the live session and one other. The machine-wide "wait until no Revit is
|
||||
running at all" gate in `tools/snapshot_defer.py` still exists and is still
|
||||
correct for the close-model ritual, but it is now opt-in
|
||||
(`--machine-free require`) rather than the law.
|
||||
|
||||
The CYCLE that replaced Phase C's tail: make the timestamped copy, close the
|
||||
sandbox Revit, reopen it on the new copy, run a batch inside it over the
|
||||
bridge, close it again, journal the lot. Its events are CYCLE_START ..
|
||||
CYCLE_DONE below, in this same log.
|
||||
|
||||
|
||||
WHY PHASE A NEEDS NO DETACHED COPY, WRITTEN DOWN SO IT IS NOT RE-LITIGATED
|
||||
--------------------------------------------------------------------------
|
||||
A snapshot read from the live document immediately after a sync and a
|
||||
snapshot read from a detached copy of that same sync state contain the same
|
||||
numbers: the detach is a transport, not a measurement. The detached copy
|
||||
therefore is not evidence for today's index at all. Its remaining purpose is
|
||||
FUTURE RE-SCANS -- extractors that do not exist yet, run against the state as
|
||||
it was on a day nobody can go back to -- and the provenance says exactly that
|
||||
so a reader does not mistake it for the source of the current numbers.
|
||||
|
||||
|
||||
THE LAYOUT DECISION, AND THE TRAP UNDER IT
|
||||
-------------------------------------------
|
||||
The owner asked for the model file to be called
|
||||
`<name>_detached_YYYYMMDD-HHMM`. Put the stamp in the FILE name and `doc.Title`
|
||||
changes on every snapshot -- and `doc.Title` is what
|
||||
`designspec.root_dir` keys the whole job folder on (`WILLTOOLS_JOBS` makes the
|
||||
job folder `<jobs root>/<doc.Title>` literally). A per-snapshot title means a
|
||||
per-snapshot job folder: no `knowledge.yaml` in it, so no declared scope and
|
||||
no parameter-role mapping, and an index built there is both wrong and
|
||||
invisible to the live session. This office has already met that problem once
|
||||
and solved it by hand, with a directory junction from the detached title back
|
||||
to the real job folder -- a fix that does not survive a name that changes
|
||||
daily.
|
||||
|
||||
So the DEFAULT puts the stamp in the FOLDER:
|
||||
|
||||
<root>/<title>_20260830-1612/<title> layout 'folder' (default)
|
||||
<root>/<title>_20260830-1612 layout 'filename'
|
||||
|
||||
(both with the model extension on the last segment)
|
||||
|
||||
Same string the owner asked for, one path segment up. The title stays
|
||||
constant, one junction serves every snapshot forever, and the stamp is still
|
||||
right there in the path Revit prints in its title bar. `layout 'filename'` is
|
||||
kept, selectable, and `plan()` returns the warning that goes with it rather
|
||||
than refusing -- a plant that does not use `WILLTOOLS_JOBS` is not exposed to
|
||||
the trap and should not be lectured about it in code it cannot change.
|
||||
|
||||
Discovery reads BOTH layouts, so a folder that already holds hand-made copies
|
||||
in either shape is understood without being converted.
|
||||
"""
|
||||
# pylint: disable=broad-except,invalid-name
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
# `20260830-1612`. Minute resolution: the process that writes one of these
|
||||
# takes minutes and nobody presses this button twice inside sixty seconds.
|
||||
STAMP_FMT = '%Y%m%d-%H%M'
|
||||
STAMP_RE = re.compile(r'^(?P<base>.+)_(?P<stamp>\d{8}-\d{4})$')
|
||||
|
||||
# Named MODEL_EXT rather than after the extension itself: `tools/scrub.py`
|
||||
# matches <word> + extension case-insensitively, so a constant called RVT
|
||||
# reads as a model filename at every call site and fails the leak audit.
|
||||
MODEL_EXT = '.rvt'
|
||||
|
||||
# The layouts `plan()` can produce and `snapshots()` can read.
|
||||
FOLDER = 'folder'
|
||||
FILENAME = 'filename'
|
||||
LAYOUTS = (FOLDER, FILENAME)
|
||||
|
||||
FILENAME_WARNING = (
|
||||
"layout 'filename' puts the timestamp in the model's own name, so "
|
||||
"doc.Title changes on every snapshot. With WILLTOOLS_JOBS set, the job "
|
||||
'folder is <jobs root>/<doc.Title> -- a new, empty one per snapshot, with '
|
||||
'no knowledge.yaml, so nothing downstream sees the declared scope. Use '
|
||||
"layout 'folder' unless you know this job does not resolve that way.")
|
||||
|
||||
# What produced a snapshot. `kind` in the provenance block.
|
||||
LIVE_DOC = 'live-doc' # Phase A: the open model, in session
|
||||
FRESH_DETACH = 'fresh-cloud-detach' # Phase C: detached from the cloud now
|
||||
EXISTING_COPY = 'existing-detached-copy' # a re-scan of a file already on disk
|
||||
KINDS = (LIVE_DOC, FRESH_DETACH, EXISTING_COPY)
|
||||
|
||||
PROVENANCE_SCHEMA = 1
|
||||
|
||||
# One JSON object per line, appended, never rewritten. Chosen over a single
|
||||
# JSON document because two processes write it -- the button in Revit and the
|
||||
# deferred host tool -- and an append of one line cannot corrupt what is
|
||||
# already there, while a read-modify-write of a whole document can.
|
||||
LOG_NAME = 'snapshots.jsonl'
|
||||
|
||||
# Log event names. The pair that matters is REQUESTED / DONE: a REQUESTED with
|
||||
# nothing after it is a Phase C that never reported, and that must be visible.
|
||||
REQUESTED = 'phase-c-requested'
|
||||
DONE = 'phase-c-done'
|
||||
FAILED = 'phase-c-failed'
|
||||
SKIPPED = 'phase-c-skipped'
|
||||
SNAPSHOT = 'phase-a-snapshot'
|
||||
|
||||
# The CYCLE, added 2026-09-01. The old three phases describe one close-model
|
||||
# ritual; these describe the pipeline that replaced its tail --
|
||||
# `tools/snapshot_cycle.py`: make the copy, cycle the sandbox Revit onto it,
|
||||
# run a batch inside it, close it again. Same log, same file, appended by the
|
||||
# same never-raising `append()`, because a reader wants ONE ledger per job and
|
||||
# not two that have to be interleaved by hand.
|
||||
#
|
||||
# CYCLE_START is written before anything is launched and CYCLE_DONE /
|
||||
# CYCLE_FAILED after the sandbox is closed, so the same "requested and never
|
||||
# reported" question `outstanding()` asks of phase C can be asked of a cycle:
|
||||
# a CYCLE_START with nothing after it is a run that died with a Revit still
|
||||
# open, which is the one outcome that costs the machine 10 GB until somebody
|
||||
# notices.
|
||||
CYCLE_START = 'cycle-start'
|
||||
CYCLE_DONE = 'cycle-done'
|
||||
CYCLE_FAILED = 'cycle-failed'
|
||||
SANDBOX_OPENED = 'sandbox-opened'
|
||||
SANDBOX_CLOSED = 'sandbox-closed'
|
||||
BATCH_DONE = 'batch-done'
|
||||
|
||||
CYCLE_TERMINAL = (CYCLE_DONE, CYCLE_FAILED)
|
||||
|
||||
# `tools/snapshot_defer.py`'s own lifecycle, added 2026-09-02 after an
|
||||
# incident where it crashed (or was never even reachable) with nothing
|
||||
# anywhere saying so. WATCHER_LAUNCHED is written once the request has been
|
||||
# read and the wait/launch is genuinely about to happen -- proof that the
|
||||
# process this ritual depends on actually started, which a REQUESTED line
|
||||
# alone cannot prove (the button can write REQUESTED and then fail to spawn
|
||||
# the host process, or spawn it into a crash before it opens a single file).
|
||||
# WATCHER_CRASHED is the backstop for anything the existing FAILED paths
|
||||
# do not already name -- an exception nobody anticipated, caught at the
|
||||
# outermost level so it is journaled before the process exits rather than
|
||||
# only printed to a stderr nobody is redirecting (the button spawns this
|
||||
# tool with `CreateNoWindow = True` and no output redirection at all: an
|
||||
# unjournalled stderr line there is not merely easy to miss, it is provably
|
||||
# unread by anything).
|
||||
WATCHER_LAUNCHED = 'watcher-launched'
|
||||
WATCHER_CRASHED = 'watcher-crashed'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- stamping
|
||||
|
||||
def stamp(when=None):
|
||||
"""`YYYYMMDD-HHMM` for a time, or now."""
|
||||
return time.strftime(STAMP_FMT, time.localtime(when)
|
||||
if when is not None else time.localtime())
|
||||
|
||||
|
||||
def is_stamp(text):
|
||||
"""True for exactly `YYYYMMDD-HHMM`. Never raises."""
|
||||
try:
|
||||
return bool(re.match(r'^\d{8}-\d{4}$', text or ''))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def split_stamped(name):
|
||||
"""('base', 'YYYYMMDD-HHMM') out of a stamped name, or (None, None).
|
||||
|
||||
The stamp is matched at the END and the base is whatever precedes it, so a
|
||||
base that itself contains underscores, spaces, or the word `detached` --
|
||||
all three occur in real titles -- survives intact.
|
||||
"""
|
||||
try:
|
||||
hit = STAMP_RE.match(name or '')
|
||||
except Exception:
|
||||
return None, None
|
||||
if not hit:
|
||||
return None, None
|
||||
return hit.group('base'), hit.group('stamp')
|
||||
|
||||
|
||||
def stamped_name(base, stamp_text):
|
||||
"""`<base>_<stamp>`. The one place the two are joined."""
|
||||
return '{0}_{1}'.format(base, stamp_text)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- where one goes
|
||||
|
||||
def plan(root, base, stamp_text=None, layout=FOLDER):
|
||||
"""Where the next detached copy goes, and what it will be called.
|
||||
|
||||
Returns a dict, never a tuple: this grew a field twice already and a
|
||||
caller unpacking three values is a caller that breaks on the fourth.
|
||||
|
||||
root the configured detached-copies folder
|
||||
base the SaveAs base name -- normally the detached document's own
|
||||
title as Revit reports it, so the title never moves
|
||||
layout 'folder' (default) or 'filename'; see the module docstring
|
||||
|
||||
-> {'layout', 'stamp', 'folder', 'path', 'title', 'title_is_stable',
|
||||
'warning'}
|
||||
|
||||
`title` is what `doc.Title` will be after the SaveAs, which is the value
|
||||
every job-folder lookup downstream keys on. It is returned, rather than
|
||||
left implicit, because getting it wrong is the expensive failure here and
|
||||
a caller should be able to print it before committing to the write.
|
||||
"""
|
||||
if layout not in LAYOUTS:
|
||||
layout = FOLDER
|
||||
stamp_text = stamp_text or stamp()
|
||||
if layout == FILENAME:
|
||||
title = stamped_name(base, stamp_text)
|
||||
return {'layout': FILENAME, 'stamp': stamp_text, 'folder': root,
|
||||
'path': os.path.join(root, title + MODEL_EXT), 'title': title,
|
||||
'title_is_stable': False, 'warning': FILENAME_WARNING}
|
||||
folder = os.path.join(root, stamped_name(base, stamp_text))
|
||||
return {'layout': FOLDER, 'stamp': stamp_text, 'folder': folder,
|
||||
'path': os.path.join(folder, base + MODEL_EXT), 'title': base,
|
||||
'title_is_stable': True, 'warning': None}
|
||||
|
||||
|
||||
# --------------------------------------------------------- finding the newest
|
||||
|
||||
def _model_in(folder):
|
||||
"""The single model file inside a snapshot folder, or None.
|
||||
|
||||
More than one is not an error worth raising over -- Revit leaves backups
|
||||
(a `.0001` counter glued into the name) beside a saved central -- so the SHORTEST name wins,
|
||||
which is the one without the backup counter glued on.
|
||||
"""
|
||||
try:
|
||||
names = [n for n in os.listdir(folder)
|
||||
if n.lower().endswith(MODEL_EXT) and
|
||||
os.path.isfile(os.path.join(folder, n))]
|
||||
except Exception:
|
||||
return None
|
||||
if not names:
|
||||
return None
|
||||
names.sort(key=lambda n: (len(n), n))
|
||||
return os.path.join(folder, names[0])
|
||||
|
||||
|
||||
def snapshots(root):
|
||||
"""Every timestamped detached copy under `root`, OLDEST FIRST.
|
||||
|
||||
Both layouts, in one list, each row saying which it is. A row is only
|
||||
produced when the model file actually exists: a stamped folder whose model was
|
||||
deleted or never finished writing is not a snapshot, and reporting it as
|
||||
one is how a caller ends up handing Revit a path to nothing.
|
||||
|
||||
Never raises. A `root` that does not exist is an empty list, because "not
|
||||
configured yet" and "configured and empty" are the caller's to tell apart
|
||||
(see `newest`) and neither is exceptional here.
|
||||
"""
|
||||
rows = []
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return rows
|
||||
for name in names:
|
||||
full = os.path.join(root, name)
|
||||
try:
|
||||
is_dir = os.path.isdir(full)
|
||||
except Exception:
|
||||
continue
|
||||
if is_dir:
|
||||
base, stamp_text = split_stamped(name)
|
||||
if not stamp_text:
|
||||
continue
|
||||
path = _model_in(full)
|
||||
if not path:
|
||||
continue
|
||||
title = os.path.splitext(os.path.basename(path))[0]
|
||||
rows.append({'layout': FOLDER, 'stamp': stamp_text, 'base': base,
|
||||
'folder': full, 'path': path, 'title': title})
|
||||
continue
|
||||
if not name.lower().endswith(MODEL_EXT):
|
||||
continue
|
||||
base, stamp_text = split_stamped(os.path.splitext(name)[0])
|
||||
if not stamp_text:
|
||||
continue
|
||||
rows.append({'layout': FILENAME, 'stamp': stamp_text, 'base': base,
|
||||
'folder': root, 'path': full,
|
||||
'title': os.path.splitext(name)[0]})
|
||||
# The stamp is fixed-width and zero-padded, so string order IS time order;
|
||||
# `path` breaks a tie so the answer does not depend on listdir order.
|
||||
rows.sort(key=lambda r: (r['stamp'], r['path']))
|
||||
return rows
|
||||
|
||||
|
||||
def newest(root):
|
||||
"""The most recent snapshot under `root`, or None. Never raises."""
|
||||
rows = snapshots(root)
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- provenance
|
||||
|
||||
def provenance(phase, kind, source_path=None, source_stamp=None,
|
||||
source_title=None, revit=None, pyrevit=None, job_folder=None,
|
||||
note=None, when=None):
|
||||
"""Where a snapshot came from. Attached to the index the snapshot wrote.
|
||||
|
||||
A snapshot that cannot say where it came from is the staleness trap this
|
||||
whole feature exists to close, so every field it needs to answer that is
|
||||
here whether or not the caller has it:
|
||||
|
||||
phase 'A' (from the open document) or 'C' (deferred, detached)
|
||||
kind one of KINDS -- live document, fresh detach from the
|
||||
cloud, or a re-scan of a file already on disk. These
|
||||
are NOT interchangeable and a reader must not have to
|
||||
guess which happened.
|
||||
source_path the file the numbers were read from. None for a live
|
||||
document that has never been saved -- which is itself
|
||||
worth knowing, so it is recorded as None rather than ''.
|
||||
source_stamp that file's snapshot timestamp, when it has one. A live
|
||||
document has none; the field is None and not the run
|
||||
time, because a source's age and a run's age are
|
||||
different facts and conflating them is the exact lie
|
||||
this block exists to prevent.
|
||||
revit/pyrevit the versions that did the reading.
|
||||
job_folder where the index was written.
|
||||
|
||||
`kind` is validated: an unrecognised value is kept but flagged, so a typo
|
||||
surfaces as a question instead of quietly becoming a fourth category.
|
||||
"""
|
||||
block = {
|
||||
'schema': PROVENANCE_SCHEMA,
|
||||
'phase': phase,
|
||||
'kind': kind,
|
||||
'kind_known': kind in KINDS,
|
||||
'source': {'path': source_path, 'stamp': source_stamp,
|
||||
'title': source_title},
|
||||
'revit': revit,
|
||||
'pyrevit': pyrevit,
|
||||
'job_folder': job_folder,
|
||||
'at': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(when)
|
||||
if when is not None else time.localtime()),
|
||||
'note': note,
|
||||
}
|
||||
return block
|
||||
|
||||
|
||||
def provenance_line(block):
|
||||
"""One printable sentence out of a provenance block. Never raises."""
|
||||
if not isinstance(block, dict):
|
||||
return 'no provenance -- this snapshot cannot say where it came from'
|
||||
src = block.get('source') or {}
|
||||
where = src.get('path') or src.get('title') or '(unsaved document)'
|
||||
when = src.get('stamp') or 'no snapshot stamp'
|
||||
return ('phase {0}, {1}, from {2} ({3}), Revit {4}, written {5}'
|
||||
.format(block.get('phase') or '?',
|
||||
block.get('kind') or 'unknown source',
|
||||
where, when, block.get('revit') or '?',
|
||||
block.get('at') or '?'))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the run log
|
||||
|
||||
def log_path(folder):
|
||||
return os.path.join(folder, LOG_NAME)
|
||||
|
||||
|
||||
def append(path, row):
|
||||
"""One event onto the end of the log. Never raises, ever.
|
||||
|
||||
Called from a button that is about to close Revit and from a background
|
||||
host process, and in both a failure to journal must not become a failure
|
||||
to do the work. A lost line is acceptable; a crash on the way out is not.
|
||||
"""
|
||||
try:
|
||||
row = dict(row or {})
|
||||
row.setdefault('at', time.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
folder = os.path.dirname(path)
|
||||
if folder and not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
text = json.dumps(row, sort_keys=True)
|
||||
handle = open(path, 'ab')
|
||||
try:
|
||||
handle.write(text.encode('utf-8'))
|
||||
handle.write(b'\n')
|
||||
finally:
|
||||
handle.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def read(path, limit=None):
|
||||
"""The log as a list of dicts, oldest first. A damaged line is skipped.
|
||||
|
||||
A half-written last line is the normal consequence of a process being
|
||||
killed, and it must cost that one line and nothing else -- a log that
|
||||
raises on read is a log that stops being read.
|
||||
"""
|
||||
rows = []
|
||||
try:
|
||||
handle = open(path, 'rb')
|
||||
try:
|
||||
raw = handle.read().decode('utf-8', 'replace')
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception:
|
||||
return rows
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
rows.append(row)
|
||||
if limit is not None and len(rows) > limit:
|
||||
rows = rows[-limit:]
|
||||
return rows
|
||||
|
||||
|
||||
def outstanding(rows):
|
||||
"""The Phase C that was asked for and never reported back, or None.
|
||||
|
||||
A deferred step that silently did not run is the failure mode this design
|
||||
is most exposed to: the button has already closed Revit, so nobody is
|
||||
watching, and the next day looks exactly like a day it worked. So the
|
||||
ledger is paired -- one REQUESTED line written before the close, one
|
||||
DONE/FAILED/SKIPPED line written by the deferred process -- and this
|
||||
reports a REQUESTED with nothing after it.
|
||||
|
||||
Matching is by `run` id, not by position, so an old unfinished request
|
||||
still shows after a later one succeeded rather than being papered over by
|
||||
it. Requests with no id at all fall back to "any later terminal event
|
||||
clears it", which is the best a line written by an older version allows.
|
||||
"""
|
||||
terminal = (DONE, FAILED, SKIPPED)
|
||||
closed = set()
|
||||
for row in (rows or ()):
|
||||
if row.get('event') in terminal and row.get('run'):
|
||||
closed.add(row.get('run'))
|
||||
pending = None
|
||||
for i, row in enumerate(rows or ()):
|
||||
if row.get('event') != REQUESTED:
|
||||
continue
|
||||
run = row.get('run')
|
||||
if run:
|
||||
if run not in closed:
|
||||
pending = row
|
||||
continue
|
||||
# No id: cleared only by a terminal line that comes after it.
|
||||
later = [r for r in (rows or ())[i + 1:] if r.get('event') in terminal]
|
||||
if not later:
|
||||
pending = row
|
||||
return pending
|
||||
|
||||
|
||||
def last(rows, event):
|
||||
"""The most recent row with this event, or None."""
|
||||
for row in reversed(list(rows or ())):
|
||||
if row.get('event') == event:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def summary(rows):
|
||||
"""What the button prints about snapshots that already happened.
|
||||
|
||||
A list of plain sentences, newest fact first, and it says the awkward
|
||||
thing out loud: a Phase C that was requested and never reported is named
|
||||
here, with its timestamp, because the alternative is that it stays
|
||||
invisible until somebody wonders why the detached folder is empty.
|
||||
"""
|
||||
lines = []
|
||||
rows = list(rows or ())
|
||||
if not rows:
|
||||
return ['no snapshot has been recorded for this job yet']
|
||||
pending = outstanding(rows)
|
||||
if pending is not None:
|
||||
lines.append('WARNING: a detached copy was requested {0} and never '
|
||||
'reported back. It may have failed, or the machine may '
|
||||
'have been shut down before it ran.'
|
||||
.format(pending.get('at') or 'at an unknown time'))
|
||||
done = last(rows, DONE)
|
||||
if done is not None:
|
||||
lines.append('last detached copy: {0} ({1})'
|
||||
.format(done.get('path') or '?', done.get('at') or '?'))
|
||||
failed = last(rows, FAILED)
|
||||
if failed is not None and (done is None or
|
||||
(failed.get('at') or '') > (done.get('at') or '')):
|
||||
lines.append('last detached copy FAILED at {0}: {1}'
|
||||
.format(failed.get('at') or '?',
|
||||
failed.get('error') or 'no reason recorded'))
|
||||
snap = last(rows, SNAPSHOT)
|
||||
if snap is not None:
|
||||
lines.append('last index snapshot: {0}, {1} pieces'
|
||||
.format(snap.get('at') or '?',
|
||||
snap.get('pieces')
|
||||
if snap.get('pieces') is not None else '?'))
|
||||
return lines
|
||||
|
||||
|
||||
# ------------------------------------------------- the command lines, exactly
|
||||
|
||||
def pyrevit_run_argv(pyrevit_exe, script, revit_year=None, purge=True):
|
||||
"""`pyrevit run <script> [--revit=YYYY] [--purge]` as an argv list.
|
||||
|
||||
Built here rather than formatted at the call site so the string the owner
|
||||
is asked to paste and the string the deferred tool actually runs are
|
||||
produced by the same code and cannot drift apart.
|
||||
|
||||
`--purge` removes the temporary run environment afterwards and is ON:
|
||||
without it every run leaves a runtime folder behind, and this runs daily.
|
||||
"""
|
||||
argv = [pyrevit_exe, 'run', script]
|
||||
if revit_year:
|
||||
argv.append('--revit={0}'.format(revit_year))
|
||||
if purge:
|
||||
argv.append('--purge')
|
||||
return argv
|
||||
|
||||
|
||||
def quote(argv):
|
||||
"""An argv list as a copy-pasteable command line. Windows quoting rules."""
|
||||
out = []
|
||||
for arg in argv:
|
||||
arg = '{0}'.format(arg)
|
||||
if arg and not re.search(r'[\s"]', arg):
|
||||
out.append(arg)
|
||||
else:
|
||||
out.append('"' + arg.replace('"', '\\"') + '"')
|
||||
return ' '.join(out)
|
||||
|
||||
|
||||
def revit_exe_guess(year):
|
||||
"""Where Revit installs itself, for a year. A GUESS -- the caller checks.
|
||||
|
||||
Returned unchecked and named `_guess` on purpose: `snapshot.revit_exe` is
|
||||
the configured answer and this is only what to try when nobody has given
|
||||
one. A path returned from here that does not exist is the pipeline's cue
|
||||
to say "configure snapshot.revit_exe", not to invent a second guess.
|
||||
"""
|
||||
if not year:
|
||||
return None
|
||||
return os.path.join(
|
||||
os.environ.get('ProgramFiles', 'C:\\Program Files'), 'Autodesk',
|
||||
'Revit {0}'.format(year), 'Revit.exe')
|
||||
|
||||
|
||||
def pyrevit_exe_guess():
|
||||
"""Where the pyRevit CLI installs itself. A GUESS -- the caller checks.
|
||||
|
||||
Same discipline as `revit_exe_guess`: returned only when the file is
|
||||
actually there, and never a substitute for `snapshot.pyrevit_exe` stated
|
||||
and WRONG -- a caller that read an explicit, bad path must never silently
|
||||
fall back to this. Both `tools/snapshot_cycle.py` and the SnapshotClose
|
||||
button already did this guess, each with its own copy; this is the one
|
||||
copy, so `tools/snapshot_defer.py` -- which used to have none at all and
|
||||
would `argparse.error()` (no journal, nothing) if `--pyrevit` was left
|
||||
off -- can make the same guess a caller forgot to make for it.
|
||||
"""
|
||||
guess = os.path.join(os.environ.get('APPDATA', ''), 'pyRevit-Master',
|
||||
'bin', 'pyrevit.exe')
|
||||
return guess if os.path.isfile(guess) else None
|
||||
|
||||
|
||||
_YEAR_RE = re.compile(r'(20\d{2})')
|
||||
|
||||
|
||||
def revit_year_from_exe(path):
|
||||
"""A 4-digit year recovered from a `Revit.exe` path, or None.
|
||||
|
||||
`pyrevit run` accepts an optional `--revit=<year>`; on a machine with
|
||||
several Revit versions attached (this office had five on 2026-09-01:
|
||||
2022 through 2026) omitting it is not safe to leave to whatever the CLI
|
||||
picks by default -- `pyrevit revits` reproducibly throws a bare
|
||||
"Object reference not set to an instance of an object" on this machine,
|
||||
and `pyrevit run` with no `--revit=` appears to walk the same path.
|
||||
`snapshot.revit_exe` almost always carries the year in its own path
|
||||
(`...\\Autodesk\\Revit 2024\\Revit.exe`), so this recovers it as a
|
||||
fallback when `snapshot.revit_year` was never separately configured --
|
||||
which is exactly the job layer shape that produced the 2026-09-01
|
||||
incident: `revit_exe` set, `revit_year` not.
|
||||
"""
|
||||
hit = _YEAR_RE.search(path or '')
|
||||
return hit.group(1) if hit else None
|
||||
|
||||
|
||||
def revit_exe_argv(revit_exe, model_path):
|
||||
"""`Revit.exe <model>` as an argv list -- the sandbox launch.
|
||||
|
||||
Deliberately not `pyrevit run`: that plays a journal and quits, which is
|
||||
what a one-shot detach wants and the opposite of what a session an agent
|
||||
is going to drive over the bridge for ten minutes wants. This starts an
|
||||
ordinary interactive Revit on one file; pyRevit loads in it exactly as it
|
||||
does when the file is double-clicked, which is what puts the bridge up.
|
||||
"""
|
||||
return [revit_exe, model_path]
|
||||
|
||||
|
||||
_UNSAFE = re.compile(r'[^A-Za-z0-9._-]+')
|
||||
|
||||
|
||||
def batch_log_name(index, command):
|
||||
"""`batch-01-python-tools-sweep.log` -- one log file per batch command.
|
||||
|
||||
The command is in the NAME as well as in the log, because a folder of
|
||||
`batch-01.log`, `batch-02.log` is a folder nobody opens twice. Truncated
|
||||
hard: the index is what makes it unique, the words are only there to be
|
||||
recognised.
|
||||
"""
|
||||
text = _UNSAFE.sub('-', '{0}'.format(command or '')).strip('-')
|
||||
if len(text) > 48:
|
||||
text = text[:48].rstrip('-')
|
||||
return 'batch-{0:02d}{1}.log'.format(int(index),
|
||||
'-' + text if text else '')
|
||||
|
||||
|
||||
def outstanding_cycle(rows):
|
||||
"""A CYCLE_START with no terminal line after it, or None.
|
||||
|
||||
The cycle's version of `outstanding()`, and it matters more: an
|
||||
unfinished phase C left a copy unwritten, while an unfinished cycle can
|
||||
have left a Revit open on a 300 MB model with nobody in front of it.
|
||||
Matched on the `run` id, same as `outstanding()`.
|
||||
"""
|
||||
closed = set()
|
||||
for row in (rows or ()):
|
||||
if row.get('event') in CYCLE_TERMINAL and row.get('run'):
|
||||
closed.add(row.get('run'))
|
||||
pending = None
|
||||
for i, row in enumerate(rows or ()):
|
||||
if row.get('event') != CYCLE_START:
|
||||
continue
|
||||
run = row.get('run')
|
||||
if run:
|
||||
if run not in closed:
|
||||
pending = row
|
||||
continue
|
||||
later = [r for r in (rows or ())[i + 1:]
|
||||
if r.get('event') in CYCLE_TERMINAL]
|
||||
if not later:
|
||||
pending = row
|
||||
return pending
|
||||
|
||||
|
||||
def cycle_summary(rows):
|
||||
"""What the last cycle did, as plain sentences. Newest fact first.
|
||||
|
||||
Kept separate from `summary()` rather than folded into it: the button
|
||||
prints the phase ledger and the pipeline prints this, and a run that
|
||||
reported nothing has to be as loud in one as in the other.
|
||||
"""
|
||||
lines = []
|
||||
rows = list(rows or ())
|
||||
if not rows:
|
||||
return ['no snapshot cycle has been recorded for this job yet']
|
||||
pending = outstanding_cycle(rows)
|
||||
if pending is not None:
|
||||
lines.append(
|
||||
'WARNING: a snapshot cycle started {0} and never reported back. '
|
||||
'A sandbox Revit (pid {1}) may still be open on this machine.'
|
||||
.format(pending.get('at') or 'at an unknown time',
|
||||
pending.get('sandbox_pid') or 'unknown'))
|
||||
done = last(rows, CYCLE_DONE)
|
||||
if done is not None:
|
||||
lines.append('last cycle: {0}, {1} batch command(s), {2} s'
|
||||
.format(done.get('at') or '?',
|
||||
done.get('batch_count')
|
||||
if done.get('batch_count') is not None else '?',
|
||||
done.get('seconds')
|
||||
if done.get('seconds') is not None else '?'))
|
||||
failed = last(rows, CYCLE_FAILED)
|
||||
if failed is not None and (done is None or
|
||||
(failed.get('at') or '') > (done.get('at') or '')):
|
||||
lines.append('last cycle FAILED at {0}: {1}'
|
||||
.format(failed.get('at') or '?',
|
||||
failed.get('error') or 'no reason recorded'))
|
||||
opened = last(rows, SANDBOX_OPENED)
|
||||
if opened is not None:
|
||||
lines.append('last sandbox opened: {0} (pid {1}) on {2}'
|
||||
.format(opened.get('at') or '?',
|
||||
opened.get('pid') or '?',
|
||||
opened.get('path') or '?'))
|
||||
return lines
|
||||
@@ -0,0 +1,526 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The shape of a design spec, and how it reads and writes as text.
|
||||
|
||||
Nothing in this module touches Revit, a document, or the filesystem -- it is
|
||||
pure data in, text or data out. That is deliberate: it is the part of the
|
||||
design-spec pipeline that can be exercised by an ordinary CPython test with no
|
||||
licence, no model and no running Revit, the same way `panellift` is the pure
|
||||
side of the handling-check boundary (see CLAUDE.md). `designspec.py` is the
|
||||
Revit side of THIS boundary -- it gathers evidence from the model and reads
|
||||
and writes these shapes to disk. Anything about what a spec MEANS, or how it
|
||||
prints, belongs here; anything about reading THIS model belongs there.
|
||||
|
||||
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib, no
|
||||
dataclasses.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from vibe import yamlkit
|
||||
from vibe.compat import string_types
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------- spec shape
|
||||
#
|
||||
# The contract every downstream check relies on. Anything the reader is
|
||||
# unsure about belongs in 'unresolved' rather than being guessed at.
|
||||
#
|
||||
# reinforcing.strand_layout is a MATRIX, not four parallel lists: columns_in
|
||||
# is where the strands sit across the piece (cumulative distance from datum,
|
||||
# same numbers a detailer would read off the dimension chain), and rows is
|
||||
# one entry per layer through the thickness, each carrying which of those
|
||||
# columns actually holds a strand at that height. A design where every column
|
||||
# has a strand at every row -- the ordinary case -- just has 'strands' filled
|
||||
# solid; a design that is not a full grid says so directly, column by column,
|
||||
# rather than forcing a human to reverse-engineer it from a total count.
|
||||
SPEC_TEMPLATE = {
|
||||
'schema': SCHEMA_VERSION,
|
||||
'design_number': None,
|
||||
'piece_description': None,
|
||||
'source': {'view': None, 'view_id': None, 'sheet': None,
|
||||
'model': None, 'extracted': None, 'extractor': None,
|
||||
'reviewed_by': None, 'reviewed_on': None},
|
||||
'concrete': {'fc_psi': None, 'fci_psi': None,
|
||||
'unit_weight_pcf': None, 'weight_class': None},
|
||||
'reinforcing': {
|
||||
'mild_grade_ksi': None,
|
||||
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
|
||||
'strand_groups': [], # {qty, dia_in, grade_ksi, it_lb, role, note}
|
||||
'strand_layout': {
|
||||
'datum': None, # 'left_rail' | 'right_rail' | 'centerline'
|
||||
'columns_in': [], # strand positions across the piece,
|
||||
# cumulative distance from the datum
|
||||
'rows': [], # [{'from_bottom_in':, 'strands': [...]}]
|
||||
# one entry per layer through the
|
||||
# thickness; 'strands' marks which column
|
||||
# holds a strand at that height
|
||||
'total_in': None, # the chain total; should equal the piece
|
||||
# width
|
||||
'note': None,
|
||||
},
|
||||
},
|
||||
'envelope': {'length': {'min_ft': None, 'max_ft': None,
|
||||
'min_text': None, 'max_text': None},
|
||||
'width': {'min_ft': None, 'max_ft': None,
|
||||
'min_text': None, 'max_text': None}},
|
||||
'thickness_in': None,
|
||||
'notes': [], # instructions that stay prose
|
||||
'unresolved': [], # [{text, reason}] nothing would guess at
|
||||
'confidence': {}, # field path -> high | medium | low
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_PATHS = (
|
||||
'design_number',
|
||||
'concrete.fc_psi',
|
||||
'concrete.fci_psi',
|
||||
)
|
||||
|
||||
|
||||
def get_path(spec, dotted, default=None):
|
||||
node = spec
|
||||
for part in dotted.split('.'):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return default
|
||||
node = node[part]
|
||||
return node if node is not None else default
|
||||
|
||||
|
||||
def validate_spec(spec):
|
||||
"""Cheap sanity check. Returns a list of complaints, empty when happy."""
|
||||
problems = []
|
||||
if not isinstance(spec, dict):
|
||||
return ['spec is not an object']
|
||||
if spec.get('schema') != SCHEMA_VERSION:
|
||||
problems.append('schema is {0}, expected {1}'
|
||||
.format(spec.get('schema'), SCHEMA_VERSION))
|
||||
for path in REQUIRED_PATHS:
|
||||
if get_path(spec, path) is None:
|
||||
problems.append('missing ' + path)
|
||||
|
||||
for i, group in enumerate(get_path(spec, 'reinforcing.strand_groups', []) or []):
|
||||
for key in ('qty', 'dia_in'):
|
||||
if group.get(key) is None:
|
||||
problems.append('strand_groups[{0}] missing {1}'.format(i, key))
|
||||
if group.get('qty') is not None and group['qty'] <= 0:
|
||||
problems.append('strand_groups[{0}] qty is {1}'.format(i, group['qty']))
|
||||
|
||||
fc = get_path(spec, 'concrete.fc_psi')
|
||||
fci = get_path(spec, 'concrete.fci_psi')
|
||||
if fc and fci and fci > fc:
|
||||
problems.append("f'ci ({0}) is higher than f'c ({1})".format(fci, fc))
|
||||
for field, lo, hi in (('concrete.fc_psi', 2000, 15000),
|
||||
('concrete.fci_psi', 1500, 12000),
|
||||
('concrete.unit_weight_pcf', 90, 200)):
|
||||
val = get_path(spec, field)
|
||||
if val is not None and not (lo <= val <= hi):
|
||||
problems.append('{0} = {1} is outside {2}..{3}'.format(field, val, lo, hi))
|
||||
return problems
|
||||
|
||||
|
||||
# ---------------------------------------------------------- design numbers
|
||||
#
|
||||
# THE ONE PLACE TWO DESIGN NUMBERS ARE COMPARED. It used to live in
|
||||
# `designspec.py`, which imports `Autodesk.Revit.DB` at module scope, so the
|
||||
# only way to ask "are these the same design?" was through a module that needs
|
||||
# a licence. `reinforcebind` has to ask it -- both in Revit and in
|
||||
# `tools/reinforce_offline.py`, which runs with no Revit at all -- so the
|
||||
# function moved down here, where nothing imports Revit, and `designspec`
|
||||
# re-exports it. Same rule, same name, one implementation: a second spelling
|
||||
# of this question is how two answers start.
|
||||
|
||||
# Used only when nomenclature is unmapped. Letters plus the first number is
|
||||
# the commonest convention, but it is a fallback, not a rule -- the plant's
|
||||
# knowledge file is where the real pattern belongs.
|
||||
FALLBACK_DESIGN_RE = r'^([A-Za-z]+)\s*(\d+)'
|
||||
|
||||
|
||||
def _design_pattern(know, pattern=None):
|
||||
"""The plant's design-number pattern, or the fallback.
|
||||
|
||||
`pattern` wins outright when it is given: it is how a caller that already
|
||||
resolved the key -- `reinforcebind.DonorFilter`, which must stay free of
|
||||
any knowledge import -- hands the same rule in without re-reading it.
|
||||
|
||||
Imported late: `knowledge` reaches for `designspec`, which imports this
|
||||
module, so a module-level import would be circular.
|
||||
"""
|
||||
if pattern:
|
||||
return pattern
|
||||
if know is None:
|
||||
return FALLBACK_DESIGN_RE
|
||||
from vibe import knowledge as K
|
||||
return K.get(know, 'nomenclature.design_number_re', FALLBACK_DESIGN_RE)
|
||||
|
||||
|
||||
def design_key(value, know=None, pattern=None):
|
||||
"""Reduce anything that names a design to the key everything matches on.
|
||||
|
||||
A design packet's cover sheet is numbered with a page suffix, so the same
|
||||
design is written PNL1 on a piece and PNL1.0 on its summary sheet, with
|
||||
PNL1.A and PNL1.B1 further into the packet. Comparing those raw splits one
|
||||
design into several that each fail to find the others -- so every design
|
||||
number, wherever it comes from, passes through here first.
|
||||
|
||||
Returns None for anything the pattern does not recognise, which keeps
|
||||
junk out of the picker rather than inventing a design for it. None is
|
||||
COULD NOT READ THIS, never "no design": a caller that treats it as a
|
||||
value will happily match two pieces whose design numbers nobody parsed.
|
||||
"""
|
||||
text = (value or '')
|
||||
try:
|
||||
text = text.strip()
|
||||
except AttributeError:
|
||||
return None
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
m = re.match(_design_pattern(know, pattern), text)
|
||||
except Exception: # pylint: disable=W0703
|
||||
m = re.match(FALLBACK_DESIGN_RE, text)
|
||||
if not m:
|
||||
return None
|
||||
groups = [g for g in m.groups() if g] or [m.group(0)]
|
||||
return ''.join([g.strip() for g in groups]).upper()
|
||||
|
||||
|
||||
def new_spec(design, evidence=None):
|
||||
"""A blank spec pre-filled with provenance."""
|
||||
spec = json.loads(json.dumps(SPEC_TEMPLATE)) # deep copy
|
||||
spec['design_number'] = design
|
||||
if evidence:
|
||||
views = evidence.get('summary_views') or []
|
||||
sheets = evidence.get('sheets') or []
|
||||
spec['source']['view'] = views[0]['name'] if views else None
|
||||
spec['source']['view_id'] = views[0]['id'] if views else None
|
||||
spec['source']['sheet'] = ('{0} - {1}'.format(sheets[0]['number'],
|
||||
sheets[0]['name'])
|
||||
if sheets else None)
|
||||
spec['source']['model'] = evidence.get('model')
|
||||
spec['source']['extracted'] = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
|
||||
return spec
|
||||
|
||||
|
||||
# An extractor that renames a field breaks every check that reads it, silently
|
||||
# and in the safe-looking direction: the value reads as None and the check
|
||||
# just does not run. Known synonyms are mapped back onto the canonical names
|
||||
# on load, and validate_spec still complains about anything left missing.
|
||||
_ALIASES = {
|
||||
'qty': ('count', 'quantity', 'number', 'n'),
|
||||
'dia_in': ('diameter_in', 'dia', 'diameter'),
|
||||
'grade_ksi': ('grade', 'grade_k', 'ksi', 'strength_ksi'),
|
||||
'it_lb': ('it_lbs', 'initial_tension_lb', 'it', 'tension_lb',
|
||||
'initial_tension', 'it_lb_force'),
|
||||
}
|
||||
|
||||
|
||||
def _first_number(value):
|
||||
"""270, '270K', '270 ksi' -> 270.0."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
m = re.search(r'[\d.]+', str(value).replace(',', ''))
|
||||
return float(m.group(0)) if m else None
|
||||
|
||||
|
||||
def normalize_spec(spec):
|
||||
"""Map known field synonyms back onto the canonical names, in place."""
|
||||
if not isinstance(spec, dict):
|
||||
return spec
|
||||
groups = get_path(spec, 'reinforcing.strand_groups', []) or []
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
for canonical, synonyms in _ALIASES.items():
|
||||
if group.get(canonical) is None:
|
||||
for alt in synonyms:
|
||||
if group.get(alt) is not None:
|
||||
group[canonical] = group[alt]
|
||||
break
|
||||
for numeric in ('qty', 'dia_in', 'grade_ksi', 'it_lb'):
|
||||
if group.get(numeric) is not None:
|
||||
group[numeric] = _first_number(group[numeric])
|
||||
if group.get('qty') is not None:
|
||||
group['qty'] = int(group['qty'])
|
||||
|
||||
concrete = spec.get('concrete')
|
||||
if isinstance(concrete, dict):
|
||||
for canonical, alt in (('fc_psi', 'fc'), ('fci_psi', 'fci'),
|
||||
('unit_weight_pcf', 'pcf')):
|
||||
if concrete.get(canonical) is None and concrete.get(alt) is not None:
|
||||
concrete[canonical] = concrete[alt]
|
||||
if concrete.get(canonical) is not None:
|
||||
concrete[canonical] = _first_number(concrete[canonical])
|
||||
return spec
|
||||
|
||||
|
||||
# --------------------------------------------------------- the strand matrix
|
||||
|
||||
_BLANK_MARKS = ('', 'null', 'none', '.', '-', '0')
|
||||
|
||||
|
||||
def strand_positions(layout):
|
||||
"""Cumulative distances from the datum, whichever spec shape wrote them.
|
||||
|
||||
New specs carry 'columns_in'; a spec written before the matrix shape
|
||||
carries the same numbers under 'positions_in'. Either way this is what
|
||||
check_strand_layout compares against what was actually modelled.
|
||||
"""
|
||||
if not isinstance(layout, dict):
|
||||
return []
|
||||
cols = layout.get('columns_in')
|
||||
if cols is None:
|
||||
cols = layout.get('positions_in')
|
||||
return [c for c in (cols or []) if c is not None]
|
||||
|
||||
|
||||
def strand_row_marks(row, n_columns):
|
||||
"""Which of n_columns holds a strand in this row.
|
||||
|
||||
A row from a spec written before the matrix shape has no 'strands' list
|
||||
at all -- it only ever said WHERE the row sits, not which columns it
|
||||
fills -- so it is read as a full row, which is what every such spec
|
||||
actually was.
|
||||
"""
|
||||
marks = row.get('strands') if isinstance(row, dict) else None
|
||||
if marks is None:
|
||||
return [True] * n_columns
|
||||
out = []
|
||||
for i in range(n_columns):
|
||||
value = marks[i] if i < len(marks) else None
|
||||
out.append(value is not None and
|
||||
str(value).strip().lower() not in _BLANK_MARKS)
|
||||
return out
|
||||
|
||||
|
||||
def _fmt_in(value):
|
||||
if value is None:
|
||||
return '?'
|
||||
text = '{0:.3f}'.format(float(value))
|
||||
text = text.rstrip('0').rstrip('.')
|
||||
return text if text else '0'
|
||||
|
||||
|
||||
_DIAGRAM_WIDTH = 68 # target character width of the scaled ruler
|
||||
|
||||
|
||||
def strand_diagram(layout):
|
||||
"""An ASCII elevation of the strand matrix, drawn from the data itself.
|
||||
|
||||
Datum on the left, the chain's segment lengths along a scaled ruler, and
|
||||
one row of marks per strand row at its height -- the same three things a
|
||||
detailer reads off the dimension chain on the drawing, in the same left-
|
||||
to-right order, so the two can be held side by side and compared. There
|
||||
is nowhere for the picture to get a number the data does not have: every
|
||||
character position is computed from columns_in / total_in / rows.
|
||||
"""
|
||||
if not isinstance(layout, dict):
|
||||
return ['(no strand layout)']
|
||||
columns = strand_positions(layout)
|
||||
total = layout.get('total_in')
|
||||
rows = [r for r in (layout.get('rows') or []) if isinstance(r, dict)
|
||||
and r.get('from_bottom_in') is not None]
|
||||
datum = layout.get('datum') or '(datum not set)'
|
||||
|
||||
if not columns or not total:
|
||||
return ['datum: {0}'.format(datum),
|
||||
'(not enough data to draw yet -- fill in columns_in, '
|
||||
'total_in and rows)']
|
||||
|
||||
scale = min(1.0, float(_DIAGRAM_WIDTH) / float(total))
|
||||
|
||||
def col(pos):
|
||||
return int(round(pos * scale))
|
||||
|
||||
width = col(total) + 1
|
||||
ticks = [0] + [col(c) for c in columns] + [col(total)]
|
||||
lengths = []
|
||||
prev = 0.0
|
||||
for c in columns:
|
||||
lengths.append(c - prev)
|
||||
prev = c
|
||||
lengths.append(total - prev)
|
||||
|
||||
ruler = [u'-'] * width
|
||||
for t in ticks:
|
||||
ruler[t] = u'+'
|
||||
for i in range(len(ticks) - 1):
|
||||
a, b = ticks[i], ticks[i + 1]
|
||||
label = _fmt_in(lengths[i])
|
||||
gap = b - a - 1
|
||||
if gap >= len(label):
|
||||
start = a + 1 + (gap - len(label)) // 2
|
||||
for j, ch in enumerate(label):
|
||||
ruler[start + j] = ch
|
||||
|
||||
lines = [u'datum: {0} (rail on the left, {1}" total)'.format(datum,
|
||||
_fmt_in(total)),
|
||||
u''.join(ruler)]
|
||||
|
||||
for r in sorted(rows, key=lambda row: -row['from_bottom_in']):
|
||||
marks = strand_row_marks(r, len(columns))
|
||||
line = [u' '] * width
|
||||
for pos, has in zip([col(c) for c in columns], marks):
|
||||
line[pos] = u'X' if has else u'.'
|
||||
n = sum(1 for m in marks if m)
|
||||
lines.append(u'{0} {1}" from bottom ({2} strand{3})'.format(
|
||||
u''.join(line), _fmt_in(r['from_bottom_in']), n,
|
||||
u'' if n == 1 else u's'))
|
||||
|
||||
lines.append(u'columns (in, from datum): ' +
|
||||
u', '.join(_fmt_in(c) for c in columns))
|
||||
return lines
|
||||
|
||||
|
||||
# --------------------------------------------------------- human-readable IO
|
||||
|
||||
SPEC_HEADER = u"""# WillTools design spec -- {design}
|
||||
#
|
||||
# A HAND-CORRECTABLE reading of the design summary sheet, not a certified
|
||||
# transcription. Every value below was read by machine (see source.extractor)
|
||||
# and NONE of it has been checked by a person unless source.reviewed_by is
|
||||
# set. Ticket QC and Panel Lift Check read this file, not the drawing --
|
||||
# compare the two before trusting either one.
|
||||
#
|
||||
# Edit it by hand where the machine got it wrong. It is read back exactly as
|
||||
# written: comments and blank lines are ignored, nothing here is re-derived
|
||||
# from anything else in the file.
|
||||
"""
|
||||
|
||||
_SOURCE_NOTE = u'''# Where this reading came from, and whether anyone has checked it since.
|
||||
# extractor says HOW it was read: 'regex' is the mechanical text/geometry
|
||||
# reader -- no judgement applied, every value either matched a rule or is
|
||||
# null. Nothing branches on the value; it is recorded so a spec written by
|
||||
# one reader is not mistaken for a spec written by another.
|
||||
# reviewed_by / reviewed_on are blank until a person sets them by hand after
|
||||
# checking this file against the drawing.'''
|
||||
|
||||
_CONCRETE_NOTE = u'''# f'c and f'ci feed the lift check's concrete-strength gates directly
|
||||
# (concrete.fc_psi, concrete.fci_psi) -- get one wrong and every handling
|
||||
# stage is checked against the wrong strength, in whichever direction makes
|
||||
# the piece look better than it is.'''
|
||||
|
||||
_MILD_NOTE = u'''# Mild reinforcing grade, where the design summary states one.'''
|
||||
|
||||
_WWF_NOTE = u'''# Welded wire fabric: the mesh designation as printed, how many layers, and
|
||||
# whether it is one layer per face or shared.'''
|
||||
|
||||
_STRAND_GROUPS_NOTE = u'''# One entry per strand group the design summary calls out separately --
|
||||
# shielded or de-tensioned strands get their own line. it_lb (initial
|
||||
# tension) feeds the lift check's prestress force directly
|
||||
# (reinforcing.strand_groups[].it_lb): a wrong qty or it_lb over- or
|
||||
# understates the precompression credited on every handling stage.'''
|
||||
|
||||
_ENVELOPE_NOTE = u'''# The min/max piece size this design is good for, read by pairing each
|
||||
# MAX./MIN. LENGTH/WIDTH label with the dimension text beside it on the
|
||||
# drawing. Checked against the piece actually modelled.'''
|
||||
|
||||
_UNRESOLVED_NOTE = u'''# Every text note on the design summary that no rule above could place,
|
||||
# verbatim, with why. This is not optional and it is not a bug list -- it is
|
||||
# the mechanism that keeps a line of the drawing from being silently dropped
|
||||
# just because nothing recognised it. A rule that does not match returns
|
||||
# nothing on its own; landing here is what makes that visible instead of
|
||||
# quiet. Read every line before trusting this spec, and fold anything real
|
||||
# into the fields above by hand.'''
|
||||
|
||||
_NOTES_NOTE = u'''# Instructions that stay prose rather than being encoded as a field --
|
||||
# stripping/blocking callouts, cut-down rules, anything that is an
|
||||
# instruction rather than a number.'''
|
||||
|
||||
_CONFIDENCE_NOTE = u'''# Per-field confidence, field path -> high | medium | low. Only ever set
|
||||
# where a reading made a judgement call -- and only for a field that same
|
||||
# reading actually just filled, never for one the mechanical reader had
|
||||
# already placed. The mechanical reader makes no judgement calls to rate, so
|
||||
# this stays empty on a 'regex' spec.'''
|
||||
|
||||
|
||||
def _strand_layout_note(layout):
|
||||
diagram = strand_diagram(layout if isinstance(layout, dict) else {})
|
||||
lines = [u'# Where the strands sit, as a matrix: columns_in is the position of',
|
||||
u'# each strand across the piece (cumulative distance from datum, the',
|
||||
u"# same numbers a detailer reads off the dimension chain); each row is",
|
||||
u'# one layer through the thickness, marking which columns it fills. A',
|
||||
u'# correct strand COUNT in the wrong PLACE is still a wrong piece, which',
|
||||
u'# is why this is a grid rather than a total.',
|
||||
u'#',
|
||||
u'# The picture below is drawn from the data beneath it and cannot drift',
|
||||
u'# from it -- if a number here looks wrong, the picture will look wrong',
|
||||
u'# the same way. Hold it next to the drawing.',
|
||||
u'#']
|
||||
for line in diagram:
|
||||
lines.append(u'# ' + line if line else u'#')
|
||||
return u'\n'.join(lines)
|
||||
|
||||
|
||||
_REINFORCING_ORDER = ('mild_grade_ksi', 'wwf', 'strand_groups', 'strand_layout')
|
||||
_REINFORCING_NOTES = {
|
||||
'mild_grade_ksi': _MILD_NOTE,
|
||||
'wwf': _WWF_NOTE,
|
||||
'strand_groups': _STRAND_GROUPS_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def _reinforcing_block(reinforcing):
|
||||
reinforcing = reinforcing or {}
|
||||
lines = [u'reinforcing:']
|
||||
for key in _REINFORCING_ORDER:
|
||||
if key not in reinforcing:
|
||||
continue
|
||||
lines.append(u'')
|
||||
if key == 'strand_layout':
|
||||
lines.append(_strand_layout_note(reinforcing.get('strand_layout')))
|
||||
else:
|
||||
for note_line in _REINFORCING_NOTES[key].split(u'\n'):
|
||||
lines.append(u' ' + note_line)
|
||||
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
||||
for key in reinforcing:
|
||||
if key not in _REINFORCING_ORDER:
|
||||
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
||||
return u'\n'.join(lines)
|
||||
|
||||
|
||||
_SECTION_ORDER = ('schema', 'design_number', 'piece_description', 'source',
|
||||
'concrete', 'reinforcing', 'envelope', 'thickness_in',
|
||||
'notes', 'unresolved', 'confidence')
|
||||
|
||||
_SECTION_NOTES = {
|
||||
'source': _SOURCE_NOTE,
|
||||
'concrete': _CONCRETE_NOTE,
|
||||
'envelope': _ENVELOPE_NOTE,
|
||||
'notes': _NOTES_NOTE,
|
||||
'unresolved': _UNRESOLVED_NOTE,
|
||||
'confidence': _CONFIDENCE_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def spec_text(spec):
|
||||
"""The whole spec, as hand-editable, commented YAML."""
|
||||
spec = spec or {}
|
||||
out = [SPEC_HEADER.format(design=spec.get('design_number') or '?')]
|
||||
for key in _SECTION_ORDER:
|
||||
if key not in spec:
|
||||
continue
|
||||
out.append(u'')
|
||||
if key == 'reinforcing':
|
||||
out.append(_reinforcing_block(spec.get('reinforcing')))
|
||||
continue
|
||||
note = _SECTION_NOTES.get(key)
|
||||
if note:
|
||||
out.append(note)
|
||||
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
||||
for key in spec:
|
||||
if key not in _SECTION_ORDER:
|
||||
out.append(u'')
|
||||
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
||||
return u'\n'.join(out) + u'\n'
|
||||
|
||||
|
||||
def parse_spec_text(text):
|
||||
"""The inverse of spec_text(): hand-edited YAML back to a spec dict."""
|
||||
return yamlkit.parse(text)
|
||||