WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+911
View File
@@ -0,0 +1,911 @@
# Plan — Strands Auto Layout, a design-summary pattern checked against every piece that carries the design
**Status: proposed. Nothing here is built. No module was changed to write it.**
**No code was written and nothing was committed.**
The owner's shape, in his words:
> "多加一个按键,在design hub底下,叫strands auto layout. 大致就是用户设计一个
> design summary, 里面有所有的strand pattern, 按下以后读这个strand pattern,在所
> 有的这个design number的framing element 底下,找这个strand pattern可能造成的问
> 题。比较常见的是干涉,或者距离洞/concrete edge太近太远。所以需要用户额外输入
> max_deflection, bed_offset, max_bed_width, min_clear_cover 等等的参数。"
> "这玩意儿我预计一旦跑起来得跑半天,所以你先写写文档啥的"
That second sentence is why this file exists before any code, and it is also a
design input, not an aside: a half-day run is a run that will be interrupted,
resumed, and overtaken by an edit to the model it is measuring. Everything in
§6 follows from it.
---
## 0. Names
This repository carries no plant or client name. The placeholders are the ones
`docs/plan-panellift-three-stage.md` §0 already uses, and CLAUDE.md's table:
| placeholder | what it means here |
| --- | --- |
| `plant_a` | the producer in production use today. Wall panels. |
| `plant_b` | the second producer, being onboarded. No job yet. |
| `sample_job` / `sample_client` | a job / a client, where one must be named |
**Plant numbers are cited by KEY PATH and never repeated here.** `strand.deflection.max_in`
names a value that lives in one file with a manual page cite beside it; writing
the number into a public document creates a second home for it, and two homes
for one number is how they drift (`plan-panellift-three-stage.md` §4.3, and the
`lifters.capacity_lb` incident of 2026-08-31 that argument is drawn from). Every
threshold below is a path or a question, never a figure.
---
## 1. What the feature does
**Strands Auto Layout** takes the strand pattern an engineer has authored on a
design summary — the `reinforcing.strand_layout` matrix in
`knowledge/job/<job>/specs/<design>.yaml`, which already exists and is already
hand-correctable — and asks, for **every framing element in the model carrying
that design number**, what problems that one pattern would cause on *that*
piece. The pieces of one design are not identical: they share a section and a
detail package, and they differ in width, length and openings (the spec's own
`envelope.length` / `envelope.width` bands say so), so a pattern that is right
on the design sheet can still be wrong on a particular piece. It reports
interference between a strand and anything else cast into the piece, strands
too close to or too far from an opening or the concrete edge, strands landing
where the casting bed has no hole, strand runs too short to develop, and the
debond or deflection those conditions call for. It **writes nothing to the
model** and produces a per-piece report plus one roll-up. It is expected to run
for hours, so it is resumable, per-piece, on disk.
---
## 2. What already exists, and is reused rather than rebuilt
Nothing in §2 is new work. It is listed because a design that quietly rewrites
any of it introduces a second answer to a question this project has already
answered once.
### 2.1 "Every piece with this design number"
`ticketqc.assemblies_by_design(doc, know)` already returns `{design key ->
[AssemblyInstance]}`, keyed on the **reduced** design number, read off the
assembly's **Structural Framing** member — which is exactly the "framing
element" the owner names. The reduction is `specformat.design_key`, this
codebase's one answer to "are these the same design", and it carries the
project's nomenclature ruling: **design number = sheet number; a packet page
suffix and a `*.A` note are not separate designs** (`PNL1`, `PNL1.0`, `PNL1.A`
and `PNL1.B1` all reduce to `PNL1`), and it does **not** merge `PNL1` with
`PNL2`. The pattern is plant knowledge (`nomenclature.design_number_re`), and
an unreadable number is `None`, which is **could not read**, never "no design"
— `reinforcebind.DonorFilter._design_reject` states the rule that two `None`s
are not a match, and the same rule holds here.
`reinforcebind.DonorFilter(match_design=True)` is the same rule as a filter.
This tool does not need the donor half of it, but it must not re-implement the
design comparison: it calls `specformat.design_key` and nothing else.
**View names do not matter.** The design is read off the framing member's
parameter, resolved by role (`panel.design_number`), never off a view name.
### 2.2 The design summary, and where the strand pattern already lives
`specformat.SPEC_TEMPLATE` already carries the pattern in full:
```
reinforcing.strand_layout:
datum 'left_rail' | 'right_rail' | 'centerline'
columns_in strand positions across the piece, cumulative from the datum
rows [{from_bottom_in, strands: [...]}] — one per layer through the
thickness, marking which columns it fills at that height
total_in the chain total; should equal the piece width
note
reinforcing.strand_groups[] {qty, dia_in, grade_ksi, it_lb, role, note}
```
plus `envelope`, `source.extracted` / `source.extractor` /
`source.reviewed_by` / `source.reviewed_on` (**null until a person checks it**),
`unresolved[]` for anything the reader would not guess at, and `confidence{}`.
**Argument: the pattern belongs there and nowhere new.** Four reasons, and the
fourth is the one that decides it.
1. It is already the shape a detailer reads — the file's own comment says
`columns_in` holds "the same numbers a detailer would read off the dimension
chain", and `rows` is a matrix rather than a count because "a correct strand
COUNT in the wrong PLACE is still a wrong piece".
2. It is already consumed. Ticket QC item 4.4 (`check_strand_layout`) reads
`reinforcing.strand_layout` today via `specformat.strand_positions`, which
accepts both the matrix (`columns_in`) and the older flat shape
(`positions_in`). A second file would give this project two strand patterns
per design and no rule about which wins.
3. It is per **design**, which is the granularity the owner asked for — one
pattern, many pieces.
4. **It already carries provenance and a review state.** A half-day run against
a machine-read, never-reviewed pattern is a half day spent checking a guess.
`source.reviewed_by: null` is that fact, already recorded, already
per-design. A new file would have to grow the same field, and until it did,
the run would have no way to say what it had trusted. (What the run *does*
with an unreviewed pattern is §5.4 and open question 3.)
**What the spec does not carry, and would have to gain.** Three additive
fields, under `cli.py`'s versioning rule as restated in
`plan-panellift-three-stage.md` §5.3: *a key may be added without a schema bump
only when its absence has an explicit stand-down* — absence must read as
"nobody said", never as a value.
| proposed field | why | absence reads as |
| --- | --- | --- |
| `reinforcing.strand_layout.bed` | which casting bed this design is laid out for. The plant layer's `beds:` table is keyed by bed sheet; nothing today says which one a design uses, and the rationale is explicit that **which bed a piece is scheduled on is job knowledge**. | not stated → the bed-station and abutment checks stand down by name, never pass |
| `reinforcing.strand_layout.debond[]` | which stations the design already debonds, and over what run. Ticket QC 4.5 reads a debond *call-out* off the ticket; the design's own intent has no home. | `[]` = the design debonds nothing (a fact); key absent = nobody recorded it (a stand-down) — and **these are different**, see §5.5 |
| `reinforcing.strand_layout.deflect[]` | which strands the design deflects, where, and by how much. | same pair |
Note the `{}` / `null` convention the knowledge layer already uses and the
inversion `plan-panellift-three-stage.md` §7.2 warns about: here an **empty
list is a judgement** ("looked, none") and a **missing key is a stand-down**.
Say that at the top of the schema block or somebody will apply the other
file's convention to this one.
### 2.3 Through-hole versus blind pocket — settled, and not to be re-litigated
`liftexport.opening_penetration()` shoots a line along the thickness axis
through each opening's centre and intersects it with the concrete solid;
`split_openings()` sorts the result into `blockouts` (through), `recesses`
(blind, carrying `concrete_in` and `depth_in`) and a count of `unjudged`.
**A strand check must not treat a blind pocket as a hole.** That exact mistake
— every rectangle `_openings` returned going into `blockouts` — produced a
genuine-looking capacity FAIL on WP128, a piece whose model has no opening in
it at all, and it was fixed on 2026-09-01. `ticketpage.strand_debond_runs`
already documents the reasoning from the strand's own side: behind a recess
"the concrete and the strand are both still there", so there is no burst-prone
stub. This tool receives the **through** set pre-filtered, exactly as
`strand_debond_runs` requires, and it inherits `split_openings`'s third
outcome unchanged: **`unjudged` stays on the through side**, because "could not
tell" is not "blind", and standing it down on a failed solve would quietly make
a piece pass. The count is reported, not only acted on.
### 2.4 Interference — the pipeline exists and is measured
`check_member_interference` (`ticketchecks.py`, geometry in `ticketqc.py`),
with the reasoning in `docs/interference.md`. Five stages, each cheap enough to
earn the next: sweep-and-prune along the length → top-in-form → front → right →
one box **per solid** → boolean intersection → exemptions. Measured on three
real pieces: 12,090 pairs down to 27 real, **under a second per piece**.
The verdict is a **mean thickness**, `t = 2V/A`, not a volume and not the box
of the intersection — boxes reported 0.51" of interference for a sliver of
0.0166 in³, and two bars grazing share a curved lens that fills 10–15% of its
own box. Configuration is `interference.tolerance_in` (default 1/8"), `exact`,
`exempt_families`, `ignore_family_pairs`, `component_groups`.
**What is different here, and it is the whole difficulty.** Ticket QC's clash
check tests bodies that are *in the model*. This tool tests a pattern that is
**proposed** — its strands may not be modelled at all, or may be modelled
differently from the design. So the strand bodies have to be **synthesised**
from the pattern (a cylinder of `strand_groups[].dia_in` at each station, run
along the piece's length axis) and fed into the same pipeline. Three
consequences, all named as units in §5:
- the synthesis is a PRE unit with its own could-not-judge (no diameter, no
axis, no placement);
- the mean-thickness verdict and the tolerance are reused **as they are** — a
second interference criterion in this codebase would be a second answer to a
question `docs/interference.md` closed after several wrong versions that
"looked right";
- `docs/interference.md`'s own bias note applies unchanged: mean thickness
**under-reports a chunky intersection**, so the tolerance is set knowing
that.
### 2.5 Strand-versus-opening rules already in the plant layer
| key | what it says | consumer today |
| --- | --- | --- |
| `strand.debond_below_continuous_ft` | along the strand direction, a run of concrete shorter than this must be debonded | Ticket QC 4.7 `check_strand_debond` / `ticketpage.strand_debond_runs` |
| `strand.debond_by_blockout` | a decision table on the **blockout's own** L and W, and on whether the opening is inside the panel or at its edge; plus `also_applies_to_top_of_panel` and `engineering_must_consider_strand_count` | **none. Landed with no consumer.** |
| `strand.deflection` | `is_last_option: true`, `max_in`, `min_cover_in` | **none** |
| `strand.debond_callout_tokens` | the words a ticket uses to call a debond out | Ticket QC 4.5 |
| `strand.standard_patterns`, `strand.edge_strand_in`, `strand.both_wythes_same_stations` | the plant's own published chains | — |
| `strand.detensioning` | cutting sequence | none, deliberately |
The plant rationale records the hazard between the first two in its own words,
and it is a **gap in the tool, not a redundancy**: the 4-foot rule measures the
continuous run of concrete **along the strand**; the decision table's L and W
are the **blockout's own dimensions**. Same geometry from opposite sides. *They
agree at the 4-foot boundary and nowhere else,* and **the L-vs-W ratio rows
have no counterpart in the tool at all** — a blockout longer than 4 ft but
narrow relative to its width is one the tool passes today and the manual
debonds. That is a real hole and this feature is the natural place to close it
(unit `POST-1.11`).
**`max_deflection` and the plant's deflection rule.** The owner's parameter
almost certainly *is* `strand.deflection.max_in`, and there is a conflict worth
stating before anything is built:
1. **It is already plant knowledge, with a manual cite and a confidence
grade.** Putting it in a per-run dialog means it gets retyped, differently,
every run — the owner's own stated concern about Project Config. §4 rules it
plant, with a Project Config field that may only make it **stricter**.
2. **`is_last_option: true` is the sharper conflict.** The plant note reads
`DEFLECTING STRAND IS THE LAST OPTION`. A tool named "auto layout" that
proposes deflection whenever a strand meets an obstruction would invert a
ranking the plant has stated: debond first, deflect last. So `max_deflection`
is **not a knob that decides a layout**. It is a **ceiling on a deflection
the design already calls for**, and the tool's own verdict when it finds an
obstruction is *"this station needs relief; the plant's ranked options are
debond, then deflect"* — never *"deflect it by 1.75 inches"*. Open question 5
asks him to confirm the ranking is a rule and not a preference.
3. `strand.deflection.min_cover_in` is a cover that applies **to the deflected
condition** (`DEFLECT STRAND INTO PANEL 1 1/2" MIN. COVER`). It is **not** a
general clear cover from the concrete edge, and the owner's `min_clear_cover`
probably means the general one, which **has no key today**. See §4 and open
question 6.
### 2.6 Strands are dimensioned from a RAIL, and the rails/beds branches exist
Project knowledge: **strands always come with a dimension chain from a rail, and
which rail is fixed is plant knowledge, never inferred.** The plant rationale
states the chain shape exactly: *rail → first strand, then strand to strand,
then last strand → far edge; N segments locate N−1 strands and the total is the
piece width.*
| key | layer today | what it holds |
| --- | --- | --- |
| `rails.strand_datum` | plant | which edge the chain starts from. Read from the wrong end and the whole pattern mirrors. |
| `rails.left_rail_is_fixed` | **plant, deliberately `null`**; **the job sets it** | whether that rail is the bed's fixed rail. Null in the plant file because it is not a plant-wide truth — the manual labels a FIXED RAIL on the beam beds only; every wall-panel bed labels both sides SIDE RAIL. That one *exists* for panels is stated; **which side is not.** |
| `rails.max_bed_width_in` | plant, **contested** | a single scalar. See below. |
| `rails.narrow_panel` | plant | a narrow panel is **held to the fixed rail**, so the expected offset is zero from it; an asymmetric pattern on such a panel is **expected, not a finding**; floating between the rails is a stated last resort. |
| `beds.<sheet>` | plant | per bed: `panel_width_in`, `strand_ordinates_in` + `ordinates_count` + `ordinates_confidence`, `strand_rows`, `strand_rows_min_in` / `_max_in`, `abutment_kips`, `abutment_cg_in`, `max_strand_cg_in`, `max_strand_dia_in`, `max_kips_per_strand`, interior-bay and outer-bay kips and per-row kips, `jacking_plate_spacing_min_in`, `bed_surface_in` |
**`max_bed_width` is a parked contradiction and this feature must not resolve
it by accident.** The rationale files it as open question A6.1: the live scalar
disagrees with the manual, no wall-panel bed at `plant_a` is that width, the
figure the scalar was reasoned from is a dimension across the bay uprights on
one abutment rather than a panel width, and **a plant with beds of three
different widths cannot be described by one scalar whichever number that scalar
holds.** The cost if the manual is right is stated: on the narrowest bed the
offset budget is fractions of an inch of noise; on the widest it is over two
feet of real slack reported as a failure. `ticketchecks.py` still reads the
scalar for `BED-WIDTH` / `BED-OFFSET` and that was left unchanged on purpose.
**Ruling for this document: the new tool reads the per-bed table, keyed by the
design's declared bed, and reports NOT RUN when no bed is declared.** It does
not read the scalar, and it does not change what Ticket QC reads. That keeps
one contradiction rather than creating a second, and it means the day the owner
rules on A6.1, one check changes and this one does not. Open question 7.
**`beds.strand_ordinates_in` buys a check nothing else can make**, and the
rationale says so outright: the bed sheets print the available strand positions
as a discrete list of drilled ordinates, the section sheets print the layouts as
dimensions from the panel edge, and **the two halves reconcile exactly** — the
resolved standard chain's stations are all in one bed's ordinate list and the
chain sums to that bed's own standard panel width. *A strand at a station this
bed has no hole for is IMPOSSIBLE, not merely unusual.* One caution rides with
it: only one bed's ordinate list is transcribed at confidence A; the others are
**reconstructions at confidence B**, checked against the stated position counts,
and the rationale asks for them to be re-transcribed before anything measures a
real strand against them. So `POST-1.6` reports the confidence of the list it
judged against, in the finding, every time.
### 2.7 The run shape — `tools/sweep_in_revit.py`
Reused whole, and stated in §6. `todo.json`, `scope.json`, `meta/<mark>.json`,
`CHUNK` pieces per bridge call, **a piece with a meta file is done**, delete the
folder to start over. Read only: no transaction, no view change, no selection,
nothing in the undo stack. Scope from `vibe.scope`, and **out of scope is a
third state, not an absence.**
`tools/sweep.py`'s `check_source_model` is the model-mismatch precedent: it
compares each job file's `source.model` stem against the open `doc.Title`,
returns `(mismatched, unstamped, reason)`, treats an unstamped file as
**unjudged and not thereby cleared**, and refuses with a message naming **both**
models. It exists because a whole reference sweep was once re-checked against a
detached copy that had diverged, every report rendered, every number reconciled,
and the two models were told apart by hand.
### 2.8 The overlap with Ticket QC section 4, and the seam between them
This is the single largest reuse risk, so it is stated flatly. `docs/checklist-code-map.md`
already carries:
| item | codes | what it asks |
| --- | --- | --- |
| 4.4 | `STRAND-POS` | modelled strand positions vs the design chain from the stated rail |
| 4.6 | `STRAND-SPACING` / `STRAND-ROWS` / `STRAND-GRID` / `BED-WIDTH` / `BED-OFFSET` | row count and spacing plausible; the pattern's offset against the bed's slack |
| 4.7 | `STRAND-DEBOND` | strand debonded wherever concrete along it runs short |
| 4.5 | `STRAND-CALLOUT` | the ticket calls the debond out |
**The seam: Ticket QC asks "does what is drawn match the design?" This tool
asks "will this design work on this piece?"** Ticket QC compares two documents;
this compares a document against geometry. They overlap in the *quantities* they
compute and not in the question they answer, which is precisely the condition
that produces two implementations that disagree.
So, as a rule with a test behind it: **where the two tools compute the same
quantity, they call the same function.** Named, now, so nobody writes a second
one:
- `ticketpage.bed_offset_status(offset_in, width_in, bed_width_in, left_rail_is_fixed)`
— pure, host-testable, already carries the three-way `left_rail_is_fixed`
handling (False = no fixed rail, slack is spendable; True = a fixed rail, so
the slack is **not** slack, and *no fixed-rail tolerance has been recorded*,
so the offset is **reported, not judged**; None = unrecorded, and the message
says so). This tool passes a different `bed_width_in` (per-bed, §2.6) and gets
the same logic.
- `ticketpage.strand_debond_runs(length_ft, through_openings, strand_positions_in, threshold_ft)`
— already returns `None` for could-not-judge, `[]` for judged-and-clean, and a
run list otherwise, and already documents why a per-strand question needs no
hole-vs-notch fraction of its own.
- `ticketchecks._fit_chain` — see §5.3; it is the existing answer to a problem
this tool has in a harder form.
- `specformat.design_key`, `specformat.strand_positions`.
Where a function has to move to be shared, it moves to the pure side
(`specformat` / a new pure `strandlayout` module) and both callers follow it.
It does not get copied.
---
## 3. Where the button lives
`WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/StrandsAutoLayout.pushbutton/`,
added to the pulldown's `layout:` list — *anything not listed there is not
created.*
House style, read off both existing rows:
- `bundle.yaml` carries **`title:` only**. No tooltip, no author: pyRevit
renders both on hover and the owner wants nothing there, and
`tests/test_button_tooltips.py` guards it. Do not add them back.
- The title is **one line** — a pulldown row is text beside a 16px icon and has
nowhere to put a `\n`. The dropdown grows to the longest row, so the title may
say what it means.
- **`(WIP)` while it is buttons-and-docs only.** Both existing rows carry it and
both say why in their own bundle files: the suffix is what stops a placeholder
being mistaken for a finished tool by anyone who opens the dropdown. Proposed
title: `Strands (WIP)`.
- `__title__` in `script.py` mirrors `bundle.yaml`'s `title:` and is kept in
step, so trimming a bundle file cannot bring an old name back.
- The placeholder script's job, per `GravityLoadChase`: **state what is settled,
state that the rest is not, print the open questions.** No arithmetic, no
knowledge key, no schema field, no data structure invented ahead of the
method. The person who presses the button is in Revit, not in a text editor,
so "see the file" is how a placeholder becomes a dead end — it carries the
substance and a pointer to this document.
---
## 4. The parameters, and where each one lives
The rule applied to every row, stated once so each verdict can be argued
against it:
> **A number with a source — a manual page, a published standard, an
> equipment rating — is knowledge and belongs in a layer. A dialog is for what
> genuinely changes between two presses of the same button on the same job.**
> A plant standard in a dialog is a plant standard that gets retyped, slightly
> differently, every run; and the run that produced last week's report can no
> longer be reproduced, because what it was run against was typed and thrown
> away.
Layer order is the existing one: `defaults → catalogue → plant → job → project
config` (`knowledge._FILE_LAYERS`), deep-merged, most specific wins.
### 4.1 The four the owner named
| parameter | home | meaning and units | absent → |
| --- | --- | --- | --- |
| `max_deflection` | **plant**, `strand.deflection.max_in`. Project Config may make it **stricter only**. | The maximum a strand may be pushed off its bed station, inches. It is a **ceiling on a deflection the design already calls for**, not an amount the tool is licensed to apply — `strand.deflection.is_last_option` ranks deflection below debonding (§2.5). | `POST-1.12` reports NOT RUN naming the missing key. It never defaults: a wrong ceiling silently passes a deflection the plant forbids. |
| `bed_offset` | **measured output, not an input** — plus a **job** fact and an optional **per-run what-if**. | How far the pattern sits off the design chain, inches, signed from `rails.strand_datum`. The tool derives it per piece by fitting the chain (§5.3). The **job** already holds `rails.left_rail_is_fixed`, which decides whether an offset is slack to spend or a finding. A per-run *what-if* offset ("place every piece at +2 in and tell me what breaks") is a legitimate dialog input **and a different quantity** — it must not share the name. | Derived offset unavailable → `POST-1.7` NOT RUN. What-if absent → the tool uses the fitted offset, which is the ordinary case. |
| `max_bed_width` | **plant, per bed** — `beds.<sheet>.panel_width_in`, with `bed_surface_in` beside it. **Which bed** is **job** (proposed `reinforcing.strand_layout.bed`, §2.2). | The clear width available across the bed, inches; the pattern is stressed across the whole bed, so a piece narrower than it has slack `bed width − panel width`. | No bed declared → the bed-station, bed-width, offset and abutment units all report NOT RUN **naming the missing declaration**. The scalar `rails.max_bed_width_in` is **not** used as a fallback (§2.6). |
| `min_clear_cover` | **plant — and the key does not exist yet.** Proposed `strand.min_clear_cover_in`. | Minimum clear concrete between a strand's surface and a free face: the concrete edge, an opening's jamb, and the outer face of the wythe. Inches. **Distinct from `strand.deflection.min_cover_in`**, which applies only to a deflected strand (§2.5). | `POST-1.4` NOT RUN with the key named. **No default. This is a number only the owner can give** — open question 6, and it may be three numbers, not one. |
### 4.2 The "等等" — everything else the survey says is needed
| parameter | home | meaning | absent → |
| --- | --- | --- | --- |
| `rails.strand_datum` | plant *(exists)* | which edge the chain is measured from | fitting is attempted from both edges and the reading is **named in every finding**; see §5.3 |
| `rails.left_rail_is_fixed` | **job** *(exists; plant leaves it null on purpose)* | whether the datum rail is the bed's fixed rail | `bed_offset_status`'s None path: verdicts unchanged, message says the flag is unrecorded |
| `rails.narrow_panel.*` | plant *(exists)* | narrow panels held to the fixed rail; asymmetry expected | the narrow-panel expectation stands down and `POST-1.7` says so |
| `beds.<sheet>.strand_ordinates_in` (+ `ordinates_confidence`) | plant *(exists)* | the drilled stations that physically exist | `POST-1.6` NOT RUN. `null` with `strand_ordinates_unknown_because` is a **declared** absence and reports as NOT APPLICABLE-with-reason, not as a gap |
| `beds.<sheet>.strand_rows_min_in` / `_max_in`, `max_strand_cg_in` | plant *(exists)* | legal row heights through the thickness, and the highest C.G. the abutment is rated for | `POST-1.9` NOT RUN |
| `beds.<sheet>.abutment_kips`, `abutment_cg_in`, `max_kips_per_strand`, `max_strand_dia_in`, interior/outer bay kips and per-row kips | plant *(exists)* | the bed's force ratings | `POST-1.8` NOT RUN. One bed's `abutment_kips` is **`null` with a stated reason** (the sheet shipped with the blank unfilled) — that is a declared unknown and must report as one, never inferred from another bed |
| `beds.<sheet>.jacking_plate_spacing_min_in` | plant *(exists; C.G.-dependent)* | minimum station spacing | `POST-1.13` NOT RUN |
| `strand.debond_below_continuous_ft` | plant *(exists)* | shortest developable run | `POST-1.10` NOT RUN — `strand_debond_runs` already refuses to default it, "a wrong default would silently decide what is this plant's rule to set" |
| `strand.debond_by_blockout` | plant *(exists, no consumer)* | the L/W decision table | `POST-1.11` NOT RUN |
| `strand.edge_strand_in` | plant *(exists)* | the plant's own first-strand dimension | informational; quoted beside `POST-1.5` |
| `interference.tolerance_in`, `exempt_families`, `ignore_family_pairs`, `component_groups`, `within_category_only` | catalogue default + plant + job *(all exist)* | the clash criterion and its exemptions | defaults hold; `interference.check` being **False by default** is a live trap — see open question 9 |
| **`strand.min_clear_to_opening_in`** | **plant — proposed, does not exist** | how close a strand may run to a through opening's jamb | NOT RUN. **Owner's number.** |
| **`strand.max_clear_to_opening_in`** | **plant — proposed, does not exist** | the "too far" half the owner named. *What it means is not obvious and is not guessed here* — open question 4 | NOT RUN |
| **`strand.max_edge_strand_in`** | **plant — proposed, does not exist** | how far in from the concrete edge the outermost strand may sit before the edge is unreinforced | NOT RUN |
| `tolerances.dimension_in` | plant *(exists)* | chain-fit tolerance, already read by 4.4 | its existing default holds |
| design selection, resume/restart, report destination, open-on-finish, the what-if offset | **per-run dialog** | what changes between two presses | dialog defaults; recorded in the run manifest |
**Project Config surface.** `configmodel.SETTINGS` is a hand-maintained table
today with an `edit` layer per row and `configform.unmapped()` + its test failing
on anything unmapped — *a setting added cannot quietly fail to appear.* Under
`plan-panellift-three-stage.md` §10 that table becomes generated from the units
that read each field. Whichever way that lands, three rows here are Project
Config candidates and the rest are not: `max_deflection` (stricter only), the
what-if offset, and the report destination. **Every other row above is a plant
or job fact and must not get a text box.**
---
## 5. The check catalogue
### 5.1 Numbering, and a finding about the scheme
`docs/plan-panellift-three-stage.md` §1.1 sets the grammar
`<SEGMENT>-<group>.<n>` with four mandatory segment tags (`PRE`, `ENG`, `POST`,
`INT`), and defers the tool prefix with a stated condition:
> "If a third tool is ever numbered, the namespace grows a tool prefix
> (`handling/PRE-2.5`). Not now — one level of namespace for one tool is enough,
> and an unused level is a level people put things in."
**That condition is met by this document, and it is met one tool earlier than
the text expects.** Ticket QC numbers its checks bare (`3.3`, `4.6`) and the
handling units are to be `PRE-*` / `ENG-*` / `POST-*` / `INT-*`. This is a
**second** segment-tagged tool, so a bare `PRE-2.5` would be ambiguous between
handling and strands the day both exist — and `checks.muted` is a deep-merged
map keyed on those strings, so the ambiguity would land in the one place that
silently stops a check from checking.
**Ruling for this document: units here are `strands/PRE-1.1`, and handling's are
`handling/PRE-2.5`.** That is a rename of ids that are, today, written down and
not yet built, so it costs a text edit now and an id migration later — and
renaming an id is the one thing that scheme is built to avoid.
**RESOLVED, 2026-09-01 — the coordinator decided rather than leaving it as a
recommendation, owner informed, no objection: the tool prefix is adopted from
day one, not deferred to a third tool.** `docs/plan-panellift-three-stage.md`
§1.1 now states this directly, so what follows in this section is the finding
that forced the ruling, not a still-open recommendation. Open question 1
puts it to the owner. Everything below carries the prefix.
**Two further findings about the scheme, both from trying to fit this feature
into it:**
1. **Composition is preset by `(plant, member_type)` — and that pair is not
unique across tools.** `pipelines.profiles.wall_panel_plant_a` names one
ordered unit list for `plant_a` × wall panel. This tool needs its own list
for the *same pair*. So the profile key needs a tool axis
(`pipelines.profiles.strands.wall_panel_plant_a`), or profiles live under a
per-tool namespace. Not a defect in the idea; a defect in the key. Open
question 2.
2. **The scheme's PRE→ENG seam is a file crossing a process boundary** (Revit's
IronPython to host CPython) and **this tool has no such boundary** — every
unit runs in-process, driven in chunks over the bridge. The scheme's own
§3 already says the three-stage split is a *different axis* from the
in-process/host split and the two must not be conflated, so the segments
still apply: PRE measures, ENG computes plant-blind geometry, POST turns a
number into a verdict against a plant threshold, INT owns the files. What is
genuinely open is whether a tool that never leaves Revit still owes a *deck*.
**Recommendation: yes, for the record reason and not the boundary reason** —
a half-day run whose inputs were not frozen and stamped cannot be reproduced,
and §6.4 needs exactly that stamp to detect a mid-run change. Open question 8.
**Finding codes get an `SL-` prefix.** `CAST-EXTRA` is the precedent
(`plan-panellift-three-stage.md` PRE-2.6: *already a Ticket QC code — do not
reuse*). `BED-WIDTH`, `BED-OFFSET`, `STRAND-SPACING`, `STRAND-POS`,
`STRAND-DEBOND` and `CLASH` are **all live Ticket QC codes** owned by rows in
`CHECKLIST_ITEMS`, and reusing one would mate this tool's findings to Ticket
QC's mutes. So every code below is `SL-*`, uniformly, including the ones with no
collision — a prefix that is applied "only where it is needed" is a prefix
nobody can predict.
**Every unit owns a verdict code and a could-not-judge partner.** Registration
refuses a unit with only one — that is the project's cardinal rule turned into a
gate, and `docs/checklist-code-map.md`'s pairs (`CALLOUT-OVERLAP` /
`CALLOUT-OVERLAP-UNREAD`) are the model. **"Could not judge" is not "no problem
found."** Every unit also owns a **skip probe** on the ticketqc contract: it
returns the *reason* it was quiet, or `None` meaning "it ran and found nothing",
and never `''`.
Provenance column: **[W]** = named by the owner. **[P]** = proposed by this
survey.
### 5.2 `strands/PRE` — reading the design and the piece
| # | unit | codes | could-not-judge case |
| --- | --- | --- | --- |
| `strands/PRE-1.1` | Which pieces carry this design number | `SL-SCOPE` / `SL-SCOPE-UNREAD` | the framing member's design parameter is unmapped or unreadable, or the role `panel.design_number` has no name behind it. **`None` is not a design**, and a piece whose number could not be read is listed as unread, never quietly excluded |
| `strands/PRE-1.2` | The strand pattern, read from the design summary | `SL-PATTERN` / `SL-PATTERN-UNREAD` | no spec for this design; `strand_layout.columns_in` empty; `strand_groups[]` missing `dia_in`. `specformat.validate_spec`'s complaints ride into the finding |
| `strands/PRE-1.3` | The pattern's review state | `SL-REVIEWED` / `SL-UNREVIEWED` | `source.reviewed_by` null → **`SL-UNREVIEWED` on every piece and at the top of the roll-up.** Not a stand-down: the run proceeds and says what it trusted (§5.4) |
| `strands/PRE-1.4` | Which casting bed this design is laid out for | `SL-BED` / `SL-BED-UNDECLARED` | the proposed `strand_layout.bed` absent, or naming a bed the plant table does not have |
| `strands/PRE-1.5` | Which rail is the datum, and whether it is fixed | `SL-RAIL` / `SL-RAIL-UNREAD` | `rails.strand_datum` unmapped. `left_rail_is_fixed: null` is **not** this unit's could-not-judge — it is a recorded unknown that `bed_offset_status` already handles by name |
| `strands/PRE-2.1` | Concrete extents, axes, thickness, layup | `SL-AXES` / `SL-AXES-UNREAD` | `liftexport._axes` / `concrete_extents` / `choose_concrete` cannot resolve a body |
| `strands/PRE-2.2` | Openings, split through vs blind | `SL-OPENING` / `SL-OPENING-UNJUDGED` | `opening_penetration`'s solve failed for a rectangle. **The unjudged rectangle stays on the through side** and the count is reported (§2.3) |
| `strands/PRE-2.3` | The bodies a strand could hit | `SL-BODIES` / `SL-BODIES-UNREAD` | a member's geometry will not solve; an exempt/ignore list is unset where the plant needs one |
| `strands/PRE-2.4` | The synthesised strand bodies | `SL-SYNTH` / `SL-SYNTH-UNREAD` | no diameter on the group, no length axis, or the pattern could not be placed (`PRE-2.5` failed) |
| `strands/PRE-2.5` | **Which of the pattern's stations this piece actually carries** | `SL-SUBSET` / `SL-SUBSET-UNJUDGED` | **the hardest unit in the tool. See §5.3.** |
| `strands/PRE-3.1` | Declare what this run did not produce for this piece | `SL-DECLARE` / `SL-DECLARE-MISSING` | a unit with no declaration row reads NOT RUN — `plan-panellift-three-stage.md` §7.2's word-not-shape rule, adopted whole |
### 5.3 `strands/ENG` — plant-blind geometry
Nothing in this segment reads a plant threshold. Every unit produces a number
or a documented could-not-judge; the word FAIL belongs to POST.
| # | unit | codes | could-not-judge case |
| --- | --- | --- | --- |
| `strands/ENG-1.1` | Resolve the chain to stations from the datum | `SL-CHAIN` / `SL-CHAIN-UNREAD` | segments missing; `total_in` disagrees with the chain's own sum |
| `strands/ENG-1.2` | Fit the pattern onto the piece; derive the offset | `SL-FIT` / `SL-FIT-UNJUDGED` | no fit within `tolerances.dimension_in` from **either** edge reading |
| `strands/ENG-2.1` | Shared thickness, strand vs every other body | `SL-XSECT` / `SL-XSECT-UNJUDGED` | a boolean fails; a body has no solid |
| `strands/ENG-2.2` | Clear distance, strand to each **through** opening's jamb | `SL-DOPEN` / `SL-DOPEN-UNJUDGED` | opening set unjudged; strand station unplaced |
| `strands/ENG-2.3` | Clear distance, strand to each concrete edge and face | `SL-DEDGE` / `SL-DEDGE-UNJUDGED` | extents unread; a non-prismatic edge the box does not describe |
| `strands/ENG-2.4` | Continuous concrete runs along each strand | `SL-RUNS` / `SL-RUNS-UNJUDGED` | `strand_debond_runs` returns `None` — length, positions or threshold missing. **`[]` is a judgement, not a stand-down** |
| `strands/ENG-2.5` | Row heights and the pattern's C.G. through the thickness | `SL-CG` / `SL-CG-UNJUDGED` | rows unread |
| `strands/ENG-2.6` | Station spacing across the width | `SL-PITCH` / `SL-PITCH-UNJUDGED` | fewer than two stations |
| `strands/ENG-3.1` | Total force, per row and per bay, from `it_lb` × qty | `SL-FORCE` / `SL-FORCE-UNJUDGED` | `it_lb` absent from a group; a group whose stations could not be placed |
| `strands/ENG-3.2` | Geometry of the relief an obstruction would need | `SL-RELIEF` / `SL-RELIEF-UNJUDGED` | the obstruction's extent could not be measured. **Produces a required offset; does not choose debond vs deflect** — that ranking is plant policy and lives in POST |
**§5.3a — `strands/PRE-2.5`, and why it is the hardest thing here.**
The job knowledge layer records, for this job, in its own words: *the printed
strand count is the design's full-width maximum, not a per-piece quantity — of
166 pieces on one design, only 86 carry all 14 strands*
(`printed_strand_count_is_per_piece: false`). **So the pattern the owner authors
is a maximum, and each piece carries a subset of it.** A tool that checks all 14
stations on a piece that carries 9 will report five phantom problems per piece,
several hundred times, in a half-day run — and every one of them will look
exactly like a real finding.
What exists to build on: `ticketchecks._fit_chain` already slides the design
chain onto what was **modelled** and keeps *the best contiguous run*, trying
both edge readings and keeping the better fit. That is the mechanism, and it
carries an assumption worth naming: **a subset is contiguous.** Nobody has
confirmed that a piece never drops an interior station.
What is different and harder here: `_fit_chain` has a modelled layout to fit
*against*. This tool may be run on pieces whose strands are not modelled at all
— that is much of the point of running it — and then there is nothing to fit and
the subset must be **derived**, from the piece's width, the datum rail, the
narrow-panel rule (held to the fixed rail, offset zero, asymmetry expected) and
the bed's ordinate list. Three possible answers and **this document does not
choose between them**:
1. derive it, and report every derived station as derived;
2. refuse to derive it and report `SL-SUBSET-UNJUDGED` on any piece whose
strands are not modelled — honest, and possibly quiet on most of the model;
3. take the subset from the modelled strands where they exist and derive it
where they do not, saying per piece which it did.
Recommendation is (3), because it is the only one that is useful on a
part-modelled job and it never hides which mode it used. But **a wrong subset
rule is a wrong finding on every piece of the design**, so this is open question
10 and it is the one to answer first.
### 5.4 `strands/POST` — the verdicts
| # | unit | source | codes | could-not-judge |
| --- | --- | --- | --- | --- |
| `strands/POST-1.1` | Strand interferes with another cast-in body | **[W]** 干涉 | `SL-CLASH` / `SL-CLASH-UNJUDGED` | `ENG-2.1` unjudged; a body whose geometry did not solve; an exemption list the plant has not filled in — reported as *not asked*, never as clean |
| `strands/POST-1.2` | Strand too close to a through opening | **[W]** 距离洞太近 | `SL-OPEN-NEAR` / `SL-OPEN-NEAR-UNREAD` | `strand.min_clear_to_opening_in` unset (**owner's number**), or `ENG-2.2` unjudged |
| `strands/POST-1.3` | Strand too far from a through opening | **[W]** 距离洞太远 | `SL-OPEN-FAR` / `SL-OPEN-FAR-UNREAD` | key unset **and the question itself unsettled** — open question 4 |
| `strands/POST-1.4` | Strand too close to the concrete edge or face (cover) | **[W]** 距离edge太近 | `SL-COVER` / `SL-COVER-UNREAD` | `strand.min_clear_cover_in` unset (**owner's number**); `ENG-2.3` unjudged |
| `strands/POST-1.5` | Outermost strand too far in from the concrete edge | **[W]** 距离edge太远 | `SL-EDGE-FAR` / `SL-EDGE-FAR-UNREAD` | `strand.max_edge_strand_in` unset |
| `strands/POST-1.6` | A station the bed has no drilled hole for | **[P]** | `SL-BED-STATION` / `SL-BED-STATION-UNREAD` | no bed declared; the bed's `strand_ordinates_in` is `null` with `strand_ordinates_unknown_because` (a **declared** absence → NOT APPLICABLE with that reason). **The finding always states the list's `ordinates_confidence`** (§2.6) |
| `strands/POST-1.7` | Piece wider than the bed; pattern offset beyond the slack; narrow panel not held to the fixed rail | **[P]** | `SL-BED-WIDTH` / `SL-BED-OFFSET` / `SL-BED-OFFSET-UNJUDGED` | via `bed_offset_status`, which already returns `None` for "nothing to judge" and already **reports rather than judges** when a fixed rail exists and no fixed-rail tolerance has been recorded |
| `strands/POST-1.8` | Force exceeds the abutment, a bay, a row, or a strand | **[P]** | `SL-ABUTMENT` / `SL-ABUTMENT-UNRATED` | the bed's rating is `null` **with a stated reason** — one bed's sheet shipped with the blank unfilled. **Never inferred from another bed.** `INSERT-NO-FOS` is the precedent: unrated means *not judged*, and it says so by name |
| `strands/POST-1.9` | Row height outside the bed's limits, or C.G. above the rated height | **[P]** | `SL-ROW-HEIGHT` / `SL-ROW-HEIGHT-UNREAD` | limits unset; `ENG-2.5` unjudged |
| `strands/POST-1.10` | A run of concrete too short to develop the strand in it | **[P]** (existing 4.7 logic, new question) | `SL-DEBOND-RUN` / `SL-DEBOND-RUN-UNREAD` | `strand_debond_runs` returned `None`. `[]` = ran and clean, reported as such |
| `strands/POST-1.11` | The blockout's own L/W calls for a debond | **[P]** — **closes a hole** | `SL-DEBOND-LW` / `SL-DEBOND-LW-UNREAD` | `strand.debond_by_blockout` unset; the inside-vs-edge classification unjudged. Carries the table's own note that **engineering shall consider the number of strands in the blockout** — so the finding shortlists, it does not rule |
| `strands/POST-1.12` | A deflection the design calls for exceeds the plant's ceiling, or is proposed where debond is available | **[W]** `max_deflection` | `SL-DEFLECT` / `SL-DEFLECT-UNREAD` | `strand.deflection.max_in` unset. **`is_last_option` is honoured: relief is reported with the plant's ranking, never as a chosen deflection** (§2.5) |
| `strands/POST-1.13` | Stations closer together than the bed's jacking-plate minimum | **[P]** | `SL-PITCH-MIN` / `SL-PITCH-MIN-UNREAD` | the minimum is C.G.-dependent and the C.G. is unjudged; or the key is unset |
| `strands/POST-1.14` | The chain total disagrees with the piece width | **[P]** | `SL-CHAIN-SUM` / `SL-CHAIN-SUM-UNJUDGED` | width unread; `total_in` absent. On a **narrow** panel this is expected, not a defect — `rails.narrow_panel` decides, and where it is unset the unit stands down rather than reporting every narrow piece |
| `strands/POST-1.15` | A strand crosses a through opening at all | **[P]** | `SL-CUT` / `SL-CUT-UNJUDGED` | opening set unjudged. Distinct from `POST-1.2`: this is *through the hole*, not *near it*, and `panellift`'s `prestress.cuts_for` already models what that does to development |
| `strands/POST-2.1` | Per-piece report | — | | |
| `strands/POST-2.2` | The basis block: every threshold, its value's layer, and its source | — | | |
| `strands/POST-2.3` | The unit table: every unit, its state, its reason | `SL-UNIT-TABLE` | | |
| `strands/POST-2.4` | The roll-up across the design: which pieces, which findings, how many NOT RUN and why | `SL-ROLLUP` | | |
| `strands/POST-2.5` | Mute roll-up: how many, by which layer, and which matched nothing | `SL-MUTE-COUNT` / `SL-MUTE-STALE` | | |
**State vocabulary** is `ticketpage.STATE_VOCABULARY`'s, unchanged: `NOT
APPLICABLE` (blue, not judged), `NOT RUN` (red, not judged), `FAIL` (red,
judged), `MUTED` (grey, judged — it ran). `state_color`'s red fallthrough — an
unrecognised word renders **red, never grey** — is what makes a grey state safe
to have, and building a second vocabulary would be a mistake this project has
already made once.
**Mute** follows §8 of the handling plan exactly and gains nothing new:
namespaced keys (`checks.muted: {'strands/POST-1.3': '<reason>'}`), reason
mandatory and refused by `configmodel.validate`, muted rows stay on the table
carrying what they really reported, **muting never softens a FAIL**, and stale
mutes are named. A bare number keeps meaning a Ticket QC master id.
**`SL-UNREVIEWED` and the top of the page.** `strands/PRE-1.3` is a
deliberate departure from "stand down when the input is not trustworthy": the
run **proceeds** on an unreviewed pattern, because the tool is read-only and
refusing would help nobody, and it **says so on every piece and at the head of
the roll-up**. That choice is open question 3 — the alternative is a hard
refusal, which is what the weight gate does and what `sandwich.REFUSAL_CODE`
does, and the owner may prefer it.
### 5.5 `strands/INT` — the boundary
| # | unit | codes |
| --- | --- | --- |
| `strands/INT-1.1` | The run manifest: pattern, thresholds, layer shas, engine version, model identity | `SL-MANIFEST` / `SL-MANIFEST-STALE` |
| `strands/INT-1.2` | Model identity — is this the model the run was started against | `SL-MODEL` / `SL-MODEL-UNSTAMPED` |
| `strands/INT-1.3` | Per-piece progress files, and resume | `SL-RESUME` / `SL-RESUME-MIXED` |
| `strands/INT-1.4` | Per-piece staleness — did this piece change after it was measured | `SL-PIECE-STALE` / `SL-PIECE-STALE-UNJUDGED` |
---
## 6. The run model
> **Owner ruling, 2026-09-01 (Discord):** "strand auto layout这个写完了,记得直接在刚dump的模型里面跑一跑试试。" --
> first trial runs of this feature happen inside a freshly detached
> snapshot model (the sandbox the snapshot cycle produces), never against
> the live model. That is the bring-up environment, on top of the
> standing read-only rule.
### 6.1 Shape on disk
`sweep_in_revit.py`'s shape, in `designspec.sub_dir(doc, 'strands')` — beside
the model, under the same `_ticketqc` root the specs and the reinforcing index
already use, gitignored, and outside the repository entirely when
`WILLTOOLS_JOBS` is set. **It holds job geometry and may never enter the code
repository.**
```
strands/
manifest.json the frozen inputs: design(s), the pattern as read, every
threshold with its value and its layer, the merged
knowledge sha, the spec sha, doc key, doc title, engine
version, started_at
scope.json the declared scope, the line, and what was left alone
todo.json every in-scope piece with this design: id, mark, design
meta/<mark>.json what happened to one piece: status, findings, the unit
table, the piece signature at the time it was measured,
seconds
report/ the rendered per-piece pages and the roll-up
```
`CHUNK` pieces per bridge call, the call returning how many are left. **A piece
with a meta file is done.** Delete the folder to start over.
### 6.2 Cancellation and resume
- **Cancel** = stop pressing the button, or close the dialog. There is no
in-flight state to unwind because there is no transaction: the current chunk
finishes or is abandoned mid-piece, and an abandoned piece simply has no meta
file, so it is re-measured on resume. A chunk of ten is the unit of loss.
- **Resume** = press again. `todo.json` is enumerated **once** and is not
re-read from the scope — so a scope change mid-run does not silently alter
what a resumed run covers, and the way to apply one is to delete the folder,
which is the same rule `sweep_in_revit.py` already states.
- **Resume against different inputs is refused, not silently honoured.**
`manifest.json` freezes the pattern and every threshold at enumeration. If the
spec or a knowledge layer has been edited since, the sha moves and the resume
refuses with `SL-MANIFEST-STALE`, naming **which layer** moved. Half a run
under one rule and half under another produces a report that is internally
inconsistent and looks fine.
### 6.3 Model-change refusal
Two different failures, and only the first has a precedent:
1. **Wrong model.** `sweep.py`'s `check_source_model` / `model_refusal`,
adopted whole: compare the manifest's model stem against `doc.Title`,
case-insensitively, `.rvt` trimmed; a **missing** stamp is `unstamped` and is
**not thereby cleared**; a mismatch refuses, **naming both models**, and
checks nothing. That refusal exists because a whole sweep was once re-checked
against a diverged detached copy and every report rendered.
2. **Same model, changed under the run.** Four hours in, someone moves an
opening. `doc.Title` is unchanged and `check_source_model` passes. **This is
the failure the existing precedent does not cover**, and a half-day run is
the case that produces it.
The nearest mechanism is `reinforcecore.signature` — flags plus the member-id
set, cheap enough to recompute for every assembly in seconds — **but it is
explicitly documented as blind to an opening moved or resized without a
member changing.** `rebuild_all` exists as the escape hatch for exactly that.
So a signature is necessary and not sufficient here.
**Design: stamp per piece, verify at the end, and report — do not pretend
the run was atomic.** Each `meta/<mark>.json` records the piece's signature
*and* a cheap geometry stamp (concrete extents plus the opening rectangle
list, both already read by `PRE-2.1` / `PRE-2.2`, so it costs nothing extra).
On resume and again at completion, the stamps are recomputed and every piece
whose stamp moved is reported as `SL-PIECE-STALE` **with what moved** — its
findings are still shown, marked measured-against-a-previous-state. The
alternative, invalidating and re-measuring, is defensible and is open
question 12; what is not defensible is a report that does not say.
`SL-PIECE-STALE-UNJUDGED` covers the piece that has since been deleted or
whose stamp will not recompute.
### 6.4 Runtime — what is actually known, and what is guessed
Measured anchors in this codebase, all on one real model:
| measurement | figure |
| --- | --- |
| 755 assemblies collected, both flags read (`reinforceindex`) | 0.29 s |
| every assembly's cheap signature | 0.71 s |
| **full geometry build, 690 pieces** | **6.9 s — about 10 ms each** |
| slowest single piece | 0.061 s |
| full interference pipeline, one real piece, all pairs → verdict | **under 1 s** |
| the boolean pass that removes two-thirds of the box hits | ~0.1 s |
| generating a view (`read_ticket` walking a ticket sheet) | **the expensive read in this project** |
**This tool generates no view.** Like `reinforceindex`, it asks elements for
their own geometry. So the dominant cost is the interference pipeline, ~1 s per
piece, plus the opening raycasts and the per-strand distance work — call it a
small multiple of that once the synthesised strands add their own pairs.
**A first-order estimate, and it is an estimate:** on the order of a few
seconds per piece × several hundred pieces of one design ≈ **tens of minutes,
not half a day** — *provided* the sweep-and-prune stages are reused. Skip them
and solve every strand against every body as solids and half a day is exactly
what it costs; that is the difference the five-stage pipeline was built to buy.
Two honest caveats. First, that estimate is arithmetic on other checks'
measurements, not a measurement of this one; nobody has run it. Second, **the
owner expects half a day and he knows his model better than this arithmetic
does** — he may mean the whole job across every design, or a model much larger
than the one the anchors come from. **The resumability in §6.1–6.3 is not
contingent on the estimate.** It is required at ten minutes too: a run that
cannot be resumed is a run nobody starts before lunch.
**A measurement to take before building:** time `PRE-2.1` through `ENG-2.1` on
ten real pieces of one design and multiply. If it lands near half a day, the
pruning is not doing its job and that is a bug to find *before* the tool is
pointed at a whole model.
---
## 7. What it reads from Revit, and what it never touches
**Reads:**
- `AssemblyInstance` collection and each assembly's `GetMemberIds()`;
- the Structural Framing member's design-number parameter, **resolved by role**
(`panel.design_number`) through `knowledge.param_names`, **never by literal
name** — a shared-parameter name is plant knowledge, and a name written into
code is a plant that cannot be served without editing the code;
- the concrete solid's geometry: extents, faces, openings — via
`liftexport.concrete_solid` / `_openings` / `opening_penetration`;
- each member's solids for the interference pipeline (**per solid**, not one box
per element: a 9-solid embed is 3% steel by box volume);
- instance parameters read by role for thickness, width, length;
- the design spec YAML and the four knowledge layers, off disk.
**Never touches:**
- **no `Transaction`, ever.** The bridge and anything touching the production
model reads and never writes. This feature only reports.
- no parameter write, no element create, delete or move;
- no view creation, no view activation, no `Elevation Top` regeneration — which
is also why it is not slow;
- no selection change, no active-view change, nothing in the user's undo stack;
- nothing written anywhere inside the code repository — every output lands under
`designspec.root_dir(doc)`.
`tools/scrub.py --worktree` must stay clean: no model filename, no absolute user
path, no client or plant name in any output that could be pasted into a commit.
---
## 8. Open questions — only the owner can answer these
Liberal, deliberately. A strand layout rule guessed wrong is a real defect in a
real panel, and several of the rows below are numbers that exist in his head and
nowhere in the knowledge layers.
1. **The tool prefix on unit ids** (§5.1). `strands/PRE-1.1` and
`handling/PRE-2.5` from the start, or bare tags until a collision actually
bites? Recommending the prefix now: the handling ids are written down and not
yet built, so this is the cheapest moment it will ever be, and a mute keyed on
an ambiguous id is the failure mode the whole scheme exists to prevent.
2. **The preset key needs a tool axis** (§5.1). `(plant, member_type)` is not
unique across two numbered tools. `pipelines.profiles.strands.wall_panel_plant_a`,
or per-tool profile namespaces?
3. **An unreviewed pattern**: run and label loudly (recommended), or refuse the
way the weight gate refuses? `source.reviewed_by` is null on the specs today.
4. **"Too far from an opening" — what does it mean?** The other three of his
four named classes have obvious geometry; this one does not. Candidate
readings, and none is assumed: (a) an opening whose jamb has no strand within
X, so the trimmer zone is unstressed; (b) a strand that *should* have been
pulled toward an opening to reinforce it and was not; (c) the residual of
"too close" — a gap in the pattern wide enough to matter. **The number and
the meaning are both his.**
5. **Is `strand.deflection.is_last_option` a rule or a preference?** It decides
whether this tool may ever propose a deflection, or may only report that
relief is needed and name the plant's ranking (recommended). If it is a rule,
is the ranking exactly debond-then-deflect, or is there a third option
(re-route, move the obstruction, change the piece)?
6. **`min_clear_cover` — one number or three?** Cover to the concrete edge, to
an opening's jamb, and to the outer face of the wythe are three faces and may
be three numbers, and none of them is the deflected-condition cover already
in `strand.deflection.min_cover_in`. **The key does not exist. Nothing here
invents a value.**
7. **The `max_bed_width` contradiction (A6.1), still parked.** This document
proposes the new tool reads the **per-bed** table and reports NOT RUN with no
bed declared, while Ticket QC keeps reading the scalar unchanged. Is that
acceptable as an interim, or does he want to rule now — scalar goes `null`
and `beds:` is the answer, or the scalar stays and `beds:` keeps only the
grid and the capacities?
8. **Does a tool that never leaves Revit still owe a deck?** Recommending yes,
for reproducibility and mid-run staleness detection, not because a process
boundary demands it.
9. **`interference.check` defaults to `False`.** Its own comment says why: on a
model that draws bars continuously through strands it reports dozens of
by-design crossings per piece until `ignore_family_pairs` is filled in. So
this tool's headline check is off by default and its exemption list is
plant-specific and, for `plant_a`, partly filled. Does `SL-CLASH` run with
whatever exemptions exist and say what it did not exempt, or does it stand
down until the lists are complete? **Reporting hundreds of by-design
crossings across a half-day run would bury every real finding.**
10. **The subset rule** (§5.3a) — *the first question to answer.* A design's
printed strand count is a full-width maximum and a given piece carries a
subset. Is a subset always **contiguous**, as `_fit_chain` assumes? On a
narrow panel held to the fixed rail, which end of the chain is dropped? And
where a piece's strands are not modelled at all, may the tool derive the
subset (recommended, mode-labelled), or must it report unjudged?
11. **Which bed does a design cast on, and who records it?** Proposed as a spec
field; it could as easily be job knowledge keyed by design. Without it, five
units stand down.
12. **A piece that changed mid-run** (§6.3): report it stale and keep its
findings (recommended), or invalidate and re-measure it on resume?
13. **Does this tool report per design or per piece?** He said "所有的这个
design number的framing element", so at minimum a roll-up per design. On
several hundred pieces, is a per-piece page each wanted, or only the pieces
with findings — and is "no findings" a page worth writing so that its absence
is never mistaken for a piece nobody looked at?
14. **May the same run take more than one design?** The button reads one
pattern; the model has many designs. One design per press is simpler and
resumable; all designs in one press is the half-day case he may actually
mean.
15. **Is the `SL-` prefix on every finding code right**, or does he want the
collision-free ones bare? Recommending uniform: a prefix applied only where
it is needed is one nobody can predict.
16. **Does the strand pattern belong to the design summary, or to a piece?**
This document argues design summary throughout (§2.2). If any piece of a
design can legitimately carry a *different* pattern rather than a subset of
one, the whole per-design framing is wrong and it is better to know now.
---
## 9. What I am not sure of
Stated because a design that hides its soft spots gets built and then
discovered.
1. **Whether the synthesised strand bodies can go through the interference
pipeline unmodified.** The pipeline's first stage is a sweep along the
length, and a strand runs the full length — so it prunes nothing against a
strand, and every strand forms a pair with every body. The measured "under a
second per piece" was measured on modelled members, and the strand rows in
those measurements were a handful of bodies. Fourteen synthesised strands
against a few hundred bodies may not prune the way the measurement suggests.
**This is the most likely reason the run is slow, and it is testable in an
afternoon before anything else is built.**
2. **Whether a distance to a rectangular opening in the piece's local frame is
the right measurement.** `_openings` returns axis-aligned rectangles; a real
blockout may be chamfered, sleeved or non-rectangular, and the clear distance
to its actual jamb is not the distance to its bounding rectangle. The error is
in the safe direction for "too close" and the unsafe direction for "too far".
3. **Whether the second wythe is handled.** `strand.both_wythes_same_stations`
says the stations are shared at `plant_a`, `liftexport.layup_of` already
tells solid from sandwich, and `beds.<sheet>.strand_rows` is 2 — but I have
not worked through what a sandwich means for cover to a *face* when the face
in question is an insulation interface rather than air.
4. **Whether `PRE-2.5`'s three modes are really three**, or whether the
derive-it mode is unbuildable without a rule nobody has stated.
5. **Whether `POST-1.11` can classify inside-panel vs at-panel-edge reliably.**
The decision table branches on it, and an opening that breaks the edge versus
one that stops an inch short of it is a judgement the geometry may not make
cleanly. It needs its own could-not-judge and may need a tolerance the owner
sets.
6. **Whether the per-piece geometry stamp in §6.3 catches enough.** Extents plus
the opening rectangle list catches a moved or resized opening — the exact
thing `reinforcecore.signature` is documented as missing — but not a changed
embed that a strand now hits. The member-id set catches an embed added or
removed, not one moved. There may be a third stamp needed, or the honest
answer may be that a long run is re-verified rather than stamped.
7. **The unit catalogue's PRE half is derived from where the existing code is
cut**, not necessarily from where the questions are. A pass over §5.2 with
the owner is worth more than another pass by me.