# 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//specs/.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.` | 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/.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..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..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..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..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..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 `-.` 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 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/.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/.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..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.