commit 52d94303bcc061d236b6dccc339b946ad9941fc8 Author: will123song Date: Thu Sep 3 16:33:47 2026 -0500 WillTools: precast shop-ticket QC and panel handling tools 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. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8bcee48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,76 @@ +--- +name: Bug report +about: Something ran and gave a wrong or crashing answer +title: '' +labels: bug +assignees: '' +--- + + + +## What happened + + + +``` +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 +``` + +
+setup_machine.py --check output + +``` +paste it here +``` + +
+ +- Windows version: +- `python --version`: +- Revit version (if this involves a ribbon button): +- pyRevit version (if this involves a ribbon button): + +## Which button or command + + + +## 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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..95601e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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? diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..35ae68f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -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 + + + +## What was unclear about it + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2a0b89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.setup-opts.example b/.setup-opts.example new file mode 100644 index 0000000..e41459a --- /dev/null +++ b/.setup-opts.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 `: ` 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e6dea9e --- /dev/null +++ b/CONTRIBUTING.md @@ -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. + + + +`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. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..edbafce --- /dev/null +++ b/INSTALL.md @@ -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 | | +| **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 (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/.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**: + +``` +/_ticketqc/ + evidence/.json raw scrape, no interpretation + specs/.json the reviewable spec <-- read this + knowledge.yaml your hand-written rules + log/.txt what the extractor printed +``` + +If the model is not saved, or its folder is read-only, it falls back to +`%APPDATA%\pyrevit-ticketqc\\`. 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** | `/_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 `/_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." diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac2be01 --- /dev/null +++ b/README.md @@ -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. diff --git a/WillTools-Intro.pptx b/WillTools-Intro.pptx new file mode 100644 index 0000000..e690032 Binary files /dev/null and b/WillTools-Intro.pptx differ diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/bundle.yaml new file mode 100644 index 0000000..7ad34fb --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/bundle.yaml @@ -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. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/icon.png new file mode 100644 index 0000000..b173c8b Binary files /dev/null and b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/script.py new file mode 100644 index 0000000..5b5bb8a --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/script.py @@ -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.') diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/bundle.yaml new file mode 100644 index 0000000..4cc3228 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/bundle.yaml @@ -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. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/icon.png new file mode 100644 index 0000000..e405140 Binary files /dev/null and b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py new file mode 100644 index 0000000..0e1aca2 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py @@ -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.') diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml new file mode 100644 index 0000000..4c6c467 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml @@ -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. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png new file mode 100644 index 0000000..ca8c191 Binary files /dev/null and b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py new file mode 100644 index 0000000..fba2839 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py @@ -0,0 +1,1555 @@ +# pylint: disable=import-error,invalid-name,broad-except +__title__ = 'Panel Analysis (WIP)' + +# Panel Analysis -- Stage 1: an INPUT CONFIRMATION form, not a lifting check. +# +# Press with one or more structural-framing panels selected. Up pops a dialog +# with one COLLAPSED section per panel; each section replicates Eriksson Wall's +# own input blocks (Design Criteria / Concrete Extents / Structural Model / +# Handling / Reinforcement), PRE-FILLED with what the model could read off the +# framing element and BLANK where it could not. The user confirms/fills the +# fields and presses that panel's own "Run Analysis" button -- which stays +# refused until the panel's required fields (geometry, materials, and at least +# two support positions) are all filled. A bottom "Analyze All" runs every +# panel whose form is complete and names the ones it skipped and why. +# +# There is NO clash detection, NO OST_SpecialtyEquipment, NO lifters, NO +# families.lifter_include and NO liftexport.export here. The owner ruled Stage +# 1 is purely an input-confirmation form (2026-09-02); auto support detection +# is a later convenience. All the gate / pre-fill / deck-build logic lives in +# vibe.panelanalysisform, host-tested off Revit; this file is the WPF shell +# and the live geometry read, which are owner-must-verify. + +import io +import json +import os + +import clr + +from Autodesk.Revit.DB import (AssemblyInstance, BuiltInCategory, ElementId, + FilteredElementCollector) + +from pyrevit import revit, script, forms + +from vibe import designspec, knowledge, liftexport, ticketqc +from vibe import panelanalysisform as F +from vibe import panelsketch +from vibe.compat import as_text + +doc = revit.doc +uidoc = revit.uidoc +output = script.get_output() +output.close_others() + +TITLE = 'Panel Analysis' + + +def not_run(message, title=TITLE): + output.print_md('# {0} — NOT RUN'.format(TITLE)) + output.print_md('**NOT RUN** — no analysis was produced.') + output.print_md('> {0}'.format(message)) + script.exit() + + +# ---------------------------------------------------------- which pieces + +def _is_framing(el): + try: + cat = el.Category + return (cat is not None and + cat.Id.IntegerValue == int(BuiltInCategory.OST_StructuralFraming)) + except Exception: + try: + return cat.Id.Value == int(BuiltInCategory.OST_StructuralFraming) + except Exception: + return False + + +def _framing_of_assembly(asm): + """The first structural-framing member of an assembly, or None.""" + try: + for mid in asm.GetMemberIds(): + member = doc.GetElement(mid) + if member is not None and _is_framing(member): + return member + except Exception: + pass + return None + + +def _resolve_framing(): + """The framing panels to build a form for, from the current selection. + + A framing element is taken as itself; an assembly (or any member of one) + resolves to that assembly's framing member -- but an assembly is NOT + required, a bare framing element is enough. De-duplicated, order kept. + """ + selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()] + found = [] + seen = set() + + def add(el): + if el is None: + return + try: + key = el.Id.IntegerValue + except Exception: + key = id(el) + if key in seen: + return + seen.add(key) + found.append(el) + + for el in selection: + if el is None: + continue + if _is_framing(el): + add(el) + continue + if isinstance(el, AssemblyInstance): + add(_framing_of_assembly(el)) + continue + try: + owner = doc.GetElement(el.AssemblyInstanceId) + except Exception: + owner = None + if isinstance(owner, AssemblyInstance): + add(_framing_of_assembly(owner)) + return found + + +# ------------------------------------------------------- read off the model + +def _read_geometry(el): + """{'length_ft','width_ft','thickness_in'} off the framing element, or {}. + + Reuses the panel-frame + concrete-extents readers Panel Lift Check uses -- + the local frame makes 'along the length' mean what it says on a rotated + wall panel -- and the same length/width/thickness axis split. Orientation + needs no assembly origin: the supports the user types define the statics, + so only the extents matter here. + """ + try: + frame = ticketqc._panel_frame(el) + if frame is None: + return {} + extents = ticketqc.concrete_extents(el, frame) + if not extents: + return {} + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + L, W, T = liftexport._axes(lo, hi) + return { + 'length_ft': round((hi[L] - lo[L]) / 12.0, 4), + 'width_ft': round((hi[W] - lo[W]) / 12.0, 4), + 'thickness_in': round(hi[T] - lo[T], 3), + } + except Exception: + return {} + + +def _param_sources(el): + """The element, then the TYPE it is an instance of. + + Some roles name a TYPE parameter, not an instance one -- the plant's + thickness role is mapped to a type parameter and says so in its own + comment. `LookupParameter` on the instance simply does not see those, so + reading only the instance left `thickness_in` blank on every piece while + the value sat on the symbol the whole time (measured live 2026-09-02 on + a real 8" horizontal wall: the role resolved to its parameter name and + the instance read `None`). + + Instance FIRST: where a value exists in both places the instance is the + one that was set for this piece, and a type value is the fallback the + piece inherited. + """ + out = [el] + try: + type_el = el.Document.GetElement(el.GetTypeId()) + if type_el is not None: + out.append(type_el) + except Exception: + pass + return out + + +def _num_from_param(el, name): + for owner in _param_sources(el): + value = _num_from_one(owner, name) + if value is not None: + return value + return None + + +def _num_from_one(el, name): + try: + p = el.LookupParameter(name) + except Exception: + p = None + if p is None: + return None + for reader in ('AsValueString', 'AsString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt: + head = '' + for ch in str(txt).replace(',', ''): + if ch.isdigit() or ch in '.-': + head += ch + elif head: + break + try: + return float(head) + except Exception: + continue + return None + + +def _str_from_param(el, name): + """One parameter's value as text, searching the instance then its type.""" + for owner in _param_sources(el): + value = _str_from_one(owner, name) + if value is not None: + return value + return None + + +def _str_from_one(el, name): + """`AsString` first: on a text parameter `AsValueString` can return the + formatted display of an empty value rather than nothing at all, and a + blank that looks like a name is worse than no name.""" + try: + p = el.LookupParameter(name) + except Exception: + p = None + if p is None: + return None + for reader in ('AsString', 'AsValueString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt and str(txt).strip(): + return str(txt).strip() + return None + + +def _role_str(el, know, role): + """The first non-empty value among the parameter names a plant maps to + `role`. Empty when the plant mapped none -- unmapped is not an error + here, it is the field staying blank for the user to fill.""" + if know is None: + return None + for name in knowledge.param_names(know, role): + value = _str_from_param(el, name) + if value: + return value + return None + + +def _read_materials(el, know): + """Best-effort f'c / f'ci / unit weight / mark off parameters, else blank. + + Material strengths are usually a per-design/sheet fact rather than a panel + parameter, so this reads whatever role the plant mapped and otherwise + leaves the field blank for the user -- the owner's rule, not an invented + default. + """ + out = {} + if know is not None: + for key, roles in (('fc_psi', ('sheet.fc',)), + ('fci_psi', ('sheet.fci',)), + ('unit_weight_pcf', + ('panel.unit_weight', 'sheet.unit_weight'))): + for role in roles: + for name in knowledge.param_names(know, role): + v = _num_from_param(el, name) + if v is not None: + out[key] = v + break + if key in out: + break + for name in knowledge.param_names(know, 'panel.design_number') + \ + knowledge.param_names(know, 'sheet.design_number'): + try: + dn = el.LookupParameter(name) + dn = dn.AsString() if dn is not None else None + except Exception: + dn = None + if dn: + out['design_number'] = dn + break + # The piece mark, through the same role the rest of the tool reads it by + # (`ticketqc._member_mark`), NOT the built-in `Mark` alone. On this + # plant's template the built-in is empty and the mark lives in the + # parameter the plant maps to `member.mark` -- read only the built-in and + # every MC panel comes up nameless, which is what put `panel 1 (id + # 123456)` in the form's header where `MWH116` belonged. + mark = _role_str(el, know, 'member.mark') or _str_from_param(el, 'Mark') + if mark: + out['mark'] = mark + + # A CONTROL NUMBER is the second name a piece has: the mark says which + # design it is, the control number says which physical piece. A plant + # that records one maps `member.control_number`; one that does not gets + # no parenthetical rather than a guessed one. Suppressed when it is the + # same string as the mark -- a plant whose mark parameter IS its control + # number (this one maps `member.mark` to the plant's mark parameter) should see the + # name once, not printed twice with a bracket round the copy. + control = _role_str(el, know, 'member.control_number') + if control and control != mark: + out['control_number'] = control + return out + + +def _reads_for(el, index, know): + reads = {} + reads.update(_read_geometry(el)) + reads.update(_read_materials(el, know)) + if not reads.get('mark'): + # Nothing named this piece. An element id is a poor name but it is a + # TRUE one, and it is the only handle that still points at the right + # piece when two panels are equally nameless -- so it stays as the + # last resort, and the header says outright that no mark was read + # rather than letting a number look like a mark. + try: + reads['mark'] = 'unnamed panel {0} (element id {1})'.format( + index + 1, el.Id.IntegerValue) + except Exception: + reads['mark'] = 'unnamed panel {0}'.format(index + 1) + reads.setdefault('section_type', 'Solid') + return reads + + +def panel_title(reads): + """The line that heads one panel's section: `MARK (CONTROL)`. + + Kept apart from `reads['mark']`, which names the run's output files and + must stay a single stable token. + """ + mark = reads.get('mark') or '' + control = reads.get('control_number') + if mark and control: + return u'{0} ({1})'.format(mark, control) + return mark + + +# ---------------------------------------------------------------- running + +REPO_ROOT = os.path.dirname(os.path.dirname(liftexport.panellift_src())) +PANELANALYSIS_PY = os.path.join(REPO_ROOT, 'tools', 'panelanalysis.py') + + +def _runner_script(exe, script_path, job, out, log): + return (u'@echo off\r\n' + u'chcp 65001 >nul\r\n' + u'set "PYTHONIOENCODING=utf-8"\r\n' + u'"{exe}" "{script}" "{job}" -o "{out}" > "{log}" 2>&1\r\n' + u'exit /b %ERRORLEVEL%\r\n').format( + exe=exe, script=script_path, job=job, out=out, log=log) + + +def run_panelanalysis(exe, script_path, job, out, timeout_ms=120000): + """(exit_code, log_text). One-line cmd wrapper redirecting to a file -- + reading one pipe while the child blocks on the other is a deadlock, not a + slow run (see liftexport.run()'s docstring for the incident).""" + from System.Diagnostics import Process, ProcessStartInfo + + base = os.path.splitext(out)[0] + log_path = base + '.log' + runner = base + '.run.cmd' + with io.open(runner, 'w', encoding='utf-8') as fh: + fh.write(_runner_script(exe, script_path, job, out, log_path)) + + info = ProcessStartInfo() + info.FileName = 'cmd.exe' + info.Arguments = '/c "{0}"'.format(runner) + info.UseShellExecute = False + info.CreateNoWindow = True + + process = Process.Start(info) + waited = 0 + while not process.WaitForExit(250): + waited += 250 + if waited >= timeout_ms: + try: + process.Kill() + except Exception: + pass + return -1, ('panel analysis did not finish in {0} seconds' + .format(timeout_ms // 1000)) + + def _read(path): + try: + with io.open(path, 'r', encoding='utf-8', errors='replace') as fh: + return fh.read() + except Exception: + return '' + + log_text = _read(log_path) + for path in (runner, log_path): + try: + os.remove(path) + except Exception: + pass + return process.ExitCode, log_text + + +def _safe_name(mark): + out = '' + for ch in str(mark): + out += ch if (ch.isalnum() or ch in '-_.') else '_' + return out or 'panel' + + +def _run_one(mark, deck): + """(ok, message). Builds the deck file, solves it, opens the page.""" + python_exe = liftexport.find_python(KNOW) + if not python_exe: + return False, ('CPython 3.9+ was not found. The page renders outside ' + 'Revit; install Python or set handling.python_exe in ' + 'the knowledge file.') + if not os.path.isfile(PANELANALYSIS_PY): + return False, 'tools/panelanalysis.py was not found at {0}.'.format( + PANELANALYSIS_PY) + + start_dir = designspec.sub_dir(doc, 'panelanalysis') + stem = _safe_name(mark) + job_path = os.path.join(start_dir, '{0}.pa.json'.format(stem)) + out_path = os.path.join(start_dir, '{0}.panelanalysis.html'.format(stem)) + blob = json.dumps(deck, indent=1, sort_keys=True) + with io.open(job_path, 'w', encoding='utf-8') as fh: + fh.write(as_text(blob)) + + code, log_text = run_panelanalysis(python_exe, PANELANALYSIS_PY, job_path, + out_path) + if code != 0 or not os.path.isfile(out_path): + return False, ('the page could not be rendered (exit {0}).\n\n{1}\n\n' + 'Deck kept at:\n{2}'.format(code, log_text or 'no ' + 'output', job_path)) + liftexport.open_file(out_path) + return True, out_path + + +# ------------------------------------------------------------------- WPF + +clr.AddReference('PresentationFramework') +clr.AddReference('PresentationCore') +clr.AddReference('WindowsBase') + +from System.Windows import (Window, Thickness, FontWeights, FontStyles, + TextWrapping, TextDecorations, + SizeToContent, WindowStartupLocation, + HorizontalAlignment, VerticalAlignment) +from System.Windows.Controls import (StackPanel, ScrollViewer, Expander, + TextBox, TextBlock, Button, Border, + Orientation, ScrollBarVisibility, Canvas, + Grid, ColumnDefinition, RowDefinition, + TabControl, TabItem, CheckBox, ComboBox) +from System.Windows.Controls import Label as WpfLabel +from System.Windows.Documents import Run +from System.Windows.Media import Brushes, RotateTransform +from System.Windows.Shapes import Line, Polygon, Rectangle, Ellipse +from System.Windows import Point, BaselineAlignment, GridLength, GridUnitType +from System.Windows.Media import PointCollection + + +def _label_block(text, bold=False, size=12, brush=None): + tb = TextBlock() + tb.Text = text + tb.TextWrapping = TextWrapping.Wrap + tb.FontSize = size + if bold: + tb.FontWeight = FontWeights.Bold + if brush is not None: + tb.Foreground = brush + tb.Margin = Thickness(0, 2, 0, 2) + return tb + + +# A prime is the only thing in these labels that a subscript follows, so the +# rule is narrow enough to be safe: f′c, f′ci. Everything else that needs a +# subscript (Aₛ, Cₛ, fₚᵤ) carries a real Unicode one in the label itself. +_PRIME = u'′' + + +def _math_block(text, bold=False, size=12, brush=None): + """A label whose primed symbols render as real notation. + + `f′ci` becomes an f, a prime, and a SUBSCRIPT ci -- three inline Runs, not + a string that hopes the reader knows what `sqrt f'ci` meant. WPF does the + baseline shift; nothing here draws glyphs. + """ + tb = TextBlock() + tb.TextWrapping = TextWrapping.Wrap + tb.FontSize = size + if bold: + tb.FontWeight = FontWeights.Bold + if brush is not None: + tb.Foreground = brush + tb.Margin = Thickness(0, 2, 0, 2) + + if _PRIME not in text: + tb.Text = text + return tb + + i = 0 + while i < len(text): + j = text.find(_PRIME, i) + if j < 0: + tb.Inlines.Add(Run(text[i:])) + break + tb.Inlines.Add(Run(text[i:j + 1])) # up to and incl. the prime + k = j + 1 + while k < len(text) and text[k].isalpha() and text[k].islower(): + k += 1 + if k > j + 1: + sub = Run(text[j + 1:k]) + sub.BaselineAlignment = BaselineAlignment.Subscript + sub.FontSize = max(7, size - 3) + tb.Inlines.Add(sub) + i = k + return tb + + +# ------------------------------------------------------------- the drawing +# +# `vibe.panelsketch` is Revit-free and host-tested; every entry point in it +# (`sketch`, `elevation`, `plan`, `section`, `weight_diagram`, and whatever a +# sibling agent adds after this lands) returns the SAME flat primitive +# vocabulary -- rect / line / support / text / marker / swatch / dot, plain +# dicts, pixel coordinates. This is the only place that vocabulary gets +# turned into WPF shapes, so a new `source` needs no new code here as long +# as it stays inside that vocabulary; a `kind` this dict has never seen is +# simply skipped (the drawing degrades one primitive short, not to a crash -- +# unlike a block `kind`, an unrecognised drawing PRIMITIVE has no owner- +# visible obligation of its own, and `problems` already carries the sentence +# that explains what is missing). + +SKETCH_W = 380 +SKETCH_H = 190 + +_ROLE_BRUSH = { + 'panel': Brushes.SteelBlue, 'panel-plan': Brushes.SteelBlue, + 'panel-side': Brushes.SteelBlue, 'panel-section': Brushes.SteelBlue, + 'panel-crosssection': Brushes.SteelBlue, + 'panel-elevation-thin': Brushes.SteelBlue, + 'support': Brushes.Firebrick, 'support-label': Brushes.Firebrick, + 'support-fraction': Brushes.Firebrick, + 'dimension': Brushes.DimGray, 'annotation': Brushes.DimGray, + 'openings-note': Brushes.DarkGoldenrod, 'note': Brushes.DarkGoldenrod, + 'lifters-note': Brushes.DarkGoldenrod, + 'title': Brushes.Black, 'caption': Brushes.Black, + 'opening': Brushes.DarkGoldenrod, 'opening-label': Brushes.DarkGoldenrod, + 'reveal': Brushes.DarkOrange, 'reveal-label': Brushes.DarkOrange, + 'solid-zone': Brushes.DimGray, 'solid-zone-label': Brushes.DimGray, + 'section-property': Brushes.Black, + 'legend-heading': Brushes.Black, 'legend-glyph': Brushes.DimGray, + 'legend-fill-label': Brushes.DimGray, + 'strand': Brushes.SteelBlue, 'strand-label': Brushes.Black, + 'strand-line': Brushes.SteelBlue, + 'centroid': Brushes.Firebrick, 'cg': Brushes.Firebrick, + 'cg-lead': Brushes.Firebrick, + 'moment-arm': Brushes.DarkGoldenrod, + 'moment-arm-label': Brushes.DarkGoldenrod, + 'beam': Brushes.Black, +} + +_FILL_BRUSH = { + 'Not Specified': Brushes.Gainsboro, + 'Stripping': Brushes.LightSkyBlue, + 'Erection': Brushes.LightGreen, + 'Stripping + Erection': Brushes.Khaki, + 'Transportation': Brushes.LightSalmon, +} + + +def _role_brush(role): + return _ROLE_BRUSH.get(role, Brushes.DimGray) + + +def _sketch_text(prim): + tb = TextBlock() + tb.Text = prim.get('text', '') + tb.FontSize = 10 + tb.Foreground = _role_brush(prim.get('role')) + x = prim.get('x', 0) + if prim.get('anchor') == 'middle': + # Estimated, not measured: a Measure() pass here would need a layout + # that does not exist yet at build time, and half a character of drift + # on a label is invisible where a crash is not. + x -= len(tb.Text) * 2.6 + if prim.get('orientation') == 'vertical': + tb.RenderTransform = RotateTransform(-90) + Canvas.SetLeft(tb, x) + Canvas.SetTop(tb, prim.get('y', 0)) + return tb + + +def _draw_primitive(canvas, prim): + """One `panelsketch` primitive -> WPF shape, appended onto `canvas`. + + A `kind` this function does not recognise is skipped, never raised on -- + a future primitive kind lands here the day this function is taught it, + and until then the rest of the drawing still renders. + """ + kind = prim.get('kind') + role = prim.get('role') + flagged = bool(prim.get('flagged')) + brush = Brushes.Red if flagged else _role_brush(role) + + if kind == 'rect': + r = Rectangle() + r.Width = max(1, prim.get('w', 1)) + r.Height = max(1, prim.get('h', 1)) + r.Stroke = brush + r.StrokeThickness = 1.5 + r.Fill = (Brushes.Transparent + if role in ('opening', 'reveal', 'solid-zone') + else Brushes.AliceBlue) + Canvas.SetLeft(r, prim.get('x', 0)) + Canvas.SetTop(r, prim.get('y', 0)) + canvas.Children.Add(r) + elif kind == 'line': + ln = Line() + ln.X1, ln.Y1 = prim.get('x1', 0), prim.get('y1', 0) + ln.X2, ln.Y2 = prim.get('x2', 0), prim.get('y2', 0) + ln.Stroke = brush + ln.StrokeThickness = 1.0 + canvas.Children.Add(ln) + elif kind == 'support': + # The support triangle points UP at the soffit it holds, and a + # support the user typed outside the panel is drawn hollow at the + # end it was clamped to -- present and visibly wrong, never gone. + x, y = prim.get('x', 0), prim.get('y', 0) + tri = Polygon() + pts = PointCollection() + pts.Add(Point(x, y)) + pts.Add(Point(x - 6, y + 11)) + pts.Add(Point(x + 6, y + 11)) + tri.Points = pts + tri.Stroke = brush + tri.StrokeThickness = 1.5 + tri.Fill = Brushes.Transparent if flagged else brush + canvas.Children.Add(tri) + elif kind == 'text': + canvas.Children.Add(_sketch_text(prim)) + elif kind == 'marker': + x, y = prim.get('x', 0), prim.get('y', 0) + glyph = prim.get('glyph') + if glyph == 'diamond': + poly = Polygon() + pts = PointCollection() + for dx, dy in ((0, -6), (6, 0), (0, 6), (-6, 0)): + pts.Add(Point(x + dx, y + dy)) + poly.Points = pts + poly.Stroke = brush + poly.StrokeThickness = 1.5 + poly.Fill = brush + canvas.Children.Add(poly) + else: # 'cross', or unnamed + for x1, y1, x2, y2 in ((x - 6, y, x + 6, y), + (x, y - 6, x, y + 6)): + ln = Line() + ln.X1, ln.Y1, ln.X2, ln.Y2 = x1, y1, x2, y2 + ln.Stroke = brush + ln.StrokeThickness = 2.0 + canvas.Children.Add(ln) + elif kind == 'swatch': + rect = Rectangle() + rect.Width = 12 + rect.Height = 12 + rect.Stroke = Brushes.Gray + rect.StrokeThickness = 0.5 + rect.Fill = _FILL_BRUSH.get(prim.get('fill'), Brushes.Gainsboro) + Canvas.SetLeft(rect, prim.get('x', 0)) + Canvas.SetTop(rect, prim.get('y', 0)) + canvas.Children.Add(rect) + elif kind == 'dot': + dot = Ellipse() + dot.Width = 5 + dot.Height = 5 + dot.Stroke = brush + dot.StrokeThickness = 1.0 + dot.Fill = brush + Canvas.SetLeft(dot, prim.get('x', 0) - 2.5) + Canvas.SetTop(dot, prim.get('y', 0) - 2.5) + canvas.Children.Add(dot) + # else: an unrecognised primitive kind. Skipped, not raised on. + + +def _drawing_canvas(source, values, w, h): + """(Border-wrapped Canvas, problems) for one `drawing` block. + + `source` names an entry point in `vibe.panelsketch`; an unknown name, or + one that raises, becomes a visible note in place of the picture -- never + a crash, never a blank box indistinguishable from an empty drawing. + """ + canvas = Canvas() + canvas.Width = w + canvas.Height = h + canvas.Background = Brushes.WhiteSmoke + + fn = getattr(panelsketch, source, None) if source else None + if fn is None: + primitives, problems = [], [ + "drawing source '{0}' is not available.".format(source)] + else: + try: + primitives, problems = fn(values, w, h) + except Exception as exc: # never take the form down + primitives, problems = [], ['drawing failed: {0}'.format(exc)] + + for prim in primitives: + try: + _draw_primitive(canvas, prim) + except Exception: + pass # one bad primitive is never worth the whole page + + for n, problem in enumerate(problems[:4]): + note = TextBlock() + note.Text = u'• ' + problem + note.FontSize = 10 + note.Foreground = Brushes.Firebrick + note.TextWrapping = TextWrapping.Wrap + note.Width = max(w - 12, 40) + Canvas.SetLeft(note, 6) + Canvas.SetTop(note, 6 + n * 24) + canvas.Children.Add(note) + + border = Border() + border.BorderBrush = Brushes.Gainsboro + border.BorderThickness = Thickness(1) + border.Margin = Thickness(0, 4, 0, 4) + border.Child = canvas + return border + + +# --------------------------------------------------------------- the chrome +# +# Eriksson prints one BLOCK per idea, titled, with its inputs in two columns +# under the title. A person reading it never has to hold fifty rows in their +# head at once, which is exactly what this form used to ask of them (the +# owner, twice: "太丑了", "你这么输入用户上哪儿能看懂"). These three widgets +# are the whole of the answer: a titled box, a two-column row, and a plain +# sentence under a field saying what it is for. + +_INK = Brushes.Black +_QUIET = Brushes.Gray +_RULE = Brushes.Gainsboro + + +def _hint_block(text): + """The plain sentence under a field. Grey, small, and never a warning -- + a warning that looks like help is read as neither.""" + tb = _math_block(text, size=10, brush=_QUIET) + tb.Margin = Thickness(0, 0, 0, 4) + return tb + + +LABEL_COL = 210 +SYMBOL_COL = 92 +EDITOR_COL = 150 + + +def _two_col_row(label_widget, editor, symbol=None, hint=None): + """One input, laid out `name | symbol | box` with the plain-English name + first and the notation BESIDE it rather than instead of it. + + The symbol column is what lets a label say `Tension allowed while + handling` and still show `n·√f′ci` to the engineer who wants it. Neither + reader has to decode the other's version. + """ + grid = Grid() + for width in (LABEL_COL, SYMBOL_COL, EDITOR_COL): + col = ColumnDefinition() + col.Width = GridLength(width) + grid.ColumnDefinitions.Add(col) + grid.RowDefinitions.Add(RowDefinition()) + if hint: + grid.RowDefinitions.Add(RowDefinition()) + + label_widget.Margin = Thickness(0, 2, 8, 0) + Grid.SetRow(label_widget, 0) + Grid.SetColumn(label_widget, 0) + grid.Children.Add(label_widget) + + sym = _math_block(symbol or '', size=11, brush=_QUIET) + sym.Margin = Thickness(0, 2, 8, 0) + Grid.SetRow(sym, 0) + Grid.SetColumn(sym, 1) + grid.Children.Add(sym) + + Grid.SetRow(editor, 0) + Grid.SetColumn(editor, 2) + grid.Children.Add(editor) + + if hint: + note = _hint_block(hint) + Grid.SetRow(note, 1) + Grid.SetColumn(note, 0) + Grid.SetColumnSpan(note, 3) + grid.Children.Add(note) + + grid.Margin = Thickness(0, 1, 0, 1) + return grid + + +def _tab(title, child): + item = TabItem() + item.Header = title + scroller = ScrollViewer() + scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto + scroller.Content = child + item.Content = scroller + return item + + +# ------------------------------------------------------- the generic renderer +# +# Everything below turns the block vocabulary five sibling agents build +# against (pa_criteria / pa_extents / pa_structural / pa_handling / +# pa_reinforcement, each a `page(values, reads=None) -> (blocks, problems)`) +# into WPF. This is the ONLY place that vocabulary is interpreted -- nothing +# any of those five modules built is visible to the owner until it passes +# through here. An unknown block `kind` renders as a visible grey note naming +# it (never vanishes); a block that raises while rendering is caught right +# where it is dispatched and becomes a small red note instead of taking the +# whole tab down. + +SECTION_MODULES = ( + ('Design Criteria', 'pa_criteria'), + ('Concrete Extents', 'pa_extents'), + ('Structural Model', 'pa_structural'), + ('Handling', 'pa_handling'), + ('Reinforcement', 'pa_reinforcement'), +) + + +def _load_section_module(modname): + """(module, None) or (None, exception) -- never raises. + + A sibling agent's file can be mid-edit (syntax error) or simply not + landed yet (ImportError); both degrade the same way, to a tab that names + the section and says why it is not available, never a crash that takes + every OTHER section down with it. + """ + try: + mod = __import__('vibe.' + modname, fromlist=[modname]) + return mod, None + except Exception as exc: + return None, exc + + +def _missing_section_note(title, modname, reason): + box = StackPanel() + box.Margin = Thickness(4) + msg = (u"The '{0}' section is not available yet -- vibe.{1} could not " + u"be loaded ({2}). Its inputs cannot be shown or edited on this " + u"panel until it lands; nothing else on this form is affected." + ).format(title, modname, reason) + tb = _math_block(msg, size=11, brush=Brushes.Firebrick) + tb.TextWrapping = TextWrapping.Wrap + box.Children.Add(tb) + return box + + +def _grid_star_columns(grid, n): + for _ in range(n): + col = ColumnDefinition() + col.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(col) + + +def _cell_border(child, header=False, zebra=False): + b = Border() + b.BorderBrush = _RULE + b.BorderThickness = Thickness(0.6) + b.Padding = Thickness(4, 2, 4, 2) + if header: + b.Background = Brushes.WhiteSmoke + elif zebra: + b.Background = Brushes.GhostWhite + else: + b.Background = Brushes.White + b.Child = child + return b + + +def _grid_text_cell(text, bold=False, header=False, zebra=False): + tb = _math_block(u'' if text is None else str(text), bold=bold, size=11) + tb.TextWrapping = TextWrapping.Wrap + return _cell_border(tb, header=header, zebra=zebra) + + +def _em_dash_cell(zebra=False): + """A matrix cell that does NOT apply to this stage -- an em dash, never + blank. Blank means 'applies, not filled in'; this means 'does not apply' + -- the two must never look the same.""" + tb = TextBlock() + tb.Text = u'—' + tb.HorizontalAlignment = HorizontalAlignment.Center + tb.Foreground = _QUIET + return _cell_border(tb, zebra=zebra) + + +def _render_heading(block): + level = block.get('level') or 1 + text = block.get('text', '') + panel = StackPanel() + if level == 1: + head = _math_block(text, bold=True, size=15) + panel.Children.Add(head) + rule = Border() + rule.BorderBrush = _RULE + rule.BorderThickness = Thickness(0, 0, 0, 2) + rule.Margin = Thickness(0, 2, 0, 8) + panel.Children.Add(rule) + elif level == 2: + head = _math_block(text, bold=True, size=13) + panel.Children.Add(head) + rule = Border() + rule.BorderBrush = _RULE + rule.BorderThickness = Thickness(0, 0, 0, 1) + rule.Width = 170 + rule.HorizontalAlignment = HorizontalAlignment.Left + rule.Margin = Thickness(0, 2, 0, 6) + panel.Children.Add(rule) + else: + head = _math_block(text, bold=True, size=11.5) + head.Margin = Thickness(0, 4, 0, 2) + panel.Children.Add(head) + return panel + + +def _render_columns(block, form): + cols = block.get('columns') or [] + if not cols: + return StackPanel() + grid = Grid() + _grid_star_columns(grid, len(cols)) + grid.RowDefinitions.Add(RowDefinition()) + for i, col_blocks in enumerate(cols): + panel = StackPanel() + panel.Margin = Thickness(6, 0, 6, 0) + panel.VerticalAlignment = VerticalAlignment.Top + for b in (col_blocks or []): + w = _render_block(b, form) + if w is not None: + panel.Children.Add(w) + Grid.SetColumn(panel, i) + Grid.SetRow(panel, 0) + grid.Children.Add(panel) + return grid + + +def _render_field(block, form): + key = block.get('key') + label = block.get('label') or key or '' + if block.get('required'): + label = label + u' *' + lab = _math_block(label, size=11) + editor = form.make_editor(block.get('editor'), key, block.get('choices'), + width=EDITOR_COL) + return _two_col_row(lab, editor, symbol=block.get('symbol'), + hint=block.get('hint')) + + +def _render_kv(block): + rows = block.get('rows') or [] + grid = Grid() + c0 = ColumnDefinition() + c0.Width = GridLength(200) + grid.ColumnDefinitions.Add(c0) + c1 = ColumnDefinition() + c1.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(c1) + for _ in rows: + grid.RowDefinitions.Add(RowDefinition()) + for i, pair in enumerate(rows): + label, value = (pair[0], pair[1]) if len(pair) >= 2 else (pair[0], '') + lab = _math_block(str(label), size=11, brush=_QUIET) + lab.Margin = Thickness(0, 1, 8, 1) + val = _math_block(u'' if value is None else str(value), size=11) + val.Margin = Thickness(0, 1, 0, 1) + Grid.SetRow(lab, i) + Grid.SetColumn(lab, 0) + grid.Children.Add(lab) + Grid.SetRow(val, i) + Grid.SetColumn(val, 1) + grid.Children.Add(val) + return grid + + +def _render_sentence(block): + tb = _math_block(block.get('text', ''), size=11, brush=_QUIET) + tb.FontStyle = FontStyles.Italic + tb.Margin = Thickness(0, 2, 0, 6) + return tb + + +def _render_table(block, form): + headers = block.get('headers') or [] + header_groups = block.get('header_groups') + rows = block.get('rows') or [] + ncols = len(headers) + if ncols == 0: + return StackPanel() + + grid = Grid() + for _ in range(ncols): + grid.ColumnDefinitions.Add(ColumnDefinition()) + header_rows = 2 if header_groups else 1 + for _ in range(header_rows + len(rows)): + grid.RowDefinitions.Add(RowDefinition()) + + r = 0 + if header_groups: + c = 0 + for label, span in header_groups: + span = max(1, min(span, ncols - c)) + cell = _grid_text_cell(label, bold=True, header=True) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, c) + Grid.SetColumnSpan(cell, span) + grid.Children.Add(cell) + c += span + r += 1 + for c, h in enumerate(headers): + cell = _grid_text_cell(h, bold=True, header=True) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, c) + grid.Children.Add(cell) + r += 1 + + for ridx, row in enumerate(rows): + zebra = (ridx % 2 == 1) + for cidx in range(ncols): + value = row[cidx] if cidx < len(row) else '' + if isinstance(value, dict) and value.get('editor') == 'check': + widget = form.make_editor('check', value.get('key')) + widget.HorizontalAlignment = HorizontalAlignment.Center + cell = _cell_border(widget, zebra=zebra) + else: + cell = _grid_text_cell(value, zebra=zebra) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, cidx) + grid.Children.Add(cell) + r += 1 + return grid + + +def _render_matrix(block, form): + columns = block.get('columns') or [] + rows = block.get('rows') or [] + if not columns or not rows: + return StackPanel() + + grid = Grid() + c0 = ColumnDefinition() + c0.Width = GridLength(220) + grid.ColumnDefinitions.Add(c0) + for _ in columns: + col = ColumnDefinition() + col.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(col) + for _ in range(1 + len(rows)): + grid.RowDefinitions.Add(RowDefinition()) + + hdr = _grid_text_cell('', bold=True, header=True) + Grid.SetRow(hdr, 0) + Grid.SetColumn(hdr, 0) + grid.Children.Add(hdr) + for ci, colname in enumerate(columns): + cell = _grid_text_cell(colname, bold=True, header=True) + Grid.SetRow(cell, 0) + Grid.SetColumn(cell, ci + 1) + grid.Children.Add(cell) + + for ridx, row in enumerate(rows): + r = ridx + 1 + zebra = (ridx % 2 == 1) + lbl_panel = StackPanel() + lbl_panel.Children.Add(_math_block(row.get('label', ''), size=11)) + if row.get('hint'): + lbl_panel.Children.Add(_hint_block(row['hint'])) + lbl_cell = _cell_border(lbl_panel, zebra=zebra) + Grid.SetRow(lbl_cell, r) + Grid.SetColumn(lbl_cell, 0) + grid.Children.Add(lbl_cell) + + cells = row.get('cells') or [] + for ci in range(len(columns)): + cellval = cells[ci] if ci < len(cells) else None + if cellval is None: + wrapped = _cell_border(TextBlock(), zebra=zebra) + elif isinstance(cellval, dict) and cellval.get('na'): + wrapped = _em_dash_cell(zebra=zebra) + elif isinstance(cellval, dict): + widget = form.make_editor(cellval.get('editor'), + cellval.get('key'), + cellval.get('choices')) + wrapped = _cell_border(widget, zebra=zebra) + else: + wrapped = _grid_text_cell(cellval, zebra=zebra) + Grid.SetRow(wrapped, r) + Grid.SetColumn(wrapped, ci + 1) + grid.Children.Add(wrapped) + return grid + + +def _render_legend(block): + groups = block.get('groups') or [] + panel = StackPanel() + for name, items in groups: + panel.Children.Add(_math_block(name, bold=True, size=11)) + for symbol, desc in (items or []): + row = StackPanel() + row.Orientation = Orientation.Horizontal + row.Margin = Thickness(8, 0, 0, 2) + sym = _math_block(symbol, size=11, brush=_QUIET) + sym.Width = 60 + row.Children.Add(sym) + row.Children.Add(_math_block(desc, size=11)) + panel.Children.Add(row) + return panel + + +def _render_rule(): + b = Border() + b.BorderBrush = _RULE + b.BorderThickness = Thickness(0, 0, 0, 1) + b.Margin = Thickness(0, 6, 0, 6) + return b + + +def _render_space(block): + b = Border() + b.Height = max(2, (block.get('h') or 1) * 6) + return b + + +def _render_unknown(block): + """A block `kind` this renderer has never seen. Named and visible, + never silently dropped -- a block that vanishes is a section of the + form nobody knows is missing.""" + tb = TextBlock() + tb.Text = u"[unrendered block: kind={0!r}]".format(block.get('kind') + if isinstance(block, dict) + else block) + tb.FontStyle = FontStyles.Italic + tb.Foreground = Brushes.Gray + tb.TextWrapping = TextWrapping.Wrap + tb.Margin = Thickness(0, 2, 0, 2) + return tb + + +def _render_block(block, form): + """One block dict -> one WPF element. Never raises -- a block that fails + to render becomes a small red note in its own place, and the rest of the + tab keeps building.""" + if not isinstance(block, dict): + return _render_unknown(block) + kind = block.get('kind') + try: + if kind == 'heading': + return _render_heading(block) + if kind == 'columns': + return _render_columns(block, form) + if kind == 'field': + return _render_field(block, form) + if kind == 'kv': + return _render_kv(block) + if kind == 'sentence': + return _render_sentence(block) + if kind == 'table': + return _render_table(block, form) + if kind == 'matrix': + return _render_matrix(block, form) + if kind == 'legend': + return _render_legend(block) + if kind == 'drawing': + return form.drawing_widget(block) + if kind == 'rule': + return _render_rule() + if kind == 'space': + return _render_space(block) + return _render_unknown(block) + except Exception as exc: + note = TextBlock() + note.Text = u'[block failed to render: kind={0} -- {1}]'.format( + kind, exc) + note.Foreground = Brushes.Firebrick + note.TextWrapping = TextWrapping.Wrap + note.FontSize = 10 + return note + + +class PanelForm(object): + """One selected panel's collapsed section: a tab per Eriksson section, + built from the five sibling agents' `page()` block lists, plus that + panel's own Run button. + """ + + def __init__(self, el, index, know): + self.el = el + self.index = index + self.reads = _reads_for(el, index, know) + self.values0 = F.prefill(self.reads) + self.mark = self.reads.get('mark') or 'panel {0}'.format(index + 1) + self.title = panel_title(self.reads) or self.mark + self.editors = {} # key -> (widget, 'text'|'check'|'choice') + self.drawings = [] # [(container, source, caption, w, h)] + self.run_btn = None + self.status = None + self.gcpi_label = None + self.tabctrl = None + self.expander = self._build() + + # -- values ------------------------------------------------------------ + def current_values(self): + vals = dict(self.values0) + for key, pair in self.editors.items(): + widget, kind = pair + if kind == 'check': + vals[key] = 'yes' if widget.IsChecked else '' + else: + try: + vals[key] = widget.Text or '' + except Exception: + vals[key] = '' + return vals + + # -- editors ------------------------------------------------------------- + def make_editor(self, kind, key, choices=None, width=None): + """One live input widget for `key`, registered in `self.editors` so + `current_values()` / the gate / the drawings can read it back. + + `check` -> CheckBox, `choice` -> an EDITABLE ComboBox over `choices` + that never pre-selects a value the user did not choose (its `.Text` + is set, never its `SelectedItem`), everything else -> TextBox. + """ + initial = self.values0.get(key, '') if key else '' + if kind == 'check': + w = CheckBox() + w.IsChecked = (initial or '').strip().lower() in ( + 'yes', 'y', 'true', '1', 'on') + w.VerticalAlignment = VerticalAlignment.Center + w.Checked += self._on_change + w.Unchecked += self._on_change + ekind = 'check' + elif kind == 'choice': + w = ComboBox() + w.IsEditable = True + for c in (choices or []): + w.Items.Add(c) + w.Text = initial or '' + if width: + w.Width = width + w.SelectionChanged += self._on_change + w.LostFocus += self._on_change + ekind = 'choice' + else: # 'text' / 'num' / other + w = TextBox() + w.Text = initial or '' + if width: + w.Width = width + w.TextChanged += self._on_change + ekind = 'text' + if key: + self.editors[key] = (w, ekind) + if key == 'wind_enclosure': + # Owner's requirement: choosing an enclosure shows its matching + # GCpi (ASCE Table 26.13-1). Pure lookup in the form module -- + # windload itself is CPython and cannot be imported here. The + # readout sits beside the enclosure editor and `refresh_gate` + # refreshes it on every change via `_update_gcpi`. + self.gcpi_label = _label_block('', size=11, brush=Brushes.Gray) + self.gcpi_label.Width = 220 + self.gcpi_label.Margin = Thickness(8, 0, 0, 0) + holder = StackPanel() + holder.Orientation = Orientation.Horizontal + holder.Children.Add(w) + holder.Children.Add(self.gcpi_label) + return holder + return w + + # -- drawings ------------------------------------------------------------ + def drawing_widget(self, block): + source = block.get('source') + caption = block.get('caption') + w = block.get('w') or SKETCH_W + h = block.get('h') or SKETCH_H + container = StackPanel() + self._draw_into(container, source, caption, w, h, self.values0) + self.drawings.append((container, source, caption, w, h)) + return container + + def _draw_into(self, container, source, caption, w, h, values): + container.Children.Clear() + container.Children.Add(_drawing_canvas(source, values, w, h)) + if caption: + cap = TextBlock() + cap.Text = caption + cap.TextDecorations = TextDecorations.Underline + cap.HorizontalAlignment = HorizontalAlignment.Center + cap.FontSize = 11 + cap.Margin = Thickness(0, 0, 0, 10) + container.Children.Add(cap) + + def _redraw_drawings(self): + vals = self.current_values() + for container, source, caption, w, h in self.drawings: + try: + self._draw_into(container, source, caption, w, h, vals) + except Exception: + pass # a drawing is never worth taking the form down + + # -- ui ---------------------------------------------------------------- + def _section_tab(self, title, modname): + content = StackPanel() + content.Margin = Thickness(6, 4, 6, 4) + + mod, err = _load_section_module(modname) + if mod is None: + content.Children.Add(_missing_section_note(title, modname, err)) + return _tab(title, content) + if not hasattr(mod, 'page'): + content.Children.Add(_missing_section_note( + title, modname, 'module has no page()')) + return _tab(title, content) + + try: + blocks, problems = mod.page(self.values0, reads=self.reads) + except Exception as exc: + content.Children.Add(_missing_section_note(title, modname, exc)) + return _tab(title, content) + + for b in (blocks or []): + w = _render_block(b, self) + if w is not None: + content.Children.Add(w) + + if problems: + exp = Expander() + exp.Header = 'Notes ({0})'.format(len(problems)) + exp.IsExpanded = False + exp.Margin = Thickness(0, 0, 0, 8) + plist = StackPanel() + plist.Margin = Thickness(6, 4, 6, 4) + for p in problems: + plist.Children.Add(_hint_block(u'• ' + p)) + exp.Content = plist + content.Children.Insert(0, exp) + + return _tab(title, content) + + def _build(self): + body = StackPanel() + body.Margin = Thickness(6, 4, 6, 4) + + self.tabctrl = TabControl() + for title, modname in SECTION_MODULES: + self.tabctrl.Items.Add(self._section_tab(title, modname)) + self.tabctrl.Height = 620 + body.Children.Add(self.tabctrl) + + self.status = _label_block('', size=11) + body.Children.Add(self.status) + + self.run_btn = Button() + self.run_btn.Content = 'Run Analysis' + self.run_btn.Margin = Thickness(0, 6, 0, 4) + self.run_btn.Width = 140 + self.run_btn.HorizontalAlignment = HorizontalAlignment.Left + self.run_btn.Click += self._on_run + body.Children.Add(self.run_btn) + + exp = Expander() + exp.Header = self.title + exp.IsExpanded = False + exp.Content = body + exp.Margin = Thickness(0, 2, 0, 2) + self.refresh_gate() + return exp + + # -- behaviour --------------------------------------------------------- + def _update_gcpi(self): + if self.gcpi_label is None: + return + pair = self.editors.get('wind_enclosure') + box = pair[0] if pair else None + gcpi, why = F.wind_gcpi(box.Text if box is not None else None) + if gcpi is None: + self.gcpi_label.Text = '' + self.gcpi_label.Foreground = Brushes.Gray + else: + self.gcpi_label.Text = 'GCpi = +/-{0}'.format(gcpi) + self.gcpi_label.Foreground = Brushes.SeaGreen + + def refresh_gate(self, *args): + self._update_gcpi() + errors = F.validate(self.current_values())[0] + self.run_btn.IsEnabled = not errors + if errors: + shown = '; '.join(errors[:6]) + if len(errors) > 6: + shown += ' ...' + self.status.Text = 'Still needed: ' + shown + self.status.Foreground = Brushes.Firebrick + else: + self.status.Text = 'Ready to run.' + self.status.Foreground = Brushes.SeaGreen + + def _on_change(self, sender, event): + self.refresh_gate() + self._redraw_drawings() + + def _on_run(self, sender, event): + errors, parsed = F.validate(self.current_values()) + if errors: + self.status.Text = 'Still needed: ' + '; '.join(errors[:6]) + self.status.Foreground = Brushes.Firebrick + return + deck = F.build_deck(parsed) + ok, message = _run_one(self.mark, deck) + if ok: + self.status.Text = 'Ran. Page opened.' + self.status.Foreground = Brushes.SeaGreen + _report_one(self.mark, True, message) + else: + self.status.Text = 'Did not run -- see the output panel.' + self.status.Foreground = Brushes.Firebrick + _report_one(self.mark, False, message) + + +def _report_one(mark, ok, message): + if ok: + output.print_md('- **{0}** — ran. [{1}]({2})'.format( + mark, os.path.basename(message), + 'file:///' + message.replace('\\', '/'))) + else: + output.print_md('- **{0}** — NOT RUN: {1}'.format(mark, message)) + + +def _analyze_all(panels, overall_status): + named = [(pf.mark, pf.current_values()) for pf in panels] + to_run, skipped = F.batch_plan(named) + output.print_md('## Analyze All') + ran_ok = 0 + for label, parsed in to_run: + deck = F.build_deck(parsed) + ok, message = _run_one(label, deck) + _report_one(label, ok, message) + if ok: + ran_ok += 1 + for label, errors in skipped: + output.print_md('- **{0}** — SKIPPED (incomplete): {1}'.format( + label, '; '.join(errors[:6]))) + overall_status.Text = ('Analyze All: {0} ran, {1} skipped (incomplete). ' + 'See the output panel.'.format(ran_ok, + len(skipped))) + overall_status.Foreground = (Brushes.SeaGreen if not skipped + else Brushes.DarkGoldenrod) + + +def show_form(panels): + # Sized for real content -- each tab is a full Eriksson printed page, not + # a handful of text boxes, and the old 620x720 could not show one without + # a scrollbar inside a scrollbar. This still fits a 1080p screen with + # room to spare, and every scroller inside still has its own + # ScrollBarVisibility.Auto for anyone on something smaller. + win = Window() + win.Title = TITLE + win.Width = 1180 + win.Height = 880 + win.MinWidth = 760 + win.MinHeight = 520 + win.SizeToContent = SizeToContent.Manual + win.WindowStartupLocation = WindowStartupLocation.CenterScreen + + root = Grid() + root.Margin = Thickness(10) + row_top = RowDefinition() + row_bottom = RowDefinition() + row_bottom.Height = GridLength.Auto + root.RowDefinitions.Add(row_top) + root.RowDefinitions.Add(row_bottom) + + header = _math_block( + 'Confirm the pre-filled fields, fill the blanks (especially the ' + 'support positions), and press Run Analysis on a panel below -- or ' + 'Analyze All for every complete one.', size=11, brush=_QUIET) + header.TextWrapping = TextWrapping.Wrap + header.Margin = Thickness(0, 0, 0, 8) + Grid.SetRow(header, 0) + # header lives inside the scroller's stack instead of its own grid row, + # so it scrolls with the panels rather than stealing a fixed row -- see + # below. + + scroller = ScrollViewer() + scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto + stack = StackPanel() + stack.Children.Add(header) + for pf in panels: + stack.Children.Add(pf.expander) + scroller.Content = stack + Grid.SetRow(scroller, 0) + root.Children.Add(scroller) + + bottom = StackPanel() + bottom.Margin = Thickness(0, 8, 0, 0) + Grid.SetRow(bottom, 1) + + overall_status = _label_block('', size=11) + + btn_row = StackPanel() + btn_row.Orientation = Orientation.Horizontal + + all_btn = Button() + all_btn.Content = 'Analyze All' + all_btn.Width = 160 + all_btn.Margin = Thickness(0, 0, 8, 0) + all_btn.Click += lambda s, e: _analyze_all(panels, overall_status) + btn_row.Children.Add(all_btn) + + close_btn = Button() + close_btn.Content = 'Close' + close_btn.Width = 100 + close_btn.Click += lambda s, e: win.Close() + btn_row.Children.Add(close_btn) + + bottom.Children.Add(btn_row) + bottom.Children.Add(overall_status) + root.Children.Add(bottom) + + border = Border() + border.Child = root + win.Content = border + win.ShowDialog() + + +# ------------------------------------------------------------------- main + +KNOW = None +try: + KNOW = knowledge.load(doc) +except Exception: + KNOW = None + +panels = _resolve_framing() +if not panels: + not_run('Select one or more structural-framing panels (the pieces you ' + 'want analysed), then run this again. Selecting an assembly, or ' + 'any member of one, resolves to its framing member.') + +output.print_md('# {0}'.format(TITLE)) +output.print_md('Opened an input form for **{0}** panel(s). Confirm the ' + 'pre-filled fields, fill the blanks (especially the support ' + 'positions), and press **Run Analysis** on a panel -- or ' + '**Analyze All** for every complete one.'.format(len(panels))) + +forms_list = [PanelForm(el, i, KNOW) for i, el in enumerate(panels)] +show_form(forms_list) diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml new file mode 100644 index 0000000..3d01388 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml @@ -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 diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png new file mode 100644 index 0000000..3b00e42 Binary files /dev/null and b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml new file mode 100644 index 0000000..d3b983f --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml @@ -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 diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/bundle.yaml new file mode 100644 index 0000000..eafcbf3 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/bundle.yaml @@ -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. diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/icon.png new file mode 100644 index 0000000..8f6d2d9 Binary files /dev/null and b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/script.py b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/script.py new file mode 100644 index 0000000..ee0a143 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/script.py @@ -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 `//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 `/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 `/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.') diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/bundle.yaml new file mode 100644 index 0000000..32a82ed --- /dev/null +++ b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/bundle.yaml @@ -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. diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/icon.png new file mode 100644 index 0000000..7b1add4 Binary files /dev/null and b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/icon.png differ diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/script.py b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/script.py new file mode 100644 index 0000000..607d742 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/script.py @@ -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. +# `
` 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: + ticket_created: + ticket_detailed: + sheet: + total_required: + sheet_count: + # either list the control-number slots explicitly ... + control_numbers: [, , ...] + + nomenclature: + # ... or, for numbered slots, match them with one expression: + control_slot_re: '^_\\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() diff --git a/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/ProjectConfig.xaml b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/ProjectConfig.xaml new file mode 100644 index 0000000..b0360ee --- /dev/null +++ b/WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/ProjectConfig.xaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +